[design mode in Golang] option mode

preface

We can see option patterns in many excellent Go language projects. For example, option patterns are used in the NewServer function of grpc / grpc Go and the New function of Uber Go / zap package.

Using the option pattern, we can implement it very brilliantly -- factory methods or functions with many default values and optionally modify some of them. This makes up for the fact that Go does not have the syntax to set default values for parameters. It greatly simplifies the cost for developers to create an object, especially when the object has many properties.

How to create objects with default parameters

Multiple plant methods

When creating an object, if we want to have some default values, we may prepare multiple factory methods for an object:

package client

import "time"

const (
	DefaultTimeout = 1 * time.Second
)

type DemoClient struct {
	host     string
	timeout  time.Duration
}

func NewDemoClient(host string) *DemoClient{
	return &DemoClient{
		host:    host,
		timeout: DefaultTimeout,
	}
}

func NewDemoClientWithTimeout(host string, timeout time.Duration) *DemoClient{
	return &DemoClient{
		host:    host,
		timeout: timeout,
	}
}

This method is very intuitive, but the scalability is too poor. If you add several parameters, such as adding a maxregistry parameter

package client

import "time"

const (
	DefaultTimeout = 1 * time.Second
	DefaultMaxRetry = 3
)

type DemoClient struct {
	host    string
	timeout time.Duration
	maxRetry int
}

Then should I add:

NewDemoClientWithMaxRetry()
NewDemoClientWithTimeoutAndMaxRetry()

It's crazy to write more parameters. To write a factory method for permutation and combination. omg. Not geek.

Factory that provides the default Option parameter

Elegantly, we can make the factory method accept an Option structure and provide the default Option.

package client

import "time"

const (
	DefaultTimeout  = 1 * time.Second
	DefaultMaxRetry = 3
)

type DemoClient struct {
	host     string
	timeout  time.Duration
	maxRetry int
}

type Options struct {
	Timeout  time.Duration
	MaxRetry int
}

func NewDefaultOptions() Options {
	return Options{
		Timeout:  DefaultTimeout,
		MaxRetry: DefaultMaxRetry,
	}
}

func NewDemoClient(host string, opt Options) *DemoClient {
	return &DemoClient{
		host:     host,
		timeout:  opt.Timeout,
		maxRetry: opt.MaxRetry,
	}
}

In this way, the code for constructing a Client will grow like this:

	opt := NewDefaultOptions()
	opt.Timeout = 3 * time.Second
	client := NewDemoClient("127.0.0.1:8888", opt)

Alternatively, the DefaultOption may not be provided. If the value of 0 is meaningless, it will be directly assigned as the default value. If the value of 0 is meaningful, it will be changed into a pointer in the Option:

package client

import "time"

const (
	DefaultTimeout  = 1 * time.Second
	DefaultMaxRetry = 3
)

type DemoClient struct {
	host     string
	timeout  time.Duration
	maxRetry int
}

type Options struct {
	Timeout  time.Duration
	MaxRetry *int
}

func NewDemoClient(host string, opt Options) *DemoClient {
	client := &DemoClient{
		host:     host,
		timeout:  DefaultTimeout,
		maxRetry: DefaultMaxRetry,
	}
	if opt.Timeout != 0 {
		client.timeout = opt.Timeout
	}
	if opt.MaxRetry != nil {
		client.maxRetry = *opt.MaxRetry
	}
	return client
}

Here, Options.MaxRetry is changed from int to * int, which should be 0. The value is meaningful, which means no retry. In order to enable the default retry, the user must explicitly specify the number of retries and assign a pointer.

In this way, you can easily create a default client.

	client := NewDemoClient("127.0.0.1:8888", Options{})

In fact, this method is quite simple and has good scalability to meet most requirements. If you need to add other parameters with default values, you only need to add them to Options, which can be compatible with previous codes. The only small disadvantage is that you still need to create Options, which is a little troublesome.

If we want to be more geek, we can change the option mode

Option mode

The following code implements the above functions through option mode:

package client

import (
	"time"
)

const (
	DefaultTimeout  = 1 * time.Second
	DefaultMaxRetry = 3
)

type DemoClient struct {
	host     string
	timeout  time.Duration
	maxRetry int
}

type Option func(opt *Options)

type Options struct {
	Timeout  time.Duration
	MaxRetry int
}

var defaultOption = Options{
	Timeout:  DefaultTimeout,
	MaxRetry: DefaultMaxRetry,
}

func WithTimeout(timeout time.Duration) Option {
	return func(opt *Options) {
		opt.Timeout = timeout
	}
}

func WithMaxRetry(maxRetry int) Option {
	return func(opt *Options) {
		opt.MaxRetry = maxRetry
	}
}

func NewDemoClient(host string, opts ...Option) *DemoClient {
	opt := defaultOption
	for _, o := range opts {
		o(&opt)
	}
	return &DemoClient{
		host:     host,
		timeout:  opt.Timeout,
		maxRetry: opt.MaxRetry,
	}
}

In the above code, we define the Option function type, and then use the closure feature of Go language to provide factory methods to return the Option function that modifies each field of Options as needed. In NewDemoClient, first initialize the default Options with defaultOption, and then call back

	for _, o := range opts {
		o(&opt)
	}

Modify the fields in Options. Finally, the object is created.

Here, by using the characteristics of variable parameter list, the default object is also constructed. You only need to:

client := NewDemoClient("127.0.0.1:8888")

There is less Option {} than the previous practice.

With parameters:

client := NewDemoClient("127.0.0.1:8888", WithMaxRetry(3), WithTimeout(3 * time.Second))

And this approach enables us to use the characteristics of closures to achieve a greater degree of flexibility when implementing WithXXXX. such as

// Float around 5% timeout
func WithInaccurateTimeout(around time.Duration) Option {
	return func(opt *Options) {
		rate := float64(95 + rand.Intn(11)) / 100
		opt.Timeout = time.Duration(float64(around) * rate)
	}
}

In this example, the Client can be directly transferred to the Option:

package client

import (
	"time"
)

const (
	DefaultTimeout  = 1 * time.Second
	DefaultMaxRetry = 3
)

type DemoClient struct {
	host     string
	timeout  time.Duration
	maxRetry int
}

type Option func(opt *DemoClient)

func WithTimeout(timeout time.Duration) Option {
	return func(opt *DemoClient) {
		opt.timeout = timeout
	}
}

func WithMaxRetry(maxRetry int) Option {
	return func(opt *DemoClient) {
		opt.maxRetry = maxRetry
	}
}

func NewDemoClient(host string, opts ...Option) *DemoClient {
	client := &DemoClient{
		host:     host,
		timeout:  DefaultTimeout,
		maxRetry: DefaultMaxRetry,
	}
	for _, o := range opts {
		o(client)
	}
	return client
}

However, it can not be simplified all the time. For example, there are parameters that are valid only during construction and do not need to be stored in the field of the object.

epilogue

Option mode has many advantages. For example, it supports passing multiple parameters and maintains compatibility when parameters change; Support the transfer of parameters in any order; Support default values; Convenient expansion; Through the function naming of WithXXX, the meaning of parameters can be more clear, and so on.

However, the disadvantage is that in order to implement the option mode, we have significantly added a lot of code. In actual development, we should choose whether to use the option mode according to the scenario.

This is just an example of how factory functions use the option mode. Functions in the general sense can also be used. For example, the rpc method in grpc is designed in the option mode.

Option mode is generally applicable to the following scenarios:

  • There are many structure parameters. When creating a structure, we expect to create a structure variable with default values and selectively modify the values of some parameters.
  • Structure parameters often change. Considering future expansion, we don't want to modify the function that creates the instance.

If there are few structural parameters, you can carefully consider whether to adopt the Option mode. Maybe it is enough to directly use the factory with Option.

reference resources

Functional Options Pattern in Go
Option mode of golang design mode

Tags: Go Design Pattern

Posted on Sun, 19 Sep 2021 21:45:42 -0400 by Zippyaus