本文

以Link1中的叙述为主,进行了翻译。并参考了其他Link中对functional options的实现以及补充。

场景

假如你正在写一个服务器组件,它可能长这样
Functional options-(函数式)功能选项 | 优雅的API - 图1
它有一些未导出的字段需要初始化,并启动一个goroutine进行服务。随着业务的深入,涌现了许多新的需求
Functional options-(函数式)功能选项 | 优雅的API - 图2
为了满足这些需求,你将你的API改成了下面这样
Functional options-(函数式)功能选项 | 优雅的API - 图3
很明显,这种解决方案是很麻烦而且脆弱的。新使用你的包的人并不知道哪些参数是可选的,哪些则是必须的,例如:

  • 我想创建一个测试用的实例,是否需要提供真正的TLS证书?如果不需要,应该提供什么来代替?
  • 如果我不关心maxconns或者maxconcurrent,应该传入什么值?零值似乎很合理,但是万一内部实现将并发数设置为0了呢?

    常见的解决方案

    1 多个函数

    Functional options-(函数式)功能选项 | 优雅的API - 图4
    我想这是一个常见的解决方案,为不同的需求提供不同的API。但是,为每一种需求都提供一个变体会让这一组API十分臃肿

    2 使用config结构体

    Functional options-(函数式)功能选项 | 优雅的API - 图5
    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 使用可变参数

Functional options-(函数式)功能选项 | 优雅的API - 图6
使用可变参数可以解决强制性的、不经常使用的配置值的问题,不再需要考虑传递nil或者零值之类问题。此外,也使得默认行为的调用更加简洁。

但是使用可变参数仍然有它的问题,那就是需要对多个config的情况进行处理。而且,[只需要一个config]与可变参数在逻辑上是有点相违背的。

3 Builder模式

这个应该很容易理解,可以参考这篇文章中的内容。

推荐的解决方案 Functional options

1 最开始的一版

Functional options-(函数式)功能选项 | 优雅的API - 图7

功能选项 functional options的概念来自于Self referential functions and design,推荐阅读

与前面的所有例子的关键区别在于,对服务器的定制不是通过结构中的配置参数来完成的,而是通过对Server值本身进行操作的函数来完成的。

而在NewServer的内部,则直接使用这些option:
Functional options-(函数式)功能选项 | 优雅的API - 图8
那么这个API就具有如下特点:

  • 合理的默认值
  • 高度可配置
  • 随着时间的推移而增长
  • 自我描述
  • 对于新用户而言是很安全的
  • 不需要使用冗余的nil或者空值

    2 对option进行一些小改变

    提前实现一些option

    我们提前实现一些option,这样就能在外部直接调用了:
    1. func enableSomething(*Server) {
    2. // ...
    3. }

但是,要设置超时的话,需要参数,而func(*Server)的函数签名已经表明,并不支持传参。这时候,我们可以这样写:

  1. func Timeout(t time.Duration) func(*Server) {
  2. return func(s *Server) {
  3. s.timeout = t
  4. }
  5. }
  6. func TLS() func(*Server) {
  7. // ...
  8. }

虽然前两个函数的签名并不符合func(*Server),但它们的返回值符合,这也算是闭包的一种应用方式了。

当然,如果需要错误处理的话,将func(*Server)写成func(*Server) error也是可以的。

Uber对于functional option的理解

看Uber GitHub的说法,它应该是参考了Link1中的思路,提出的一个它们认为更优解

首先看看如果采取Uber提出的实现,其使用

  1. type Option interface {
  2. // ...
  3. }
  4. func WithCache(c bool) Option {
  5. // ...
  6. }
  7. func WithLogger(log *zap.Logger) Option {
  8. // ...
  9. }
  10. // Open creates a connection.
  11. func Open(
  12. addr string,
  13. opts ...Option,
  14. ) (*Connection, error) {
  15. // ...
  16. }

从外部看来,对比原本提出的Option模式,Uber将Option定义成了一个接口,整体的使用方式并没有什么差别。不过我们在转向内部实现,就会发现不同了:

  1. type options struct {
  2. cache bool
  3. logger *zap.Logger
  4. }
  5. type Option interface {
  6. apply(*options)
  7. }
  8. type cacheOption bool
  9. func (c cacheOption) apply(opts *options) {
  10. opts.cache = bool(c)
  11. }
  12. func WithCache(c bool) Option {
  13. return cacheOption(c)
  14. }
  15. type loggerOption struct {
  16. Log *zap.Logger
  17. }
  18. func (l loggerOption) apply(opts *options) {
  19. opts.logger = l.Log
  20. }
  21. func WithLogger(log *zap.Logger) Option {
  22. return loggerOption{Log: log}
  23. }
  24. // Open creates a connection.
  25. func Open(
  26. addr string,
  27. opts ...Option,
  28. ) (*Connection, error) {
  29. options := options{
  30. cache: defaultCache,
  31. logger: zap.NewNop(),
  32. }
  33. for _, o := range opts {
  34. o.apply(&options)
  35. }
  36. // ...
  37. }

Uber的理由:我们相信这种模式能为作者提供更多的灵活性,也更容易为用户调试和测试。特别地,它允许选项在测试和模拟中相互比较,而闭包不可能这样做。此外,它还允许选项实现其他接口,包括fmt.Stringer,它允许用户对选项进行可读的字符串表示。

不过以我目前的需求以及浅薄的理解而言,并没能感受到这种设计方式带来的优越性。同时,新增一个选项,需要为之添加一条声明、一个定义、一个apply方法、一个接收者方法,成本并不低。

Conclusion

option模式对于编写一个优雅的、可读性高、扩展性强的接口是很方便的。而且除了初始化函数之外,我在定义一些interface的函数的时候,发现这种模式也意外的好用。所以我认为functional option模式的价值不容忽视。

Links

  1. Functional options for friendly APIs
  2. uber_go_guide_cn#功能选项