Go编程模式:详解函数式选项模式

时间:2022-09-05 17:19:48

Go编程模式:详解函数式选项模式

大家好,我是 polarisxu。

Go 不是完全面向对象语言,有一些面向对象模式不太适合它。但经过这些年的发展,Go 有自己的一些模式。今天介绍一个常见的模式:函数式选项模式(Functional Options Pattern)。

01 什么是函数式选项模式

Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的,这就是函数式选项模式(Functional Options Pattern)。

函数式选项模式是一种在 Go 中构造结构体的模式,它通过设计一组非常有表现力和灵活的 API 来帮助配置和初始化结构体。

在 Uber 的 Go 语言规范中提到了该模式:

Functional options 是一种模式,在该模式中,你可以声明一个不透明的 Option 类型,该类型在某些内部结构中记录信息。你接受这些可变数量的选项,并根据内部结构上的选项记录的完整信息进行操作。

将此模式用于构造函数和其他公共 API 中的可选参数,你预计这些参数需要扩展,尤其是在这些函数上已经有三个或更多参数的情况下。

02 一个示例

为了更好的理解该模式,我们通过一个例子来讲解。

定义一个 Server 结构体:

  1. package main
  2.  
  3. type Server {
  4. host string
  5. port int
  6. }
  7.  
  8. func New(host string, port int) *Server {
  9. return &Server{host, port}
  10. }
  11.  
  12. func (s *Server) Start() error {
  13. }

如何使用呢?

  1. package main
  2.  
  3. import (
  4. "log"
  5. "server"
  6. )
  7.  
  8. func main() {
  9. svr := New("localhost", 1234)
  10. if err := svr.Start(); err != nil {
  11. log.Fatal(err)
  12. }
  13. }

但如果要扩展 Server 的配置选项,如何做?通常有三种做法:

为每个不同的配置选项声明一个新的构造函数

定义一个新的 Config 结构体来保存配置信息

使用 Functional Option Pattern

做法 1:为每个不同的配置选项声明一个新的构造函数

这种做法是为不同选项定义专有的构造函数。假如上面的 Server 增加了两个字段:

  1. type Server {
  2.  
  3. host string
  4.  
  5. port int
  6.  
  7. timeout time.Duration
  8.  
  9. maxConn int
  10.  
  11. }

一般来说,host 和 port 是必须的字段,而 timeout 和 maxConn 是可选的,所以,可以保留原来的构造函数,而这两个字段给默认值:

  1. func New(host string, port int) *Server {
  2.  
  3. return &Server{host, port, time.Minute, 100}
  4.  
  5. }

然后针对 timeout 和 maxConn 额外提供两个构造函数:

  1. func NewWithTimeout(host string, port int, timeout time.Duration) *Server {
  2.  
  3. return &Server{host, port, timeout}
  4.  
  5. }
  6.  
  7. func NewWithTimeoutAndMaxConn(host string, port int, timeout time.Duration, maxConn int) *Server {
  8.  
  9. return &Server{host, port, timeout, maxConn}
  10.  
  11. }

这种方式配置较少且不太会变化的情况,否则每次你需要为新配置创建新的构造函数。在 Go 语言标准库中,有这种方式的应用。比如 net 包中的 Dial 和 DialTimeout:

  1. func Dial(network, address string) (Conn, error)
  2.  
  3. func DialTimeout(network, address string, timeout time.Duration) (Conn, error)

做法 2:使用专门的配置结构体

这种方式也是很常见的,特别是当配置选项很多时。通常可以创建一个 Config 结构体,其中包含 Server 的所有配置选项。这种做法,即使将来增加更多配置选项,也可以轻松的完成扩展,不会破坏 Server 的 API。

  1. type Server {
  2. cfg Config
  3. }
  4.  
  5. type Config struct {
  6. Host string
  7. Port int
  8. Timeout time.Duration
  9. MaxConn int
  10. }
  11.  
  12. func New(cfg Config) *Server {
  13. return &Server{cfg}
  14. }

在使用时,需要先构造 Config 实例,对这个实例,又回到了前面 Server 的问题上,因为增加或删除选项,需要对 Config 有较大的修改。如果将 Config 中的字段改为私有,可能需要定义 Config 的构造函数。。。

做法 3:使用 Functional Option Pattern

一个更好的解决方案是使用 Functional Option Pattern。

在这个模式中,我们定义一个 Option 函数类型:

  1. type Option func(*Server)

Option 类型是一个函数类型,它接收一个参数:*Server。然后,Server 的构造函数接收一个 Option 类型的不定参数:

  1. func New(options ...Option) *Server {
  2.  
  3. svr := &Server{}
  4.  
  5. for _, f := range options {
  6.  
  7. f(svr)
  8.  
  9. }
  10.  
  11. return svr
  12.  
  13. }

那选项如何起作用?需要定义一系列相关返回 Option 的函数:

  1. func WithHost(host string) Option {
  2.  
  3. return func(s *Server) {
  4.  
  5. s.host = host
  6.  
  7. }
  8.  
  9. }
  10.  
  11. func WithPort(port int) Option {
  12.  
  13. return func(s *Server) {
  14.  
  15. s.port = port
  16.  
  17. }
  18.  
  19. }
  20.  
  21. func WithTimeout(timeout time.Duration) Option {
  22.  
  23. return func(s *Server) {
  24.  
  25. s.timeout = timeout
  26.  
  27. }
  28.  
  29. }
  30.  
  31. func WithMaxConn(maxConn int) Option {
  32.  
  33. return func(s *Server) {
  34.  
  35. s.maxConn = maxConn
  36.  
  37. }
  38.  
  39. }

针对这种模式,客户端类似这么使用:

  1. package main
  2.  
  3. import (
  4.  
  5. "log"
  6.  
  7. "server"
  8.  
  9. )
  10.  
  11. func main() {
  12.  
  13. svr := New(
  14.  
  15. WithHost("localhost"),
  16.  
  17. WithPort(8080),
  18.  
  19. WithTimeout(time.Minute),
  20.  
  21. WithMaxConn(120),
  22.  
  23. )
  24.  
  25. if err := svr.Start(); err != nil {
  26.  
  27. log.Fatal(err)
  28.  
  29. }
  30.  
  31. }

将来增加选项,只需要增加对应的 WithXXX 函数即可。

这种模式,在第三方库中使用挺多,比如 github.com/gocolly/colly:

  1. type Collector {
  2.  
  3. // 省略...
  4.  
  5. }
  6.  
  7. func NewCollector(options ...CollectorOption) *Collector
  8.  
  9. // 定义了一系列 CollectorOpiton
  10.  
  11. type CollectorOption{
  12.  
  13. // 省略...
  14.  
  15. }
  16.  
  17. func AllowURLRevisit() CollectorOption
  18.  
  19. func AllowedDomains(domains ...string) CollectorOption
  20.  
  21. ...

不过 Uber 的 Go 语言编程规范中提到该模式时,建议定义一个 Option 接口,而不是 Option 函数类型。该 Option 接口有一个未导出的方法,然后通过一个未导出的 options 结构来记录各选项。

Uber 的这个例子能看懂吗?

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

03 总结

在实际项目中,当你要处理的选项比较多,或者处理不同来源的选项(来自文件、来自环境变量等)时,可以考虑试试函数式选项模式。

注意,在实际工作中,我们不应该教条的应用上面的模式,就像 Uber 中的例子,Open 函数并非只接受一个 Option 不定参数,因为 addr 参数是必须的。因此,函数式选项模式更多应该应用在那些配置较多,且有可选参数的情况。

参考文献

https://golang.cafe/blog/golang-functional-options-pattern.html

https://github.com/uber-go/guide/blob/master/style.md#functional-options

原文链接:https://mp.weixin.qq.com/s/B-HZu1oZGseaOuNUznjJFA