← Back to posts

Go函数选项模式

Go函数选项模式(Functional Options)

写Go的时候,经常会遇到一个问题:一个结构体有很多配置项,有的必填有的选填,怎么设计API才好用?

函数选项模式就是解决这个问题最优雅的方式。


问题是什么

假设我们要创建一个HTTP服务器:

type Server struct {
    host     string
    port     int
    timeout  time.Duration
    maxConn  int
    tls      bool
    certFile string
    keyFile  string
}

方案一:直接传参

func NewServer(host string, port int, timeout time.Duration,
    maxConn int, tls bool, certFile string, keyFile string) *Server {
    return &Server{
        host:     host,
        port:     port,
        timeout:  timeout,
        maxConn:  maxConn,
        tls:      tls,
        certFile: certFile,
        keyFile:  keyFile,
    }
}

问题:参数太多,记不住顺序,不传TLS还得传空字符串。

方案二:传结构体

type ServerConfig struct {
    Host     string
    Port     int
    Timeout  time.Duration
    MaxConn  int
    TLS      bool
    CertFile string
    KeyFile  string
}

func NewServer(cfg ServerConfig) *Server {
    return &Server{
        host:     cfg.Host,
        port:     cfg.Port,
        timeout:  cfg.Timeout,
        maxConn:  cfg.MaxConn,
    }
}

问题:没法区分必填和选填,调用者不知道哪些字段必须设置。而且零值是合法值还是”未设置”?无法区分。

方案三:Builder模式

Go里写Builder太啰嗦了,不符合Go的简洁风格。而且Builder方法没法在goroutine间安全使用(除非加锁)。


函数选项模式

核心思路:用函数作为参数,每个函数负责设置一个选项。

定义选项类型

type Option func(*Server)

一个函数,接收 *Server,修改它的字段。

定义选项函数

func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

func WithMaxConn(max int) Option {
    return func(s *Server) {
        s.maxConn = max
    }
}

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) {
        s.tls = true
        s.certFile = certFile
        s.keyFile = keyFile
    }
}

构造函数

func NewServer(host string, port int, opts ...Option) *Server {
    s := &Server{
        host: host,
        port: port,
        // 设置合理的默认值
        timeout: 30 * time.Second,
        maxConn: 100,
    }

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

    return s
}

调用

// 最简调用
s := NewServer("localhost", 8080)

// 按需配置
s := NewServer("localhost", 8080,
    WithTimeout(10*time.Second),
    WithMaxConn(200),
)

// 开启TLS
s := NewServer("localhost", 443,
    WithTLS("cert.pem", "key.pem"),
    WithMaxConn(500),
)

优点一目了然:

  • 必填参数(host、port)放在前面,选填通过选项设置
  • 调用者只写关心的配置,其他的用默认值
  • 参数有名字,可读性好
  • 新增选项不用改函数签名,向后兼容

实际项目中的例子

标准库和知名项目都在用

grpc-go:

conn, err := grpc.Dial("localhost:50051",
    grpc.WithInsecure(),
    grpc.WithTimeout(5*time.Second),
    grpc.WithBlock(),
)

uber-go/zap:

logger := zap.NewExample(
    zap.WithClock(zap.DefaultClock(zap.ISO8601)),
    zap.WithCaller(false),
)

进阶用法

带校验的选项

选项函数里可以做校验:

func WithMaxConn(max int) Option {
    return func(s *Server) {
        if max <= 0 {
            panic("maxConn must be positive")
        }
        s.maxConn = max
    }
}

更好的做法是让构造函数返回 error:

func NewServer(host string, port int, opts ...Option) (*Server, error) {
    if host == "" {
        return nil, errors.New("host is required")
    }
    if port <= 0 || port > 65535 {
        return nil, errors.New("invalid port")
    }

    s := &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,
        maxConn: 100,
    }

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

    if s.tls && (s.certFile == "" || s.keyFile == "") {
        return nil, errors.New("TLS requires cert and key files")
    }

    return s, nil
}

选项可以组合

func WithProduction() Option {
    return func(s *Server) {
        s.timeout = 60 * time.Second
        s.maxConn = 1000
        s.tls = true
    }
}

// 一行搞定生产配置
s := NewServer("0.0.0.0", 443, WithProduction())

接口型选项(带返回值)

有时候选项需要告诉调用者一些信息:

type Option func(*Server) error

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) error {
        if certFile == "" || keyFile == "" {
            return errors.New("cert and key files are required")
        }
        s.tls = true
        s.certFile = certFile
        s.keyFile = keyFile
        return nil
    }
}

func NewServer(host string, port int, opts ...Option) (*Server, error) {
    s := &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,
    }

    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, err
        }
    }

    return s, nil
}

什么时候该用

适合用的场景:

  • 结构体有多个可选配置项
  • 需要提供合理的默认值
  • API需要长期保持兼容(新增配置不破坏旧代码)
  • 开源库的公开API

不需要用的场景:

  • 只有两三个参数,直接传就行
  • 配置项全是必填的
  • 内部使用的简单结构体

别过度设计,三行能解决的事情不要写成三十行。


完整示例

package server

import (
    "errors"
    "time"
)

type Server struct {
    host     string
    port     int
    timeout  time.Duration
    maxConn  int
    tls      bool
    certFile string
    keyFile  string
}

type Option func(*Server) error

func WithTimeout(d time.Duration) Option {
    return func(s *Server) error {
        if d <= 0 {
            return errors.New("timeout must be positive")
        }
        s.timeout = d
        return nil
    }
}

func WithMaxConn(n int) Option {
    return func(s *Server) error {
        if n <= 0 {
            return errors.New("maxConn must be positive")
        }
        s.maxConn = n
        return nil
    }
}

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) error {
        if certFile == "" || keyFile == "" {
            return errors.New("cert and key files are required for TLS")
        }
        s.tls = true
        s.certFile = certFile
        s.keyFile = keyFile
        return nil
    }
}

func NewServer(host string, port int, opts ...Option) (*Server, error) {
    s := &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,
        maxConn: 100,
    }

    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, err
        }
    }

    return s, nil
}

总结

函数选项模式的三个要点:

  1. 必填参数正常传,选填参数用 ...Option
  2. 构造函数里设默认值,选项函数覆盖默认值
  3. 新增选项不改签名,完美向后兼容

这是Go社区里最成熟的配置模式之一,grpc、zap、kubernetes都用它。如果你的结构体有多个可选配置,用这个模式准没错。