ZimmerWen

Golang 中应用 Functional Options 模式

Functional Options 是一个函数式编程的应用案例,编程技巧也很好,是目前在go语言中最流行的一种编程模式。在grpc的源码中有大量的应用。

要解决什么问题

配置选项问题

在编程中,我们会经常需要对一个对象进行相关的配置,比如:

type Server struct {
  Addr string
  Port int
  Protocol string
  Timeout time.Duration
  MaxConns int
  TLS *tls.Config
}

在这个Server对象中,我们可以看到:

  • 要有监听的ip地址 addr 和端口号 Port,这两个配置选项是必须的
  • 还有 ProtocolTimeoutMaxConns 字段,这几个字短是不能为空的,但有默认值:协议是tcp,超时时间30秒和最大连结束1024个
  • 还有 TLS 这个是安全链接,需要配置相关的证书和私钥。这个是可以为空的

所以,针对与上述这样的配置,因为go语言不支持重载函数,所以不得不用不同的函数来应对不同的配置创建Server的函数,如:

func NewDefaultServer(addr string, port int) (*Server, error) {
  return &Server{
    Addr: addr, Port: port, 
    Protocol: "tcp", Timeout: 30*time.Second, MaxConns: 1024
    TLS: nil
  }, nil
}

func NewTLSServer(addr string, port int, tlsConf *tls.Config) (*Server, error) {
  return &Server{
    Addr: addr, Port: port,
    Protocol: "tcp", Timeout: 30*time.Second, MaxConns: 1024
    TLS: tlsConf
  }, nil
}

func NewServerWithTimeout(addr string, port int, timeout time.Duration) (*Server, error) {
  return &Server{
    Addr: addr, Port: port,
    Protocol: "tcp", Timeout: timeout, MaxConns: 1024,
    TLS: nil
  }, nil
}

如何解决

配置对象方案

要解决配置选项的问题,最常见的方式是使用一个配置对象,我们把哪些费必需的选项都移动到一个结构体里,于是Server对象变成了:

type Server struct {
  Addr string
  Port int
  Conf *Config
}

于是我们只需要一个 NewServer() 的函数,使用前构造 Config 对象即可,如:

func NewServer(addr string, port int, conf *Config) (*Server, error) {
  // ...
}

// using the default configuration
srv1, _ := NewServer("localhost", 9000, nil)

// with config
conf := Config{Protocol: "tcp", Timeout: 60 * time.Duration}
srv2, _ := NewServer("localhost", 9000, &conf)

这段代码算是很不错了,大多数情况下,我们可能就止步于此了。但是我们可以看到其中有一点不好的是:Config 并不是必须的,所以,需要在 NewServer 中判断是否是 nilempty

Builder方案

如果熟悉设计模式的话,一定会很自然的使用上 builder 模式。采用这个模式,我们可以把代码改成如下的方式:

type ServerBuilder struct {
  Server
}

func (s *ServerBuilder) Create(addr string, port int) *ServerBuilder {
  s.Server.Addr = addr
  s.Server.Port = port
  return s
}

func (s *ServerBuilder) WithProtocol(protocol string) *ServerBuilder {
  s.Server.Protocol = protocol
  return s
}

// ...
func (s *ServerBuilder) WithTLS(tlsConf *tls.Config) *ServerBuilder {
  s.Server.TLS = tlsConf
  return s
}

func (s *ServerBuilder) Build() Server {
  return s.Server
}

现在我们就可以用下面的方式使用:

sb := ServerBuilder{}
server := sb.Create("localhost", 8080).WithProtocol("udp").WithTimeout(30 * time.Second).Build()

上面这样的方式也很清楚,不需要额外的Config,使用链式的函数调用的方式来构造一个对象,只需要多加一个Builder。如果想省掉这个包装的结构体,那么就可以使用 Functional Options 来实现

Functional Options

首先,我们先定义一个函数类型,然后,我们可以使用函数式的方式定义一组函数:

type Option func(*Server)

func WithProtocol(p string) Option {
  return func(s *Server) {
    s.Protocol = p
  }
}

func WithTimeout(timeout time.Duration) Option {
  return func(s *Server) {
    s.Timeout = timeout
  }
}

func WithMaxConns(maxConns int) Option {
  return func(s *Server) {
    s.MaxConns = maxConns
  }
}

func WithTLS(tlsConf *tls.Config) Option {
  return func(s *Server) {
    s.TLS = tls
  }
}

上面这组代码传入一个参数,然后返回一个函数,返回的这个函数会设置自己的 Server 函数,例如:

当我们调用其中一个函数使用 MaxConns(30) 时,它的返回值是一个 func(s *Server) { s.MaxConns = 30} 的函数

这个叫做高阶函数,在数学上,就好像以下这样的数学定义:

  • 计算长方形面积的公式为:rect(w, h) = w * h
  • 这个函数我们包装一下,就可以变成计算正方形面积的公式:square(w) = rect(w, w)

现在,我们在定义一个 NewServer 的函数,其中一个可变参数 options 可以传入上面的函数,然后使用 for-loop 来设置我们需要的 Server 对象:

func NewServer(addr string, port int, opts ...Option) (*Server, error) {
  srv := &Server{
    Addr: addr, Port: port, Protocol: "tcp", Timeout: 30 * time.Second, MaxConns: 1024,
    TLS: nil,
  }
  
  for _, opt := range options {
    opt(srv)
  }
  // ...
  return srv, nil
}

于是,我们在创建 Server 的时候,我们就可以这样:

srv1, _ := NewServer("localhost", 9001)
srv2, _ := NewServer("localhost", 9001, WithProtocol("udp"))
srv3, _ := NewServer("localhost", 9001, WithTimeout(5 * time.Second), WithMaxConns(1024))

这样的方式,不但解决了使用 Config 方式时需要有一个config参数,但在不需要的时候,是放 nil 还是 Config{} 的选择困难,也不需要引用一个 Builder 的控制对象,直接使用函数式编程的方式,在代码阅读上也比较优雅。

Functional Options 方式至少可以带来以下好处:

  • 直觉式的编程
  • 高度的可配置化
  • 很容易维护和扩展
  • 自文档
  • 对于团队新人来说很容易上手
  • 没有令人困惑的事,如需要判断nilempty