Go program pattern 01: Functional Options Pattern

huizhou92 - Apr 23 - - Dev Community

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
}
Enter fullscreen mode Exit fullscreen mode

How do we use it?

func main() {
    svr := New("localhost", 1234)
    if err := svr.Start(); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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)
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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:

If you found my article enjoyable, feel free to follow me and give it a đź‘Ź. Your support would be greatly appreciated.

. . . . . . . . . . . . . . . . .