Variadic Configuration Functions in Go
At SOON_we regularly use Go for writing and interfacing with RESTy HTTP APIs. We run these APIs in different ways depending on the environment we are in (host names, ports, log levels etc) so we need easily configurable types.
For example, if we need to write a wrapper for MyService
and the host location of the service must be configurable, a first attempt might look something like this:
type MyService struct {
Host string
}func (svc *MyService) GetFoos() ([]Foo, error) { ... }
Which would then be executed like so:
func main() {
svc := &MyService{Host: "localhost:5000"}
foos, err := svc.GetFoos()
}
Ultimately, this is great, however, there are some notable downsides:
- Firstly, we need to export the
Host
attribute on our type, which is not in itself a negative but as our implementation grows, we may need to export more and more attributes so the user can configure them as they need. This in turn could lead to errors if not handled correctly by the end user. Also, your documentation has to be up to scratch to ensure the user is well aware of what needs to be defined and how. - Secondly, by default, variables not explicitly defined with a value will get a zero value for strings that have an empty
""
. If we instantiated our type without explicitly settingHost
then it’s likely requests to our API would fail, unless we had a check in place for this. As our implementation grows we need to add more conditions to our code to ensure correct sane defaults are applied in the instances where attributes on our type held zero values (or nil’s if we want pointers), which can lead to a lot of tedious boiler plate code.
Whilst these issues are not hard to overcome, we can assist our users by introducing a constructor method that creates a new instance of our type that’s ready to use:
type MyService {
host string
}func New(host string) *MyService {
return &MyService{host: host}
}
In the above, we have not exported host
to ensure a user cannot explicitly set it. Instead, a host
string would need to be provided to the constructor, which in turn sets the host
value and returns our type. It is now clear to the user that when constructing the type, we need a host
. One drawback to this approach is how many arguments our New
method needs to make our implementation grow. For example, we could configure 10 different attributes but none of them are required, meaning this solution would end up looking rather ugly and painful for our users to implement since we would need to provide default values for which a user could pass in as arguments for attributes they don’t want to configure.
A common way to combat this is to define a Config
type that would be a single argument you could pass to the constructor:
type Config struct {
Host string
}type MyService {
host string
}func New(config Config) *MyService {
return &MyService{host: config.Host}
}
This allows our implementation to grow into as many configuration options as are needed but which still requires code to ensure the user has set all the correct values and assigned sane defaults where they have not. We are able to do this in our constructor but that might result in it being crufty and potentially prone to errors. Ideally, we need a way for a user to pass in as many configuration options as they want to our constructor and have default sane values set for attributes they don’t.
In Go, a function can take any number of trailing arguments of a specific type, these are called Variadic functions:
func sum(nums ...int) int { ... }
sum(1, 2) // 3
sum(1, 2, 3) // 6
sum(1) // 1
See https://gobyexample.com/variadic-functions
Functions in Go are also first class so we can declare them as types and pass them around like normal variables. This combined with Variadic functions gives us some powerful tools to help resolve the configuration problem.
Firstly, we declare a new type called Option
, which will be a function that takes a single pointer value to a MyService
instance we want to configure but returns nothing.
type MyService struct {
Host string
Logger Logger // Logger interface
}type Option func(svc *MyService)
We can now create a constructor for our MyService
type that takes any number of the Option
type as arguments. We can instantiate a new MyService
with some sane default values and then range over the Option
functions, calling them and passing a pointer to our MyService
instance (since our Option
functions will need to modify the MyService
instance).
func New(opts ...Option) MyService {
svc := MyService{
Host: "localhost:1337",
Logger: log.New(os.Stdout, "MyService:", log.Lshortfile),
}
for _, opt := range opts {
opt(&svc)
}
return svc
}
On top of this, we can also declare some helper Option
functions for the end user to help with configuration:
func WithHost(host string) Option {
return func(svc *MyService) {
svc.Host = host
}
}func WithLogger(logger Logger) Option {
return func(svc *MyService) {
svc.Logger = logger
}
}
These two functions also return functions, which match the Option
type, so we can call these ‘helper functions’ directly in the MyService
constructor passing in our configuration values:
svc1 := New(WithHost(":5000"), WithLogger(&MyCustomLogger{}))
svc2 := New(WithHost(":5000"))
svc3 := New(WithLogger(&MyCustomLogger{}))
svc4 := New()
The most impressive aspect to this is that the user can pass in as many config options as they desire (or not) and MyService
will always get sane defaults for attributes not configured, with no additional conditional logic for it.
The end user could also write their own Option
function if they wanted functionality we don’t provide, so perhaps to set Host
and Logger
all at once:
func WithHostAndLogger(host string, logger myservice.Logger) myservice.Option {
return myservice.Option(svc *MyService) {
svc.Host = host
svc.Logger = logger
}
}...svc := myservice.New(WithHostAndLogger(":8000", &MyLocalLogger{}))
In conclusion, Variadic functions are a great way to provide configuration options easily to the end user. They can be documented via godoc
as we explicitly declare those functions, constructors return types with sane default values and the user can override them as they see fit.