A driver pattern

Writing modular programs

Modular programming implies decoupling abstractions from implementations. Quite often, your program is built atop a particular piece of technology and you realize that it could be easily replaced with something else, keeping all functionalities available and working. At this point you are saying to yourself: “I just need a modular way to pick any of those implementations while writing generic code in the rest of my application”. In other words you are looking for drivers.

Good old drivers

Unlike plugins that leverage a mechanism to extend the set of features offered by a program, drivers focus on offering a strict environment tied to other pieces of code by contract. A contract is the only interaction medium with a driver, putting aside implementations specificities.

The layout

Firsteval we need a driver registry that will reside in the driver package. We will then create a drivers package containing our drivers structured by group. Each group has a register package that eases the import process in the rest of the application and sets build constraints.

.
├── driver
│   └── registry.go
└── drivers
    └── group
        ├── group.go
        ├── driver1
        │   └── driver1.go
        ├── driver2
        │   └── driver2.go
        └── register

It’s all about contracts

Without surprise, the way to enforce a contract is with an interface. Let’s pretend we want to write a sample application that could leverage multiple printing backends.

type Printer interface {
	Open(dest string) error
	Print([]byte) (n int, err error)
	Close() error
}

The driver registry

At some point we will basically need to retrieve the driver implementation from a name i.e. a string. This means that we need drivers to declare themselves to the registry under aliases.

Note: The following code is simplified (it’s likely that the reflect package will send panics directly to the app if you are messing up with types).

package driver

import (
	"reflect"
	"sync"
)

var registry struct {
	contracts sync.Map
	drivers   sync.Map
}

// Declare ties a contract to a group name in the registry. It panics if the
// contract is not an Interface.
func Declare(group string, contract interface{}) {
	if reflect.TypeOf(contract).Elem().Kind() != reflect.Interface {
		panic("Contract is not an Interface for driver group " + group)
	}
	registry.contracts.Store(group, contract)
}

// Load fetches a driver.  It panics if the driver can't be retrieved.
func Load(group, name string) interface{} {
	fqn := fullQualifiedName(group, name)
	driver, ok := registry.drivers.Load(fqn)
	if !ok {
		panic("Unknown driver " + fqn)
	}
	return driver
}

// Register pushes a driver into a registry group with the given name. It
// panics whenever the  group is missing from the registry or the driver is
// not implementing the group contract.
func Register(group, name string, driver interface{}) {
	fqn := fullQualifiedName(group, name)
	contract, ok := registry.contracts.Load(group)
	if !ok {
		panic("Unknown driver group " + group)
	}
	if !reflect.TypeOf(driver).Implements(reflect.TypeOf(contract.Elem()) {
		panic("Unsatisfied contract for driver " + fqn)
	}
	registry.drivers.Store(fqn, driver)
}

func fullQualifiedName(group, name string) string {
	return group + ":" + name
}

Declaring a driver group

Now let’s declare our driver group refering to the Printer interface.

package printer

import "repo/user/project/driver"

func init() {
	driver.Declare("printer", (*Printer)(nil))
}

type Printer interface {
	Open(dest string) error
	Print([]byte) (n int, err error)
	Close() error
}

Writing drivers

We first implement a driver that writes to the console.

package console

import (
	"fmt"

	"repo/user/project/driver"
)

func init() {
	driver.Register("printer", "console", &Console{})
}

type Console struct{}

func (c *Console) Open(string) error {
	return nil
}

func (c *Console) Print(buf []byte) (int, error) {
	return fmt.Print(buf)
}

func (c *Console) Close() error {
	return nil
}

Then we implement a driver that writes to a file.

package file

import (
	"os"

	"repo/user/project/driver"
)

func init() {
	driver.Register("printer", "file", &File{})
}

type File struct {
	dest *os.File
}

func (f *File) Open(dest string) (err error) {
	f.dest, err = os.Create(dest)
	return
}

func (f *File) Print(buf []byte) (int, error) {
	return f.dest.Write(buf)
}

func (f *File) Close() error {
	return f.dest.Close()
}

Drivers at compile time

We want to avoid a build process that includes irrelevant drivers for a target OS, doesn’t allow to exclude unstable drivers for production or build minimal binaries. Fortunately, go already provides all necessary material to elaborate fine grained cross-plateform application builds thanks to build constraints.

As an example, if we declare the following in the register package of our driver group:

// +build !exclude_driver_console

package register

import _ "repo/user/project/drivers/printer/console"

Then this driver will not be built if you pass -tags=exclude_driver_console to the build chain.

Using drivers

Using drivers is trivial, we simply import the register package and the driver group in the proper order.

package main

import (
	"flag"

	"repo/user/project/driver"

	"repo/user/project/drivers/printer"
	_ "repo/user/project/drivers/printer/register"
)

func main() {
	driverName := flag.String("driver", "console", "Printing driver")
	flag.Parse()

	printer := driver.Load("printer", *driverName).(printer.Printer)

	printer.Open("out")
	printer.Print([]byte("Hello world!"))
	printer.Close()
}

Result

$ go run main.go --driver=console
Hello world!

$ go run main.go --driver=file
$ cat out
Hello world!

Now, go write some drivers!