Featured image of post Go 中的 Option 模式:构造函数参数膨胀的优雅解法

Go 中的 Option 模式:构造函数参数膨胀的优雅解法

构造函数参数越加越多怎么办?Option 函数式选项模式让 Go 构造既灵活又类型安全——一篇看懂为什么 K8s / etcd 都用它

一个写过 Go 库都遇到的痛

写一个 HTTP client 库——一开始构造函数很简单:

1
2
3
func NewClient(baseURL string) *Client {
    return &Client{baseURL: baseURL}
}

业务用着用着,需要加超时:

1
func NewClient(baseURL string, timeout time.Duration) *Client { ... }

又要加重试:

1
func NewClient(baseURL string, timeout time.Duration, retries int) *Client { ... }

再加 logger、TLS、middleware、connection pool size、custom headers……一年后构造函数变成这样:

1
2
3
4
5
func NewClient(baseURL string, timeout time.Duration, retries int,
    logger *log.Logger, tlsConfig *tls.Config, middleware []Middleware,
    poolSize int, headers map[string]string, dialer *net.Dialer) *Client {
    // ...
}

调用:

1
2
NewClient("https://api.com", 30*time.Second, 3, logger, nil, nil, 100, nil, nil)
// 这一堆 nil 是什么?

这就是『构造函数参数膨胀』反模式——参数多、含义模糊、加新参数破坏向后兼容。

Go 社区的标准解法是 Functional Options Pattern——Rob Pike 推广,K8s / etcd / gRPC / dapr 等几乎所有大型 Go 项目都在用。


一、Option 模式长什么样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
type Client struct {
    baseURL string
    timeout time.Duration
    retries int
    logger  *log.Logger
    headers map[string]string
}

type Option func(*Client)

func WithTimeout(t time.Duration) Option {
    return func(c *Client) { c.timeout = t }
}

func WithRetries(n int) Option {
    return func(c *Client) { c.retries = n }
}

func WithLogger(l *log.Logger) Option {
    return func(c *Client) { c.logger = l }
}

func WithHeader(k, v string) Option {
    return func(c *Client) {
        if c.headers == nil { c.headers = map[string]string{} }
        c.headers[k] = v
    }
}

func NewClient(baseURL string, opts ...Option) *Client {
    c := &Client{
        baseURL: baseURL,
        timeout: 10 * time.Second,    // 默认值
        retries: 0,
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

调用方:

1
2
3
4
5
6
client := NewClient("https://api.com",
    WithTimeout(30*time.Second),
    WithRetries(3),
    WithLogger(myLogger),
    WithHeader("X-API-Key", apiKey),
)

参数顺序无所谓、可以省略、加新选项不破坏老代码——优雅、灵活、类型安全


二、为什么 Option 比其他方案好

对比方案 1:构造函数重载(Java 风格)

1
2
3
4
func NewClient(baseURL string) *Client { ... }
func NewClientWithTimeout(baseURL string, t time.Duration) *Client { ... }
func NewClientWithLogger(baseURL string, l *log.Logger) *Client { ... }
// ...

Go 不支持重载——只能换名字。组合爆炸:3 个可选参数有 8 种组合,要写 8 个构造函数。

对比方案 2:Config struct

1
2
3
4
5
6
7
8
type Config struct {
    BaseURL string
    Timeout time.Duration
    Retries int
    Logger  *log.Logger
}

func NewClient(cfg Config) *Client { ... }

调用:

1
2
3
4
NewClient(Config{
    BaseURL: "https://api.com",
    Timeout: 30 * time.Second,
})

也不错——但有几个不足:

  • 零值无法区分"没传"和"传了零值"——Retries: 0 是没设置还是显式设置为 0?
  • 不能动态校验/默认值——只能在构造函数里 if-else 兜底
  • 不能"行为型"配置——比如"应用某个 middleware"
  • 不友好对外文档——配置项散在 struct 里

对比方案 3:Builder

1
2
3
4
client := NewClientBuilder("https://api.com").
    Timeout(30 * time.Second).
    Retries(3).
    Build()

Builder 也常见——但要写更多模板代码(Builder 类、Build() 方法、链式 setter),且不如 Option 灵活(无法把多个相关配置打包成一个 helper)。

Option 的独特优势

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Option 可以做"组合配置"
func WithProductionDefaults() Option {
    return func(c *Client) {
        c.timeout = 30 * time.Second
        c.retries = 5
        c.logger = log.Default()
    }
}

// 用户一行搞定一组生产环境配置
client := NewClient(url, WithProductionDefaults(), WithHeader("X-Env", "prod"))

这是 Option 的杀手级特性——把一组配置封装成一个 Option,可复用、可组合、可继承。


三、Option 的进阶用法

1. 必填参数 vs 可选参数

1
2
3
4
5
6
// 必填参数走构造函数位置参数
func NewClient(baseURL string, opts ...Option) *Client { ... }

// 可选参数走 Option
WithTimeout(...)
WithRetries(...)

位置参数表达必填,Option 表达可选——语义清晰。

2. 校验

1
2
3
4
5
6
func WithRetries(n int) Option {
    return func(c *Client) {
        if n < 0 { panic("retries must be >= 0") }
        c.retries = n
    }
}

或者让 Option 返回 error(更优雅但模板代码多):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Option func(*Client) error

func WithRetries(n int) Option {
    return func(c *Client) error {
        if n < 0 { return errors.New("retries must be >= 0") }
        c.retries = n
        return nil
    }
}

func NewClient(baseURL string, opts ...Option) (*Client, error) {
    c := &Client{baseURL: baseURL, ...}
    for _, opt := range opts {
        if err := opt(c); err != nil {
            return nil, err
        }
    }
    return c, nil
}

3. 互斥/依赖检查

构造完成后整体校验——多个 Option 之间的关系,比单个 Option 内更复杂

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func NewClient(baseURL string, opts ...Option) (*Client, error) {
    c := &Client{...}
    for _, opt := range opts {
        opt(c)
    }
    if c.useTLS && c.tlsConfig == nil {
        return nil, errors.New("TLS enabled but no config")
    }
    return c, nil
}

4. Option 加在已有对象上

1
2
3
4
5
6
7
8
9
func (c *Client) Apply(opts ...Option) {
    for _, opt := range opts {
        opt(c)
    }
}

client := NewClient(url, WithTimeout(10*time.Second))
// 后续动态调整
client.Apply(WithTimeout(30*time.Second))

适合配置可热更新的场景。

5. 接口式 Option(隐藏内部字段)

如果不想暴露内部字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Option interface {
    apply(*config)
}

type config struct {
    timeout time.Duration
    retries int
}

type timeoutOption time.Duration

func (t timeoutOption) apply(c *config) { c.timeout = time.Duration(t) }

func WithTimeout(d time.Duration) Option {
    return timeoutOption(d)
}

更复杂——但对外完全不暴露 config 结构。K8s 的 client-go 用了类似模式。


四、Option 模式的"反派"

不是所有场景都该上 Option:

1. 简单 + 字段少

1
func NewQueue(size int) *Queue { ... }

3 个以下参数,直接位置参数最简单——上 Option 是过度设计。

2. 内部数据结构

1
2
3
4
5
6
7
type User struct {
    Name string
    Age  int
}

// 反例:给 User 写 Option?
NewUser(WithName("张三"), WithAge(20))   // ❌ 没必要

业务对象用 struct literal 就够:

1
user := &User{Name: "张三", Age: 20}

3. 一次性参数

1
func Send(msg string, channel string) error

每次调用都不同,根本不需要 Option。


五、几个真实案例

gRPC

1
2
3
4
5
6
7
conn, err := grpc.NewClient(
    "localhost:8080",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultCallOptions(grpc.MaxCallSendMsgSize(10*1024*1024)),
    grpc.WithKeepaliveParams(...),
    grpc.WithUnaryInterceptor(...),
)

gRPC 是 Option 模式最经典案例——选项几十个,业务用啥配啥。

K8s informers

1
2
3
4
5
6
7
8
factory := informers.NewSharedInformerFactoryWithOptions(
    clientset,
    time.Minute,
    informers.WithNamespace("default"),
    informers.WithTweakListOptions(func(opts *metav1.ListOptions) {
        opts.LabelSelector = "app=myapp"
    }),
)

Kubernetes 生态的 informer / controller-runtime / cobra 等库大量使用 Option——典型的"参数多 + 大多数有合理默认 + 经常加新选项"场景。

etcd client

1
2
3
4
cli, err := clientv3.New(clientv3.Config{
    Endpoints: []string{"localhost:2379"},
    DialTimeout: 5 * time.Second,
})

etcd 用了 Config struct——也是 Go 社区认可的姿势。可以混用。


六、踩坑提醒

1. Option 名要规范

约定:WithXxx 格式。

1
2
3
WithTimeout(...)
WithLogger(...)
WithRetries(...)

不要写成 SetTimeoutTimeout 等——With 是 Go 社区共识

2. Option 不要保存状态

1
2
3
4
5
6
7
8
// 反例
var sharedHeader = make(map[string]string)
func WithHeaders(h map[string]string) Option {
    return func(c *Client) {
        sharedHeader = h          // ❌ Option 不该改外部状态
        c.headers = sharedHeader
    }
}

Option 应当是纯函数——只改传入的对象。

3. 不要在 Option 里做重活

1
2
3
4
5
func WithDB(dsn string) Option {
    return func(c *Client) {
        c.db = openDB(dsn)        // ❌ 在 Option 里建连接
    }
}

Option 应当只设值——副作用、IO、初始化都放到 NewClient 主流程。

4. Option 数量过多

如果你的 Option 已经 30 个了——这不是 Option 模式的问题,是 API 设计太复杂。考虑把 Client 拆成多个职责更聚焦的子 Client。


七、Option 是 Go 的"文化"

Java 用 Builder,Python 用 kwargs,TypeScript 用 Options object——每个语言生态都有自己的"配置传递文化"。

Go 的 Option 模式不仅是技术选择——它是 Go 文化的体现

  • 简单:不需要继承、不需要装饰器
  • 显式:每个选项都有自己的函数 + 文档
  • 可读WithTimeout(30*time.Second)cfg.Timeout = 30*time.Second 更像句子

学 Go 不只是学语法——学这种"小语言、强约定"的文化


小结

把全文压一句:

Option 模式是 Go 解决『构造函数参数膨胀』的标准答案——必填走位置参数,可选走 WithXxx Option,灵活、可组合、对外友好。

工程实践:

  • 2-3 个参数用位置参数;4+ 用 Option
  • WithXxx 命名规范
  • Option 是纯函数,不做副作用
  • 校验在 NewXxx 主流程一次性做
  • 可以把多个 Option 组合成预设

下次写 Go 库时——别再让构造函数变成"参数大字报"。Option 是 Go 给你的礼物。

使用 Hugo 构建
主题 StackJimmy 设计