本文
以Link1中的叙述为主,进行了翻译。并参考了其他Link中对functional options的实现以及补充。
场景
假如你正在写一个服务器组件,它可能长这样
它有一些未导出的字段需要初始化,并启动一个goroutine进行服务。随着业务的深入,涌现了许多新的需求
为了满足这些需求,你将你的API改成了下面这样
很明显,这种解决方案是很麻烦而且脆弱的。新使用你的包的人并不知道哪些参数是可选的,哪些则是必须的,例如:
- 我想创建一个测试用的实例,是否需要提供真正的TLS证书?如果不需要,应该提供什么来代替?
如果我不关心
maxconns
或者maxconcurrent
,应该传入什么值?零值似乎很合理,但是万一内部实现将并发数设置为0了呢?常见的解决方案
1 多个函数
我想这是一个常见的解决方案,为不同的需求提供不同的API。但是,为每一种需求都提供一个变体会让这一组API十分臃肿2 使用config结构体
config struct的方式是另一个常见的解法。这种方式对比上一个,有了明显的进步:API不会随着新的选项增加而改变
- 可以在单独拎出来的结构体上编写易懂的注释
不过这种模式并不是完美的。在默认值方面仍然存在一定的问题,特别是零值存在含义的时候。例如:在没有提供端口号的时候(此时,config中的port字段为0),默认监听8080端口。这样做的缺点是,你不能明确地将port设置为0,而让操作系统自动选择一个空闲的端口。因为你所填写的0,与默认值0是无法区分的。
而且即便用户想要使用一个默认的配置,仍然需要传递第二个参数(一个空的config)。某种程度上来说,这个部分是冗余的。
2.1 如果使用*config作为参数呢?
这样做的话,用户就可以直接传递nil来得到一个默认的server了。但同时也引入了新的问题:
- 传nil与一个指向空值的指针有什么区别吗?
- 用户与API共用了同一个config,如果在调用NewServer之后,config发生了更改,会导致什么后果吗?(从这一点来看,我个人认为这是一定要避免的方式)
原作者认为,一个好的API不应该要求调用者创建假值来满足那些罕见的使用情况;nil应该永远不会称为传递给任何公共(导出的)函数的参数。
2.2 使用可变参数
使用可变参数可以解决强制性的、不经常使用的配置值的问题,不再需要考虑传递nil或者零值之类问题。此外,也使得默认行为的调用更加简洁。
但是使用可变参数仍然有它的问题,那就是需要对多个config的情况进行处理。而且,[只需要一个config]与可变参数在逻辑上是有点相违背的。
3 Builder模式
这个应该很容易理解,可以参考这篇文章中的内容。
推荐的解决方案 Functional options
1 最开始的一版
功能选项 functional options的概念来自于Self referential functions and design,推荐阅读
与前面的所有例子的关键区别在于,对服务器的定制不是通过结构中的配置参数来完成的,而是通过对Server值本身进行操作的函数来完成的。
而在NewServer
的内部,则直接使用这些option:
那么这个API就具有如下特点:
- 合理的默认值
- 高度可配置
- 随着时间的推移而增长
- 自我描述
- 对于新用户而言是很安全的
- 不需要使用冗余的nil或者空值
2 对option进行一些小改变
提前实现一些option
我们提前实现一些option,这样就能在外部直接调用了:func enableSomething(*Server) {
// ...
}
但是,要设置超时的话,需要参数,而func(*Server)
的函数签名已经表明,并不支持传参。这时候,我们可以这样写:
func Timeout(t time.Duration) func(*Server) {
return func(s *Server) {
s.timeout = t
}
}
func TLS() func(*Server) {
// ...
}
虽然前两个函数的签名并不符合func(*Server),但它们的返回值符合,这也算是闭包的一种应用方式了。
当然,如果需要错误处理的话,将func(*Server)
写成func(*Server) error
也是可以的。
Uber对于functional option的理解
看Uber GitHub的说法,它应该是参考了Link1中的思路,提出的一个它们认为更优解
首先看看如果采取Uber提出的实现,其使用
type Option interface {
// ...
}
func WithCache(c bool) Option {
// ...
}
func WithLogger(log *zap.Logger) Option {
// ...
}
// Open creates a connection.
func Open(
addr string,
opts ...Option,
) (*Connection, error) {
// ...
}
从外部看来,对比原本提出的Option模式,Uber将Option定义成了一个接口,整体的使用方式并没有什么差别。不过我们在转向内部实现,就会发现不同了:
type options struct {
cache bool
logger *zap.Logger
}
type Option interface {
apply(*options)
}
type cacheOption bool
func (c cacheOption) apply(opts *options) {
opts.cache = bool(c)
}
func WithCache(c bool) Option {
return cacheOption(c)
}
type loggerOption struct {
Log *zap.Logger
}
func (l loggerOption) apply(opts *options) {
opts.logger = l.Log
}
func WithLogger(log *zap.Logger) Option {
return loggerOption{Log: log}
}
// Open creates a connection.
func Open(
addr string,
opts ...Option,
) (*Connection, error) {
options := options{
cache: defaultCache,
logger: zap.NewNop(),
}
for _, o := range opts {
o.apply(&options)
}
// ...
}
Uber的理由:我们相信这种模式能为作者提供更多的灵活性,也更容易为用户调试和测试。特别地,它允许选项在测试和模拟中相互比较,而闭包不可能这样做。此外,它还允许选项实现其他接口,包括fmt.Stringer
,它允许用户对选项进行可读的字符串表示。
不过以我目前的需求以及浅薄的理解而言,并没能感受到这种设计方式带来的优越性。同时,新增一个选项,需要为之添加一条声明、一个定义、一个apply方法、一个接收者方法,成本并不低。
Conclusion
option模式对于编写一个优雅的、可读性高、扩展性强的接口是很方便的。而且除了初始化函数之外,我在定义一些interface的函数的时候,发现这种模式也意外的好用。所以我认为functional option模式的价值不容忽视。