Go is not a fully object-oriented language, and some object-oriented patterns are not well-suited for it. However, over the years, Go has developed its own set of patterns. Today, I would like to introduce a common pattern: the Functional Options Pattern.
This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.
What is the Functional Options Pattern?
Go does not have constructors like other languages. Instead, it typically uses a New
function to act as a constructor. However, when a structure has many fields that need to be initialized, there are multiple ways to do so. One preferred way is to use the Functional Options Pattern.
The Functional Options Pattern is a pattern for constructing structs in Go. It involves designing a set of expressive and flexible APIs to help configure and initialize the struct.
The Go Language Specification by Uber mentions this pattern:
Functional options are a pattern in which you declare an opaque
Option
type that records information in some internal structure. You accept these variable numbers of options and operate on the complete information recorded by the options on the internal structure.Use this pattern for optional parameters in constructors and other public APIs where you expect these parameters to be extended, especially when there are already three or more parameters on these functions.
An Example
To better understand this pattern, let's walk through an example.
Let's define a Server
struct:
package main
type Server struct {
host string
port int
}
func New(host string, port int) *Server {
return &Server{
host,
port,
}
}
func (s *Server) Start() error {
return nil
}
How do we use it?
func main() {
svr := New("localhost", 1234)
if err := svr.Start(); err != nil {
log.Fatal(err)
}
}
But what if we want to extend the configuration options for the Server
? There are generally three approaches:
- Declare a new constructor function for each different configuration option.
- Define a new
Config
struct to store the configuration information. - Use the Functional Options Pattern. ### Approach 1: Declare a new constructor function for each different configuration option
This approach involves defining dedicated constructor functions for different options. Let's say we added two fields to the Server
struct:
type Server struct {
host string
port int
timeout time.Duration
maxConn int
}
Typically, host
and port
are required fields, while timeout
and maxConn
are optional. We can keep the original constructor function and assign default values to these two fields:
func New(host string, port int) *Server {
return &Server{
host,
port,
time.Minute,
100,
}
}
Then, we can provide two additional constructor functions for timeout
and maxConn
:
func NewWithTimeout(host string, port int, timeout time.Duration) *Server {
return &Server{
host,
port,
timeout,
100,
}
}
func NewWithTimeoutAndMaxConn(host string, port int, timeout time.Duration, maxConn int) *Server {
return &Server{
host,
port,
timeout,
maxConn,
}
}
This approach works well for configurations that are unlikely to change frequently. Otherwise, you would need to create new constructor functions every time you need to add a new configuration. This approach is used in the Go standard library, such as the Dial
and DialTimeout
functions in the net
package:
func Dial(network, address string) (Conn, error)
func DialTimeout(network, address string, timeout time.Duration) (Conn, error)
Approach 2: Use a dedicated configuration struct
This approach is also common, especially when there are many configuration options. Typically, you create a Config
struct that contains all the configuration options for the Server
. This approach allows for easy extension without breaking the API of the Server
, even when adding more configuration options in the future.
type Server struct {
cfg Config
}
type Config struct {
Host string
Port int
Timeout time.Duration
MaxConn int
}
func New(cfg Config) *Server {
return &Server{
cfg,
}
}
When using this approach, you need to construct a Config
instance first, which brings us back to the original problem of configuring the Server
. If you modify the fields in Config
, you may need to define a constructor function for Config
if the fields are changed to private.
Approach 3: Use the Functional Options Pattern
A better solution is to use the Functional Options Pattern.
In this pattern, we define an Option
function type:
type Option func(*Server)
The Option
type is a function type that takes a *Server
parameter. Then, the constructor function for Server
accepts a variable number of Option
types as parameters:
func New(options ...Option) *Server {
svr := &Server{}
for _, f := range options {
f(svr)
}
return svr
}
How do the options work? We need to define a series of related functions that return Option
:
func WithHost(host string) Option {
return func(s *Server) {
s.host = host
}
}
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeout(timeout time.Duration) Option {
return func(s *Server) {
s.timeout = timeout
}
}
func WithMaxConn(maxConn int) Option {
return func(s *Server) {
s.maxConn = maxConn
}
}
To use this pattern, the client code would look like this:
package main
import (
"log"
"server"
)
func main() {
svr := New(
WithHost("localhost"),
WithPort(8080),
WithTimeout(time.Minute),
WithMaxConn(120),
)
if err := svr.Start(); err != nil {
log.Fatal(err)
}
}
Adding new options in the future only requires adding corresponding WithXXX
functions.
This pattern is widely used in third-party libraries, such as github.com/gocolly/colly
:
type Collector struct {
// ...
}
func NewCollector(options ...CollectorOption) *Collector
// Defines a series of CollectorOptions
type CollectorOption struct {
// ...
}
func AllowURLRevisit() CollectorOption
func AllowedDomains(domains ...string) CollectorOption
...
However, when Uber's Go Programming Style Guide mentions this pattern, it suggests defining an Option
interface instead of an Option
function type. This Option
interface has an unexported method, and the options are recorded in an unexported options
struct.
Can you understand Uber's example?
type options struct {
cache bool
logger *zap.Logger
}
type Option interface {
apply(*options)
}
type cacheOption bool
func (c cacheOption) apply(opts *options) {
opts.cache = bool(c)
}
func WithCache(c bool) Option {
return cacheOption(c)
}
type loggerOption struct {
Log *zap.Logger
}
func (l loggerOption) apply(opts *options) {
opts.logger = l.Log
}
func WithLogger(log *zap.Logger) Option {
return loggerOption{Log: log}
}
// Open creates a connection.
func Open(
addr string,
opts ...Option,
) (*Connection, error) {
options := options{
cache: defaultCache,
logger: zap.NewNop(),
}
for _, o := range opts {
o.apply(&options)
}
// ...
}
Summary
In real-world projects, when dealing with a large number of options or options from different sources (e.g., from files or environment variables), consider using the Functional Options Pattern.
Note that in actual work, we should not rigidly apply the pattern as described above. For example, in Uber's example, the Open
function does not only accept a variable number of Option
parameters because the addr
parameter is required. Therefore, the Functional Options Pattern is more suitable for cases with many configurations and optional parameters.
References:
- https://golang.cafe/blog/golang-functional-options-pattern.html
- https://github.com/uber-go/guide/blob/master/style.md#functional-options
If you found my article enjoyable, feel free to follow me and give it a đź‘Ź. Your support would be greatly appreciated.