构造一个组件时,往往有一堆“可配可不配”的参数:主机、端口、编码、是否解析时间……全塞进构造函数要么参数表爆炸,要么到处传一个巨大的配置结构体。
在 Go 里很常见的一种做法是:函数选项模式(Functional Options)。 下面以MySQL实例创建为例子讲解这种实践方法。
一、长什么样
对外暴露的是可变参数:例如 NewClient(opts …Option)。调用方只写关心的项,其余走默认值。
db, err := dbconn.NewMySQL(
dbconn.OptionHost("10.0.0.1"),
dbconn.OptionPort(3307),
dbconn.OptionDatabase("app"),
dbconn.OptionUser("appuser"),
dbconn.OptionPassword("secret"),
)
没写的项(例如 charset、parseTime)来自包内的 defaultOptions,不必重复填。
二、内核:接口 + 闭包
实现上通常有三块:对外的 Option 接口、内部的 options 结构体、把函数包装成 Option 的小类型。
1. 接口只负责“能应用到配置上”
type Option interface {
apply(*options) error
}
注意 apply 返回 error。当前各个选项里可以只是改字段后 return nil,若要在选项里做校验(例如端口范围、非法主机名),可以直接在这里返回错误,在构造函数里统一处理,扩展性已经留好了。
2. 内部状态不导出
type options struct {
user string
password string
host string
port int
database string
charset string
parseTime bool
}
字段小写,包外不能直接拼结构体字面量,避免绕过约定;默认值集中在一处:
var defaultOptions = options{
host: "127.0.0.1",
port: 3306,
charset: "utf8mb4",
parseTime: true,
}
3. funcOption:用函数实现接口
type funcOption struct {
f func(*options) error
}
func (f *funcOption) apply(o *options) error {
return f.f(o)
}
每个具体的“选项”并不是新类型,而是一个返回 Option 的包级函数,内部塞一个闭包,闭包里改 *options:
func OptionHost(val string) Option {
return &funcOption{f: func(o *options) error {
o.host = val
return nil
}}
}
OptionPort、OptionDatabase 等同理。这样新增选项时,多数情况只加一个小函数,不必改接口定义。
三、怎么拼起来用
构造函数里先把默认值拷贝到本次调用专用的那份配置,再按顺序执行每个 Option:
func NewMySQL(opts ...Option) (*Client, error) {
o := defaultOptions
for _, opt := range opts {
if err := opt.apply(&o); err != nil {
return nil, err
}
}
db, err := sql.Open("mysql", o.DSN())
if err != nil {
return nil, err
}
return &Client{db: db}, nil
}
options 只是内部聚合;真正交给 database/sql 的是驱动约定的连接串。下面以 MySQL 常见 DSN 格式为例(需 fmt、net/url),把用户名、密码、主机端口、库名以及查询串里的 charset、parseTime 等拼成一行:
func (o *options) DSN() string {
user := o.user
if user == "" {
user = "root"
}
userInfo := url.UserPassword(user, o.password).String()
query := "charset=" + url.QueryEscape(o.charset)
if o.parseTime {
query += "&parseTime=True"
}
return fmt.Sprintf("%s@tcp(%s:%d)/%s?%s",
userInfo, o.host, o.port, o.database, query)
}
o := defaultOptions 在 Go 里是结构体值拷贝,得到的是栈上(或逃逸后的)一份独立副本;后面 apply(&o) 改的是这份副本,不会改到包里的 defaultOptions,也不会和别的 NewMySQL 调用共享同一块可变状态,这样写是正确、常见的。
若希望全程用 *options、少写取址符,更贴近工程里“先有一份自己的配置,再拿指针往下传”的习惯,可以先拷贝再取址,与上面语义等价:
cfg := defaultOptions
o := &cfg
for _, opt := range opts {
if err := opt.apply(o); err != nil {
return nil, err
}
}
或者先拿值再显式起别名,效果一样:o := defaultOptions,再 optCopy := &o,后面只对 optCopy 做 apply。
需要单独说一句:不要在 defaultOptions 是包级变量时写成 o := &defaultOptions。那样拿到的是指向全局默认模板的指针,多次调用或并发时会改到同一块内存,默认值被污染,属于隐蔽坑。要么值拷贝再改(o := defaultOptions + apply(&o)),要么先 cfg := defaultOptions 再 o := &cfg,保证指针指向的是本次调用自己的副本。
顺序有语义:后写的选项覆盖先写的。apply 跑完后,上面的 DSN 把当前这份 options 落成驱动认识的字符串;业务侧在 NewMySQL 里只看到具名选项,不必关心字段怎么拼进 DSN。
四、和别的写法比一比
| 方式 | 优点 | 缺点 |
|---|---|---|
| 一长串位置参数 | 直观 | 参数一多就难记顺序,加参数破坏所有调用方 |
| 导出一个巨大的 Config 结构体,字段全公开 | 调用方灵活 | 零值语义容易踩坑,文档要写清“哪些必填” |
| 函数选项 | 调用像具名参数;新增选项通常兼容旧代码;可逐步加校验 | 多写一层 OptionXxx 样板;初学者要理解接口与闭包 |
在公共库或长期演进的包里,“函数选项”往往更稳:公开 API 是稳定的函数名,而不是一堆容易乱掉的字段或位置。
五、小结
这套写法的本质是 Rob Pike 推广的 Functional Options:用 Option 接口统一行为,用 funcOption 少写具体类型,用 …Option 做可变参数,用 defaultOptions 收敛默认值。若你也在封装连接池、HTTP 客户端、日志等可配置组件,可以按同一套路拆一版;需要校验时,把逻辑放进对应选项的 apply 里返回 error 即可。