目录

构造一个组件时,往往有一堆“可配可不配”的参数:主机、端口、编码、是否解析时间……全塞进构造函数要么参数表爆炸,要么到处传一个巨大的配置结构体。

在 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 即可。