背景

在 Go 外面写一个 struct 时,常常会遇到要给 struct 外面的各个字段提供设置性能。这个问题看起来很简略很容易,实际上困扰了不少人,连 Go 的三巨头之一 Rob Pike 都已经为之苦恼了一段时间,起初找到了最佳实际后还为此开心地写了一篇 Blog。

我最早是在 GRPC 的代码里发现这个套路的,起初在往年7月 Go 官网 Blog 里又看到了对这个套路的举荐,以及 Rob Pike 的 Blog 链接。我本人在代码里尝试之后感觉很好,又举荐给共事尝试,大家都很喜爱。

示范案例

我用这样一个需要案例来比照一下各种套路的优劣。

咱们要写一个 struct,它的外围性能是创立一个网络连接 net.Conn 的实例,也就是实现上面这个办法:

type MyDialer struct {    dialer *net.Dialer}func (d *MyDialer) DialContext(ctx context.Context, addr net.Addr) (net.Conn, error) {    return d.dialer.DialContext(ctx, addr.Network(), addr.String())} 

针对这个 Dialer ,咱们减少两个选项,一个是连贯超时,一个是重试次数。代码就变成了这样:

type MyDialer struct {    dialer *net.Dialer    timeout time.Duration    retry int}func (d *MyDialer) DialContext(ctx context.Context, addr net.Addr) (conn net.Conn, err error) {    for i := 0; i < d.retry+1; i++ {        d.dialer.Timeout = d.timeout        conn, err = d.dialer.DialContext(ctx, addr.Network(), addr.String())        if err == nil {            return conn, err        }    }    return nil, err} 

当初问题来了,咱们须要实现一个结构 MyDialer 的办法,在结构时能够指定超时和重试的配置。

这个问题很简略,对不对?实际上并非如此,咱们来看一下怎么设计。

惯例套路

在说最佳套路之前,先梳理一下常见的惯例套路。剖析这些套路的优劣,有助于了解最佳套路为何是最佳的。

惯例套路大抵能够分三种:

  • 字段导出为公共
  • 在生成办法上减少配置字面量
  • 提供 Set 系列办法

惯例套路1:导出字段

首先咱们能够思考一种最简略的形式,把 MyDialer 外面须要对外设置的字段都导出。

type MyDialer struct {    Dialer *net.Dialer    Timeout time.Duration    Retry int} 

Go 规范库中大部分构造体都是这样解决的,例如 http.Client 等。这种做法简略得令人发指,不过却有一些问题。

  1. 因为没有初始化办法,局部字段在应用的时候是须要先判断一下调用者是否初始化的。例如这个例子外面,如果 *net.Dialer 没有初始化,那么运行时会间接 panic。
  2. 为了解决 #1 的问题,咱们还须要在应用这些字段的时候判断一下是否初始化过,如果没有初始化,就应用默认值。
  3. 应用办法 #2 又引入一个更麻烦的问题,默认值如果不是一个类型的零值,那就无奈判断字段的值是未被初始化,还是调用者无意设置的。

考虑一下这样的代码:

func (d *Dialer) DialContext(ctx context.Context, addr net.Addr) (conn net.Conn, err error) {    if d.Dialer == nil {        d.Dialer = &net.Dialer{}    }    if int64(d.Timeout) == 0 {        d.Timeout = time.Second // 应用默认的超时    }    if d.Retry == 0 {        // 完了……到底是调用者不想重试,还是他忘了设置?        // d.Retry = 2    }} 

惯例套路2:应用 Config 构造体

第二种惯例套路是设置一个 New 办法,应用一个 Config 构造体。

咱们先说不应用 Config 构造体的办法:

func NewMyDialer(dialer *net.Dialer, timeout time.Duration, retry int) *MyDialer {    return &MyDialer{        dialer: dialer,        timeout: timeout,        retry: retry,    }} 

在很多语言外面,这是最典型的写法。然而这种写法对于 Go 来说很不适合,起因在于 Go 不反对多态函数,如果当前减少了新的字段,在很多语言外面(例如 Java 或 C++),只有再申明一个参数不同的新的 New 办法就能够了,编译器会主动依据调用处的参数格局抉择对应的办法,然而 Go 就不行了。

为了防止这种问题,很多库会应用 Config 构造体:

type Config struct {    Dialer *net.Dialer    Timeout time.Duration    Retry int}// 这样调用:// dialer := MyDialer(&Config{Timeout: 3*time.Second})func NewMyDialer(config *Config) *MyDialer {    d := MyDialer{        dialer: config.Dialer,        timeout: config.Timeout,        retry: config.Retry,    }    // 再检查一下设置是否正确    if d.dialer == nil {        d.dialer = &net.Dialer{}    }    if int64(d.timeout) == 0 {        d.timeout = time.Second    }    if d.retry == 0 {        // 问题又来了,调用者是不是成心设置retry为0的呢?    }} 

应用 Config 模式最麻烦的问题就在于对配置零值的解决。以至于有段时间看到很多人走这样的正路:

type Config struct {    // ... other fields    Retry *int} 

通过配置项指针是否为nil来判断是否为调用者成心设置。不过应用上很麻烦:

// 间接用字面量会无奈编译:config := Config{    Retry: &3,}// 必须发明一个长期变量:r := 3config := Config{    Retry: &r,} 

罕用套路3:提供 Set 办法

提供 Set 办法是另一种常见套路,配合上 New 办法应用,简直能满足绝大多数状况。

type MyDialer struct{...}func NewMyDialer() *MyDialer {    return &MyDialer{        dialer: &net.Dialer{},        timeout: time.Second,        retry: 2,    }}func (d *MyDialer) SetRetry(r int) {    d.retry = r} 

在许多场景下,Set 模式曾经十分不错了,然而在上面两种状况下依然有些麻烦:

  1. 有一些对象的字段心愿只在生成的时候配置一次,之后就不能再批改了。这个时候用 Set 就不能很好地保障这一点。
  2. 有时候咱们心愿咱们提供进来的库的性能是以 interface 来示意的,这样能够更容易地将实现替换掉。在这种状况下应用 Set 模式会大大增加 interface 的办法数量,从而减少替换实现的老本。

举例来说:

// 接下来 MyDialer 以接口方式提供type MyDialer interface {    DialContext(ctx context.Context, addr net.Addr) (net.Conn, error)}// 而 myDialer 作为 MyDialer 接口的实现,是不导出的type myDialer struct {...}func NewMyDialer() MyDialer {    return &myDialer{}} 

在这种设计下,如果应用 Set 模式,就须要为 MyDialer 这个接口减少 SetRetry, SetTimeout, SetDialer 这一系列办法,应用方如果在写单测等时候须要替换掉 MyDialer 的话,也须要在本人的测试替身(Test Double)实现上减少这三个办法。

Option Types 套路

Rob Pike 把这个套路称为 Option Types ,我就沿用这个办法。这种看上去仿佛是23种经典设计模式中的命令模式的一种状态。

Options Types 套路的外围思路是创立一个新的Option类型,这个类型负责批改配置,被调用方接管这个类型来批改本人的选型,调用方创立这个类型传给被调用方。

咱们持续方才的例子,当初假如咱们别离设计了 MyDialer 的接口和实现,让调用者应用 MyDialer 接口,然而咱们提供 New 办法创立 MyDialer 的实现 myDialer

// MyDialer 是导出的接口类型type MyDialer interface {    DialContext(context.Context, net.Addr) (net.Conn, error)}// myDialer 是未导出的接口实现type myDialer struct {...} 

实现步骤

  1. 首先,咱们须要创立一个 Option 类型。
type Option interface {    apply(*myDialer)} 
  1. 接下来咱们让 myDialer 能够解决这个类型。
// 咱们能够在构造方法中应用func NewMyDialer(opts ...Option) MyDialer {    // 首先咱们将默认值填上    d := &myDialer{        timeout: time.Second,        retry: 2,    }    // 接下来用传入的 Option 批改默认值,如果不须要批改默认值,    // 就不须要传入对应的 Option    for _, opt := range opts {        opt.apply(d)    }    // 最初再检查一下,如果 Option 没有传入自定义的必要字段,我    // 们在这里补一下。    if d.dialer == nil {        d.dialer = &net.Dialer{}    }    return d}// 咱们也能够提供独自的办法,并随接口导出,提供相似 Set 模式的性能。func (d *myDialer) ApplyOptions(opts ...Option) {    for _, opt := range opts {        opt.apply(d)    }} 
  1. 当初咱们来实现Option类型。

先用惯例形式写一种啰嗦的写法:

type retryOpt struct {    retry int}func RetryOption(r int) Option {    return &retryOpt{retry:r}}func (o *retryOpt) apply(d *myDialer) {    d.retry = o.retry}type timeoutOpt struct {    timeout time.Duration}func TimeoutOption(d time.Duration) Option {    return &timeoutOpt{timeout: d}}func (o *retryOpt) apply(d *myDialer) {    d.timeout = o.timeout}// ... dialer 的 Opt 相似 

惯例形式外面须要一个实现 Option 接口的类型,和一个该类型的构造方法。所以咱们设置3个字段,就须要写9段代码。

上面咱们用函数转单办法接口的套路,来简化实现 Option 的代码。

type optFunc func(*myDialer)func (f optFunc) apply(d *myDialer) {    f(d)}func RetryOption(r int) Option {    return optFunc(func(d *myDialer) {        d.retry = r    })}func TimeoutOption(timeout time.Duration) Option {    return optFunc(func(d *myDialer) {        d.timeout = timeout    })}func DialerOption(dialer *net.Dialer) Option {    return optFunc(func(d *myDialer) {        d.dialer = dialer    })} 

应用示例

接下来咱们应用这个 MyDialer,看看有多不便:

// 无自定义 Option,全副应用默认的d := NewMyDialer()// 只批改 Retry,并且 Retry 是0次d := NewMyDialer(RetryOption(0))// 批改多个 Optiond := NewMyDialer(RetryOption(5), TimeoutOption(time.Minute), DialerOption(&net.Dialer{    KeepAlive: 3*time.Second,})) 

补充

Rob Pike 是在2014年写 Blog 总结这个套路的,过后他的 Option 不是一个 interface,而是一个function。应用上略有差别。目前普遍认为函数转单办法接口这种做法更灵便,倡议大家应用这个形式。

总结

最初我说一个我总结这个套路的心得。

首先,最后我在寻找一个创建对象的最佳套路时,次要的方向还是看那五个创立型模式(工厂、形象工厂、生成器、单例、原型),看来看去也没有找到适合的,没想到截止目前找到的最佳套路是命令模式。再次阐明套路重要,对套路的翻新更加重要。

其次,我想感叹一下,作为 r@google.com 这个顶级邮箱的拥有者,Rob Pike 老爷子依然保持亲自写代码,并在代码细节上如此尽如人意,令人敬佩。而咱们国内技术圈却常常花大量工夫探讨架构师应不应该写代码,甚至架构师是否须要会写代码,这可能也是许多技术文章字里行间散发着一股创痕文学气味的起因之一吧。