golang最佳实践-functional options

golang中的函数/方法是不支持默认值和重载的,自己最近半年的工作封装了不少基础库,经常为此苦恼,但是由于工作量较大的原因,并未深入研究这个问题,最近google一下,其实dave cheney早在2014年就引入了详细的解决方案了。原文比较冗长,这里是文章核心内容和自己的理解。

个人将functional options翻译为函数化选项

提出问题

比如有以下函数:

func NewServer(addr string) (*Server, error) {
    //...
}

Server可能有很多初始化参数/属性,比如支持tls,超时时间等等,怎么比较优雅的设置这些参数了?

解决方案1

通过将参数/属性抽象为一个Config,可以部分解决这个问题。 比如:

type ServerConfig struct {
    Timeout time.Duration
    //...
}

func NewServer(addr string, config *ServerConfig) (*Server, error) {
    //默认初始化参数处理
    if config != nil && config.Timeout == 0 {
        config.Timeout = 默认参数
    }

    //...
}

config := &ServerConfig{
    Timeout: 1*time.Second,
}

server, _ := NewServer(":9001", &config)

config.Timeout = 2*time.Second

自己主要使用的这个方案,但是这个方案还不够优雅,有两个问题:

    1. 初始化参数不太好处理,需要在NewServer中判断config是否为空且初始值是否有效,无效则设置默认值,这里的代码非常不友好,加重了心智负担。
    1. 上面代码的最后一行,server初始化后,再修改config的属性,会引起什么了?除了把Server的所有相关代码读一遍,否则无法预知。这里可以通过将NewServer的第二个参数类型的指针去掉,改为func NewServer(addr string, config ServerConfig) (*Server, error)

在公司内部快速迭代开发过程中,个人觉得这种做法暂时是可以的,但是,非常不优雅,甚至可以说是问题较大,与go语言的尽量少的心智负担的理念相悖。需要尽早按照下面的第二种方案重构。

解决方案2

最佳实践应该是dave引入的下面这种模式:

func NewServer(addr string, options ...func(*Server)) (*Server, error) {
    srv := Server{Timeout: 默认值}
    // ...
}

func main() {
    srv, _ := NewServer("localhost")  //default

    timeout := func(srv *Server) {
        srv.timeout = 60 * time.Second
    }

    srv2, _ := NewServer("localhost", timeout)
}

这里的最佳实践可以让函数签名有以下特性,下面的翻译都是按照自己理解来的,还是看英文比较容易理解:

  • 明确的默认值(sensible defaults)
  • 高可配置(is hightly configurable)
  • 今后易扩展(can grow over time)
  • 自我描述(self documenting)
  • 新手可控(safe for newcomers)
  • 不需要nil或者空值对编译器友好(and never requires nil or an empty value to keep the compiler happy)

下面将dave自己重构的过程摘录一下,方便进一步理解: dave的老代码:

pakcage main

import "github.com/pkg/term"

func main() {
    t, err := term.Open("/dev/ttyUSB0")
    // handle error
    err = t.SetSpeed(115200)
    // handle error
    err = t.SetRawMode()
    // handle error
    //...
}

使用functional options模式重构后:

pakcage main

import "github.com/pkg/term"

func main() {
    // just open the terminal
    t, _ := term.Open("/dev/ttyUSB0")


    // open at 115200 baud in raw mode
    t2, _ := term.Open("/dev/ttyUSB0", term.Speed(115200), term.RawMode)
}


在term包中,有如下代码:

func RawMode(t *Term) error { return t.setRawMode()}
func Speed(baud int) func(*Term) error {
    return func(t *Term) error {
        return t.setSpeed(baud)    
    }
}

func Open(dev string, options ...func(*Term) error) (*Term, error) {
    t, err := openTerm(dev)
    for _, option := range options {
        err := option(t)
        //handle err, cleanup
    }
    return t, nil
}

openTerm中可以设定默认的参数.

引申

掌握了这个模式,很多开源代码就可以看懂了,因为大部分写的比较好的开源库,基本都是用了这个模式,比如go-micro的NewService:

import (
        "github.com/micro/go-micro" 
        // etcd v3 registry
        "github.com/micro/go-plugins/registry/etcdv3"
        // nats transport
        "github.com/micro/go-plugins/transport/nats"
        // kafka broker
        "github.com/micro/go-plugins/broker/kafka"
)

func main() {
	registry := etcdv3.NewRegistry()
	broker := kafka.NewBroker()
	transport := nats.NewTransport()

        service := micro.NewService(
                micro.Name("greeter"),
                micro.Registry(registry),
                micro.Broker(broker),
                micro.Transport(transport),
        )

	service.Init()
	service.Run()
}

反过来看,go-micro的代码质量是不错的,值得学习。

再比如自己经常使用的爬虫库gocolly,初始化爬虫也用了functional options:

func NewCollector(options ...func(*Collector)) *Collector {
    c := &Collector{}
    c.Init()

    for _, f := range options {
        f(c)
    }

    c.parseSettingsFromEnv()

    return c
}