Modular Programming in Go Part 1: Register on init

Modular Programming in Go Part 1: Register on init

Story time 📖

A few weeks back I was tasked with writing config driven black box monitoring tool. The requirements included two different handlers each with their own settings and no overlap. Now, in Python I might reach for dispatch pattern where I select the class of handler based on the Kind specified in my configuration file. This got me thinking, how can I accomplish the same thing in Go?

Well, the go to package for inspecting types in the Go stdlib is reflect, but a glance at the docs shows me no way of inspecting defined types just their methods 🤦🏻‍♀️. After some more reading on the subject I settled on the stdlib plugin package.

So I worked up a demo of this tool with each of the handlers packaged as compiled plugins. It worked well, but it required some runtime helpers and my team lead pointed out that the register on init pattern used by the database/sql package could accomplish the dynamic type selection achieved by my current approach but without the helpers at runtime 🎉.

Let's dive into how this works 🤿

For starters, the whole pattern hinges on the ✨ specialness ✨ of the init() function in Go. The init() function is run on import of a package and regardless of how many times that package is imported, the init() function will only be called once in a given package block.

So, since Go doesn't offer a way for us to obtain listing of declared types, let's just create a mapping of our own and have each of our handler types add themselves to that mapping by declaring an init() function in their respective package blocks.

Define an interface for our Component types and some helpers for mapping them

package components

import (
	"fmt"
	"log"
)

var (
	Registry = make(map[string]Component)
)

type Component interface {
	Greet() string
	UnmarshalSettings([]byte) Component
}

// GetComponent returns the Component specified by name from `Registry`.
func GetComponent(kind string) (Component, error) {
	// check if exists
	if _, ok := Registry[kind]; ok {
		return Registry[kind], nil
	}
	return nil, fmt.Errorf("%s is not a registered Component type", kind)
}

// Register is called by the `init` function of every `Component` to add
// the caller to the global `Registry` map. If the caller attempts to
// add a `Component` to the registry using the same name as a prior
// `Component` the call will log an error and exit.
func Register(kind string, c Component) {
	// check for name collision before adding
	if _, ok := Registry[kind]; ok {
		log.Fatalf("Component: %s has already been added to the registry", kind)
	}
	Registry[kind] = c
}
src/components.go

Define our first Component and use an init() function to register it

package components

import (
	"fmt"

	"github.com/beautifulentropy/go-init-register-pattern/src/components"
	"gopkg.in/yaml.v2"
)

type Component1 struct {
	Input string `yaml:"input"`
}

func (c Component1) Greet() string {
	return fmt.Sprintf(
		"Hello, the input you provided to me was: %s", c.Input)
}

func (c Component1) UnmarshalSettings(settings []byte) components.Component {
	var c1 Component1
	yaml.Unmarshal(settings, &c1)
	return c1
}

func init() {
	components.Register("C1", Component1{})
}
src/components/component1/component1.go

Bring it all together in cmd/main.go

package main

import (
	"log"

	"github.com/beautifulentropy/go-init-register-pattern/src/components"
	_ "github.com/beautifulentropy/go-init-register-pattern/src/components/component1"
	_ "github.com/beautifulentropy/go-init-register-pattern/src/components/component2"
	"gopkg.in/yaml.v2"
)

type config struct {
	Kind     string                 `yaml:"kind"`
	Settings map[string]interface{} `yaml:"settings"`
}

func main() {
	// Component1
	// Our "fake" YAML input for Component 1 contains a key: `input`
	// with a string value.
	c1YAML := []byte(`
kind: C1
settings:
  input: Over 9000
`)
	// Unmarshal our YAML to a config struct so we can access `Kind` and
	// use it's value to fetch the right Component type for `Settings`
	// to get unmarshaled to.
	var c1data config
	yaml.Unmarshal(c1YAML, &c1data)

	// Our component was registered as "C1" by the init method in
	// `src/components/component1`. This was called when we performed
	// our blank import on line 7 above. So we just have to fetch C1
	// from the Registry
	c1, err := components.GetComponent(c1data.Kind)
	if err != nil {
		log.Fatal(err)
	}

	// Marshal just the `Settings` field of `config` back to bytes and
	// pass it to our `Component` type to be unmarshaled.
	c1settings, _ := yaml.Marshal(c1data.Settings)
	c1 = c1.UnmarshalSettings(c1settings)
	log.Printf("type: '%T' says: %q", c1, c1.Greet())
cmd/main.go

What happens when we run this code?

  1. Each init() function is run and our Component types are added to the Registry mapping.
  2. We have some YAML that we load as a config object, so we can access the Kind field and request a Component of config.Kind.
  3. We have our Component of Kind, woot!
  4. All of the keys in config.Settings should be a 1:1 mapping to fields in our Component, so we can just use yaml.Unmarshal to unpack config.Settings into our Component.
  5. To accomplish this, we need config.Settings to be []byte which we can accomplish with yaml.Marshal.
  6. We pass our Component type's UnmarshalSettings() method YAML of config.Settings as []byte.
  7. We get back a shiny Component of config.Kind and when we call the Greet() method, we get exactly what we expect.

Let's run the code and see

2021/03/26 12:05:08 type: 'components.Component1' says: "Hello, the input you provided to me was: Over 9000"
https://dribbble.com/shots/6816387--Go-Ku-The-GoLang-Story-Illustration-Series

Let's add another type and some YAML that makes use of it

package components

import (
	"fmt"

	"github.com/beautifulentropy/go-init-register-pattern/src/components"
	"gopkg.in/yaml.v2"
)

type Component2 struct {
	Input int `yaml:"input"`
}

func (c Component2) Greet() string {
	return fmt.Sprintf("Yeah I'm the other one and the input you provided to me was: %d", c.Input)
}

func (c Component2) UnmarshalSettings(settings []byte) components.Component {
	var c2 Component2
	yaml.Unmarshal(settings, &c2)
	return c2
}

func init() {
	components.Register("C2", Component2{})
}
src/components/component2/component2.go
	// Component2
	c2YAML := []byte(`
kind: C2
settings:
  input: 9001
`)
	var c2data config
	yaml.Unmarshal(c2YAML, &c2data)

	c2, err := components.GetComponent(c2data.Kind)
	if err != nil {
		log.Fatal(err)
	}

	c2settings, _ := yaml.Marshal(c2data.Settings)
	c2 = c2.UnmarshalSettings(c2settings)
	log.Printf("type: '%T' says: %q", c2, c2.Greet())
cmd/main.go (continued)

Let's run our updated code

2021/03/26 12:05:08 type: 'components.Component1' says: "Hello, the input you provided to me was: Over 9000"
2021/03/26 12:05:08 type: 'components.Component2' says: "Yeah I'm the other one and the input you provided to me was: 9001"

What happens if we reference an unregistered Component type?

	// Component3
	c3YAML := []byte(`
kind: C3
settings:
  input: Over 10000
`)
	var c3data config
	yaml.Unmarshal(c3YAML, &c3data)

	// There is no C3 so this should fail.
	c3, err := components.GetComponent(c3data.Kind)
	if err != nil {
		log.Fatalln(err)
	}

	c3settings, _ := yaml.Marshal(c3data.Settings)
	c3 = c3.UnmarshalSettings(c3settings)
	log.Printf("type: '%T' says: %q", c3, c3.Greet())
}
cmd/main.go (continued)

We should be greeted by the first two, but then fail on the third, let's see:

2021/03/26 12:05:08 type: 'components.Component1' says: "Hello, the input you provided to me was: Over 9000"
2021/03/26 12:05:08 type: 'components.Component2' says: "Yeah I'm the other one and the input you provided to me was: 9001"
2021/03/26 12:05:08 C3 is not a registered Component type
exit status 1

💯 🙆🏻‍♀️

Summary

The code for this blog post can be found at the link below.

beautifulentropy/go-init-register-pattern
Repository for a blog post. Contribute to beautifulentropy/go-init-register-pattern development by creating an account on GitHub.