Go语言中更为优雅的动态配置

开发过程中,需要构造一个对象(结构体)时,总是需要传递一些必要的参数以使其正常运行。在Go标准库中,往往会直接暴露字段,在构造的时候能够直接传入。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
req := &Request{
    ctx:        ctx,
    Method:     method,
    URL:        u,
    Proto:      "HTTP/1.1",
    ProtoMajor: 1,
    ProtoMinor: 1,
    Header:     make(Header),
    Body:       rc,
    Host:       u.Host,
}

但是需要注意的是,面向对象编程中三大特性之一的封装要求隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读取和修改的访问级别。对于一些初始化后不可改动的字段,需要尽可能的使其不暴露,以避免其受修改导致难以预料的结果。

在Go语言发展的过程中,形成了三种常用的参数传递方法:构造函数、Builder模式以及Options模式。

构造函数

构造函数方式同其他所有面向对象的语言保持一致,虽然Go语言并未类似C++、Java等语言有明确的构造函数的定义,但是可以通过将所有所需字段设置为unexported的,仅暴露一个或多个形如NewXXX的构造函数即可,所需参数通过函数参数传入。但是可以预见的缺点是,依赖于构造函数,当所需参数数目达到一定量级后,难以确定其顺序等,且Go语言并不支持可选参数,再加之Go语言不支持函数重载,如果需要分别为不同参数的不同场景设置构造函数,就需要创建不同名字的构造函数,较为繁琐。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// https://github.com/spf13/pflag/blob/master/flag.go#L1223
func NewFlagSet(name string, errorHandling ErrorHandling) *FlagSet {
    f := &FlagSet{
        name:          name,
        errorHandling: errorHandling,
        argsLenAtDash: -1,
        interspersed:  true,
        SortFlags:     true,
    }
    return f
}

Builder模式

Builder模式是23种设计模式之一,Builder模式是一种创建型设计模式, 使你能够分步骤创建复杂对象,该模式允许你使用相同的创建代码生成不同类型和形式的对象[1]。在一些优秀的Go语言的第三方库中也有经常使用,此处以go-gorm/gorm为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// https://github.com/go-gorm/gorm/blob/master/chainable_api.go#L112
func (db *DB) Select(query interface{}, args ...interface{}) (tx *DB) {
    // do something
}


// https://github.com/go-gorm/gorm/blob/master/chainable_api.go#L200
func (db *DB) Where(query interface{}, args ...interface{}) (tx *DB) {
    // do something
}

Builder模式的函数声明都是类似的,成员函数接收一些特定的参数,最后再将自己本身返回,通过这样的声明,在使用中可以类似如下:

1
2
3
4
// https://github.com/go-gorm/gorm/blob/master/tests/group_by_test.go#L62
if err := DB.Model(&User{}).Select("name, sum(age) as total").Where("name LIKE ?", "groupby%").Group("name").Having("name = ?", "groupby1").Row().Scan(&name, &total); err != nil {
    t.Errorf("no error should happen, but got %v", err)
}

Options模式

Options模式又称函数选项模式(Functional Options ),同样是一种创建型的设计模式,允许使用接受零个或多个函数作为参数的可变构造函数构建复杂结构。由于Go语言中的函数参数不支持可选参数,如果需要传递进一些可选参数,可以使用...interface{}这样一个可选参数,但是在函数中需要对其的各种情况进行判定,但是Go语言的函数参数支持传递闭包,因此在Option模式中,可通过传递进一个...OptionFunc的可变长参数,在这个OptionFunc闭包中会对对象进行配置。本文通过avast/retry-go这个库为样例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// https://github.com/avast/retry-go/blob/master/options.go#L25
type Config struct {
    attempts         uint
    attemptsForError map[error]uint
    delay            time.Duration
    maxDelay         time.Duration
    maxJitter        time.Duration
    onRetry          OnRetryFunc
    retryIf          RetryIfFunc
    delayType        DelayTypeFunc
    lastErrorOnly    bool
    context          context.Context
    timer            Timer

    maxBackOffN uint
}

// Option represents an option for retry.
type Option func(*Config)

首先定义了相关配置对象,包括尝试次数、延迟等,对象内的所有变量均不对外暴露,并定义了用于进行配置对象内参数的函数类型,用于接收配置对象。

1
2
3
4
5
6
7
8
// https://github.com/avast/retry-go/blob/master/options.go#L56
// Attempts set count of retry. Setting to 0 will retry until the retried function succeeds.
// default is 10
func Attempts(attempts uint) Option {
    return func(c *Config) {
        c.attempts = attempts
    }
}

以尝试次数为例,Attempts函数接收一个尝试次数的设置,返回一个Option类型的闭包,在该Option类型的闭包中,接收*Config类型的参数,在函数体内对其进行配置更新。

在具体的初始化函数中,可以通过如下方式获取所有配置,并将其作用于对象之上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// https://github.com/avast/retry-go/blob/master/retry.go#L82
func Do(retryableFunc RetryableFunc, opts ...Option) error {
    var n uint

    // default config
    config := newDefaultRetryConfig()

    // apply opts
    for _, opt := range opts {
        // 遍历所有配置函数进行调用
        opt(config)
    }

    // do something
}

// Example
err := Do(
        func() error {
            // do something
        },
        Attempts(0),
        MaxDelay(time.Nanosecond),
    )

在具体配置的过程中,遍历可变长参数opts,对其依次进行调用,使其作用在真正的配置中。

通过Functional Options这种编程模式,不仅满足了面向对象对于封装的要求,将对象内部属性隐藏起来,还能通过相对优雅、直观的方式进行动态配置。