一个写过 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 模式最经典案例——选项几十个,业务用啥配啥。
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(...)
|
不要写成 SetTimeout、Timeout 等——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 给你的礼物。