大家好,我是十三!欢迎来到十三Tech。
前阵子 review 一个 HTTP client 封装,构造函数有 11 个参数:超时、重试次数、连接池大小、TLS 配置、代理、UA、headers、cookie jar……调用方代码里每个 NewClient(...) 调用都是一长串参数,光数逗号就累。更要命的是大部分参数都有默认值,调用方为了传第 11 个参数,前 10 个全得抄一遍默认值。
我提了个 PR 改成 Functional Options 风格,结果被同事质疑"为什么不用经典的 Builder 模式"。聊了一下午发现大家对"建造者模式"的理解都不一样——有人觉得是链式调用,有人觉得是 Director + Builder,有人觉得就是参数对象。
这一篇就来聊聊 Go 里建造者模式到底有几种写法,什么场景用哪种,以及为什么 Go 社区最终选择了 Functional Options。
一、建造者在解决什么——不是"分步构建"
教科书定义:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。这话听起来很哲学,但翻译成工程语言就一句话——当构造函数参数多到没法维护时,把参数收集过程拆成多个步骤。
很多人误把建造者当成"链式调用"的同义词。链式调用只是建造者的一种实现风格,不是建造者本身。建造者的核心价值是让参数收集和对象构造分离——你可以慢慢收集参数,最后一次性构造不可变对象。
判断口诀:构造参数 ≥ 5 个、或大部分参数有默认值、或同一个对象有多种配置组合 → 建造者;参数 ≤ 3 个 → 构造函数够了。
回头看我那个 11 参数的 HTTP client,它需要建造者不是因为它"复杂",而是因为调用方经常只关心 1-2 个参数,其他都用默认值。这种"稀疏配置"场景就是建造者的地盘。
二、Go 里建造者的三种写法,怎么选
Java 时代的经典 Builder 长这样:一个 Builder 类负责收集参数,最后 build() 返回目标对象。Go 里这种写法能跑,但显得啰嗦。Go 社区演化出了三种更地道的写法,适用场景完全不同。
写法一:参数对象(Config 结构体)——最简单直接,适合参数有明确结构、且大部分场景都传大部分参数的场景:
type HTTPClientConfig struct {
Timeout time.Duration
RetryCount int
TLSConfig *tls.Config
Headers map[string]string
}
func NewClient(cfg HTTPClientConfig) *Client { /* ... */ }
调用方代码很干净:NewClient(HTTPClientConfig{Timeout: 5*time.Second})。零值字段自动用默认值,不用显式传。database/sql.DB、net/http.Server 都用这种风格。Go 工程里 80% 的"参数多"场景用 Config 结构体就够了。
写法二:链式 Builder——适合参数之间有顺序或约束的场景:
client := NewClientBuilder().
WithTimeout(5*time.Second).
WithRetry(3).
Build()
这种写法读起来流畅,但要写一堆 With* 方法,且链式调用让"必传参数"难以表达(编译期不报错,运行期才发现 Build() 缺了关键参数)。Go 标准库基本不用这种风格,更多见于第三方库(如 gorm、prometheus/client_golang)。
写法三:Functional Options——Go 社区的主流选择,Dave Cheney 在 2014 年推广后成了事实标准:
type Option func(*Client)
func WithTimeout(d time.Duration) Option {
return func(c *Client) { c.timeout = d }
}
func NewClient(opts ...Option) *Client {
c := &Client{timeout: 30*time.Second} // 默认值
for _, opt := range opts { opt(c) }
return c
}
调用方:NewClient(WithTimeout(5*time.Second), WithRetry(3))。这种写法的精髓是——默认值在构造函数里给死,调用方只传"想修改"的字段。gRPC-go、ceph/go-ceph、kubernetes/client-go 全是这种风格。
为什么 Go 社区偏爱 Functional Options?三个理由:默认值清晰(在 NewClient 主体里)、可读性好(每个 Option 名字自解释)、扩展性强(加新 Option 不破坏老代码)。这三个理由加起来,让 Functional Options 成了 Go 工程里建造者的默认选择。
三、什么时候别用建造者
建造者不是所有"参数多"场景都适用,至少有三个反模式要知道。
反模式一:参数少的对象硬上建造者。3 个参数的对象用 Config 结构体就够了,硬上 Functional Options 反而增加样板代码。建造者的成本(多个 With* 函数、Option 类型定义)只有参数多时才摊得平。
反模式二:建造者返回可变对象。建造者的核心价值之一是"构造完成后对象不可变",让并发访问更安全。如果 Build() 返回的对象还能被随意修改,建造者就退化成了参数收集器,价值大打折扣。
反模式三:建造步骤之间有强依赖。比如 B 步骤必须在 A 之后、C 必须在 A 和 B 都完成之后。建造者假设步骤独立,强依赖说明该用模板方法或状态机。强行用建造者,最后会变成"调用方按特定顺序调 With* 方法"的隐式约定,bug 满天飞。
Go 标准库里建造者思想藏在很多地方——strings.Builder(建造字符串)、net/url.Values(建造 query string)、text/template.Template(建造渲染模板)。但 Go 标准库对 Functional Options 用得反而克制,更多用 Config 结构体——标准库要服务所有场景,Config 结构体的简单直白比 Functional Options 的灵活更重要。第三方库用 Functional Options 更多,因为它们的 API 稳定性要求更高。
回头看那个 HTTP client——值得用 Functional Options 吗?值得,11 个参数 + 大部分有默认值,正是 Functional Options 的甜区。但如果只是 3 个参数的小对象,Config 结构体就够了,Functional Options 反而是过度设计。
建造者真正教会我的,不是"分步构建对象",而是让对象的默认状态合理、可选状态显式——调用方只关心"我想改什么",不用关心"其他参数是什么"。这个 API 设计原则比模式本身重要得多。
下一篇讲原型模式,它解决的是"对象复制"的问题,跟建造者完全是另一个维度。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
