random things are here

Implementing pluggable backends in go

· Read in about 9 min · (1728 Words)
go

When writing a component like a storage backend you often want a way to switch between different implementations like memory, sqlite, or redis. Another common example is authentication backends for LDAP or Kerberos.

With interfaces in go you can have multiple implementations of a common API, but it takes a little more work to make them pluggable.1 The goal is that we want to be able to have a configuration file with a section like

"store" {
    "type": "boltdb",
    "options": {
        "filename": "my.db"
    }
}

to create an arbitrary backend with the appropriate options.

Let’s write a simple frontend library for a key value store and work up to pluggable backends. The first step is to decide on the interface:

type KVStore interface {
    Get(key string) (string, error)
    Set(key, value string) error
    Delete(key string) (error)
}

var NoSuchKey = errors.New("No such key")

First interface implementation

With the interface decided on, let’s write an in-memory implementation using a map:

type MemoryKVStore struct {
    store map[string]string
}

func NewMemoryKVStore() *MemoryKVStore {
    store := make(map[string]string)
    return &MemoryKVStore{store: store}
}

func (m *MemoryKVStore) Get(key string) (string, error) {
    val, ok := m.store[key]
    if !ok {
        return "", NoSuchKey
    }
    return val, nil
}
func (m *MemoryKVStore) Set(key string, value string) error {
    m.store[key] = value
    return nil
}

func (m *MemoryKVStore) Delete(key string) error {
    delete(m.store, key)
    return nil
}

At this point we can write a main function and do an initial test:

func main() {
    kv := NewMemoryKVStore()

    fmt.Println(kv.Get("test"))
    fmt.Println(kv.Set("test", "success"))
    fmt.Println(kv.Get("test"))
    fmt.Println(kv.Delete("test"))
    fmt.Println(kv.Get("test"))
}

Which outputs the expected:

 No such key
<nil>
success <nil>
<nil>
 No such key

A real test case would be better, so let’s write one. I can plan ahead for multiple implementations by using the sub tests feature in go 1.7. Instead of testing the MemoryKVStore directly, I can test the KVStore interface:

import "testing"

func storeTestHelper(t *testing.T, store KVStore) {
    if _, err := store.Get("test"); err != NoSuchKey {
        t.Fatalf("Expected NoSuchKey, got %q", err)
    }
    if err := store.Set("test", "success"); err != nil {
        t.Fatalf("Expected nil, got %q", err)
    }
    if val, _ := store.Get("test"); val != "success" {
        t.Fatalf("Expected success, got %q", val)
    }
    if err := store.Delete("test"); err != nil {
        t.Fatalf("Expected nil, got %q", err)
    }
    _, err := store.Get("test")
    if err != NoSuchKey {
        t.Fatalf("Expected NoSuchKey, got %q", err)
    }
}

func TestKVStore(t *testing.T) {
    t.Run("backend=MemoryStore", func(t *testing.T) {
        kv := NewMemoryKVStore()
        storeTestHelper(t, kv)
    })
}

2nd interface implementation

One copy & paste, search & replace, and a few lines of code later, a 2nd Implementation is ready:

type BoltDBStore struct {
    db *bolt.DB
}

var bucketID = []byte("storage")

func NewBoltStore(filename string) (*BoltDBStore, error) {
    db, err := bolt.Open(filename, 0600, nil)
    if err != nil {
        return nil, err
    }
    err = db.Update(func(tx *bolt.Tx) error {
        _, err = tx.CreateBucketIfNotExists(bucketID)
        if err != nil {
            return fmt.Errorf("create bucket: %s", err)
        }
        return nil
    })
    if err != nil {
        return nil, err
    }

    return &BoltDBStore{db: db}, nil
}

func (b *BoltDBStore) Get(key string) (string, error) {
    var val []byte
    err := b.db.View(func(tx *bolt.Tx) error {
        bucket := tx.Bucket(bucketID)
        val = bucket.Get([]byte(key))
        return nil
    })
    if err != nil {
        return "", err
    }
    if val == nil {
        return "", NoSuchKey
    }
    return string(val), nil

}
func (b *BoltDBStore) Set(key string, value string) error {
    return b.db.Update(func(tx *bolt.Tx) error {
        bucket := tx.Bucket(bucketID)
        err := bucket.Put([]byte(key), []byte(value))
        return err
    })
}

func (b *BoltDBStore) Delete(key string) error {
    return b.db.Update(func(tx *bolt.Tx) error {
        bucket := tx.Bucket(bucketID)
        err := bucket.Delete([]byte(key))
        return err
    })
}

and hooked up to the tests:

t.Run("backend=BoltDBStore", func(t *testing.T) {
    dir, err := ioutil.TempDir("", "bolttest")
    defer os.RemoveAll(dir) // clean up
    kv, err := NewBoltStore(dir + "/db")
    if err != nil {
        t.Fatal(err)
    }
    storeTestHelper(t, kv)
})

Pluggable problems

At this point, we already start to run into trouble making these backends plugable. The new BoltDBStore implements the same interface as MemoryKVStore, but the NewBoltStore has a different signature from NewMemoryKVStore.

Changing MemoryKVStore to return a nil error is easy, but it doesn’t make sense to add an unused filename parameter.

The solution is.. more types

Option types

type MemStoreOptions struct {
}

func NewMemoryKVStore(options MemStoreOptions) (*MemoryKVStore, error) {
    store := make(map[string]string)
    return &MemoryKVStore{store: store}, nil
}


type BoltStoreOptions struct {
    filename string
}

func NewBoltStore(options BoltStoreOptions) (*BoltDBStore, error) {
    db, err := bolt.Open(options.filename, 0600, nil)
    ...

Now, these two New functions take almost the same interface, aside from MemStoreOptions and BoltStoreOptions not being the same type.

To solve this problem, we can use a type assertion.

Generic NewStore

func NewStore(backend string, options interface{}) (KVStore, error) {
    switch backend {
    case "memory":
        opts, ok := options.(MemStoreOptions)
        if !ok {
            return nil, fmt.Errorf("Invalid memory options %q", options)
        }
        return NewMemoryKVStore(opts)
    case "boltdb":
        opts, ok := options.(BoltStoreOptions)
        if !ok {
            return nil, fmt.Errorf("Invalid Bolt options %q", options)
        }
        return NewBoltStore(opts)
    default:
        return nil, fmt.Errorf("Unknown store: %s", backend)
    }
}

With this written, the tests can be updated:

func TestKVStore(t *testing.T) {
    t.Run("backend=MemoryStore", func(t *testing.T) {
        kv, _ := NewStore("memory", MemStoreOptions{})
        storeTestHelper(t, kv)
    })
    t.Run("backend=BoltDBStore", func(t *testing.T) {
        dir, err := ioutil.TempDir("", "bolttest")
        defer os.RemoveAll(dir) // clean up
        kv, err := NewStore("boltdb", BoltStoreOptions{filename: dir + "/db"})
        if err != nil {
            t.Fatal(err)
        }
        storeTestHelper(t, kv)
    })
}

This is almost everything we need to implement our config file.

Configuration file

First, define a struct to hold the configuration:

type StoreConfig struct {
    Backend string                 `json:"backend"`
    Options map[string]interface{} `json:"options"`
}

type Configuration struct {
    Store StoreConfig `json:"store"`
}

Second, define two test configuration files:

var testMemConfig = []byte(`
{
    "store": {
        "backend": "memory"
    }
}
`)

var testBoltConfig = []byte(`
{
    "store": {
        "backend": "boltdb",
        "options": {
            "filename": "config_test_tempory.db"
        }
    }
}
`)

Now, we write a function that can take an io.Reader for a configuration, and return a store:

NewStoreFromConfig(r io.Reader) (KVStore, error) {
    var cfg Configuration 
    err := json.NewDecoder(r).Decode(&cfg)
    if err != nil {
        return nil, err
    }
    return NewStore(cfg.Store.Backend, cfg.Store.Options)
}

The problem is, this does not work! cfg.Store.Options is a map, but we need an Interface{}:

--- FAIL: TestKVStore (0.00s)
    --- FAIL: TestKVStore/backend=MemoryStore (0.00s)
        kv_test.go:51: Invalid memory options map[]
    --- FAIL: TestKVStore/backend=BoltDBStore (0.00s)
        kv_test.go:60: Invalid Bolt options map["filename":"config_test_tempory.db"]

The easy way to fix this is to update the store backends to have a function that converts the options into the right interface and return a store:

func NewMemoryStoreFromMap(options map[string]interface{}) (*MemoryKVStore, error) {
    //Nothing to do here
    opts := MemStoreOptions{}
    return NewMemoryKVStore(opts)
}

func NewBoltStoreFromMap(options map[string]interface{}) (*BoltDBStore, error) { 
    opts := BoltStoreOptions{}
    opts.filename = options["filename"].(string)
    return NewBoltStore(opts)
}

A lot of fancy reflection can do this in a generic way, but this keeps things simple.

With those two functions in place, NewStoreFromConfig can be rewritten:

func NewStoreFromConfig(r io.Reader) (KVStore, error) {
    var cfg Configuration
    err := json.NewDecoder(r).Decode(&cfg)
    if err != nil {
        return nil, err
    }
    switch cfg.Store.Backend {
    case "memory":
        return NewMemoryStoreFromMap(cfg.Store.Options)
    case "boltdb":
        return NewBoltStoreFromMap(cfg.Store.Options)
    default:
        return nil, fmt.Errorf("Unknown store: %s", cfg.Store.Backend)
    }
}

And the tests can be updated to use NewStoreFromConfig

func TestKVStore(t *testing.T) {
    t.Run("backend=MemoryStore", func(t *testing.T) {
        kv, err := NewStoreFromConfig(bytes.NewReader(testMemConfig))
        if err != nil {
            t.Fatal(err)
        }
        storeTestHelper(t, kv)
    })
    t.Run("backend=BoltDBStore", func(t *testing.T) {
        os.RemoveAll("config_test_tempory.db")       // clean up
        defer os.RemoveAll("config_test_tempory.db") // clean up
        kv, err := NewStoreFromConfig(bytes.NewReader(testBoltConfig))
        if err != nil {
            t.Fatal(err)
        }
        storeTestHelper(t, kv)
    })
}

One more thing

This is fully functional but it can still be improved. NewStoreFromConfig has to have knowledge about each store type. It would be better if each store was 100% self contained. This problem can be solved by having store backends register themselves into a map.

First, to simplify things, write a NewStoreFromInterface for each backend:

func NewBoltStoreFromInterface(options interface{}) (*BoltDBStore, error) {
    opts, ok := options.(BoltStoreOptions)
    if !ok {
        return nil, fmt.Errorf("Invalid boltdb options %q", options)
    }
    return NewBoltStore(opts)
}

func NewMemoryKVStoreFromInterface(options interface{}) (*MemoryKVStore, error) {
    opts, ok := options.(MemStoreOptions)
    if !ok {
        return nil, fmt.Errorf("Invalid memory options %q", options)
    }
    return NewMemoryKVStore(opts)
}

This moves the type assertions to the backends and simplifies the NewStore function to:

func NewStore(backend string, options interface{}) (KVStore, error) {
    switch backend {
    case "memory":
        return NewMemoryKVStoreFromInterface(options)
    case "boltdb":
        return NewBoltStoreFromInterface(options)
    default:
        return nil, fmt.Errorf("Unknown store: %s", backend)
    }
}

At this point, NewStore is effectively a map from

memory -> NewMemoryKVStoreFromInterface
boltdb -> NewBoltStoreFromInterface

and NewStoreFromConfig is a map from:

memory -> NewMemoryStoreFromMap
boltdb -> NewBoltStoreFromMap

Having two functions like this is a little awkward. A grouping of similar functions is exactly what an interface is for, so let’s build another interface for creating stores:

type KVStoreDriver interface {
    NewFromInterface(interface{}) (KVStore, error)
    NewFromMap(map[string]interface{}) (KVStore, error)
}

And create a map of strings to Drivers and a function to manage them:

var drivers = make(map[string]KVStoreDriver)

func Register(backend string, driver KVStoreDriver) {
    drivers[backend] = driver
}

And then move our New functions into this interface:

type MemoryStoreDriver struct {
}

func (d MemoryStoreDriver) NewFromMap(options map[string]interface{}) (KVStore, error) {
    //Nothing to do here
    opts := MemStoreOptions{}
    return NewMemoryKVStore(opts)
}
func (d MemoryStoreDriver) NewFromInterface(options interface{}) (KVStore, error) {
    opts, ok := options.(MemStoreOptions)
    if !ok {
        return nil, fmt.Errorf("Invalid memory options %q", options)
    }
    return NewMemoryKVStore(opts)
}

And the final ‘trick’ that will allow this to work: init inside each driver:

func init() {
    Register("memory", MemoryStoreDriver{})
}

With that in place, NewStore and NewStoreFromConfig can be rewritten to simply look up the driver in the map:

func NewStore(backend string, options interface{}) (KVStore, error) {
    driver, ok := drivers[backend]
    if !ok {
        return nil, fmt.Errorf("Unknown store: %s", backend)
    }
    return driver.NewFromInterface(options)
}

func NewStoreFromConfig(r io.Reader) (KVStore, error) {
    var cfg Configuration
    err := json.NewDecoder(r).Decode(&cfg)
    if err != nil {
        return nil, err
    }
    driver, ok := drivers[cfg.Store.Backend]
    if !ok {
        return nil, fmt.Errorf("Unknown store: %s", cfg.Store.Backend)
    }
    return driver.NewFromMap(cfg.Store.Options)
}

At this stage the backends are fully pluggable and the core of the kvstore library has zero knowledge of any individual backend implementation. Adding a new backend is simply a matter of importing a package that uses Register to add a new KVStoreDriver that implements the right interfaces.

See also

The code for this post is available at go-interface-blog-post .

The implementation of the sql package does something similar. The main difference is that the open function takes a single string as an argument. This works, but forces option values to be serialized into a string like user=pqgotest dbname=pqgotest sslmode=verify-full


  1. In python, we would write backends[type](**options) and call it a day. [return]