最近的项目用 Go 编写了一个压测启动工具。压测工具不是对外写法比较自由,因此在朋友的推荐下尝试了一些新写法,其中一个就是尝试了 Option 模式,观感还不错,稍微做个总结。

什么是 Option 模式?

按面向对象设计风格的说法,我们写代码构造对象时,总是需要传递一些必要的参数才能让对象正常工作。Go 标准库对此的常见做法是直接暴露内部字段。典型的例子是 Go 自己的标准 net/http 库。构造的 http.Transport 和http.Client 对象时,所有的参数都能直接在结构体构造时赋值,如下面的例子所示。

tr := &http.Transport{
	MaxIdleConns:       10,
	IdleConnTimeout:    30 * time.Second,
	DisableCompression: true,
}
client := &http.Client{Transport: tr}
resp, err := client.Get("https://www.zhihu.com")

类似的做法也常见于其他标准库中。但这种用法,对习惯了 Java、C++ 等传统面向对象开发风格的程序员们来说,多少难以接受。原因无他,面向对象设计风格一直强调,内部字段应当封装而不是暴露在外,以免在对象构造之后又被外部修改,造成难以库开发者预料的后果。随着历史演进,Go 社区发展出了三种惯用法:构造函数、Builder 模式,和 Option 模式。

构造函数模式

构造函数是历史最悠久的,基本上所有面向对象程序设计语言都有类似机制。虽然 Go 严格以以上说没有构造函数的概念,但可以利用 Go 包空间的封装规则达到同样地效果:即隐蔽小写开头字段,只暴露一个 NewXXX 开头的构造函数,配置都通过构造函数参数传入。比如 spf13 的多个系统库都使用了这一模式。但构造函数模式有一个明显缺点,就是当参数较多时难以记忆,而当多个参数都有同样类型(比如字符串)时,很容易弄错顺序。相比 C++ 和 Java,Go 使用构造函数还存在一个额外的问题。因为 Go 不支持函数重载,当库函数开发者需要给出多个构造函数时,每个构造函数都需要起不同的名字。偏偏这种场景又很难避免,比如某些库函数可能存在可组合的多个可选参数,或者版本更新时需要增加新的必填,此时库函数设计者就得提供多个构造函数,还得费心想好每一个合理的名字,相当麻烦。

Builder 模式

为了解决上述问题,程序员们引入了 Builder 模式。Builder 模式同样历史悠久。它是 23 个设计模式中的一个,在多种语言中广泛使用,比如 Apache Pulsar 的 Java 客户端都使用这种惯用法。Pulsar 官网的代码展示了其用法。Pulsar 的 Go 客户端则使用了一种变形。区别在于,Java 客户端(典型的 Builder 模式)用的是「构造 Builder 对象 - 链式调用方法传递参数 - 调用 create() 函数真正创建对象」的三步走;Go 客户端则使用「构造 ClientOptions 对象赋值参数 - 构造对象」的两步走。两者形式不同,思路是一致的,将参数保存在独立的 builder 对象中。而真正对象的构造函数只读取 builder 对象。以下代码展示了 Pulsar Java 和 Go 客户端的用法。

// Apache Pulsar Java client
PulsarClient client = PulsarClient.builder()
        .serviceUrl("pulsar://localhost:6650,localhost:6651,localhost:6652")
        .build();
Producer<String> stringProducer = client.newProducer(Schema.STRING)
        .topic("my-topic")
        .create();

---

// Apache Pulsar Go client
client, err := pulsar.NewClient(pulsar.ClientOptions{
        URL: "pulsar://localhost:6650,localhost:6651,localhost:6652",
        OperationTimeout:  30 * time.Second,
        ConnectionTimeout: 30 * time.Second,
    })
if err != nil { ... }
producer, err := client.CreateProducer(pulsar.ProducerOptions{
    Topic: "my-topic",
})

Option 模式

我原来一直用 Builder 模式,所以 Option 模式对我也算是新事物。它和 Builder 的部分目标相同,只需要待构造对象提供单个构造函数,参数增减不影响构造函数原型。区别在于,它借用了 Golang 支持闭包函数的便利,省去了构造 builder 对象的步骤。以下代码展示了其基本用法。

type MyPulsarClient struct {
        url string
        operationTimeout time.Duration
        ...
}
type MyPulsarClientOption = func(*MyPulsarClient)

func NewMyPulsarClient(...opts) (*PulsarClient, error) {
        newObj := &MyPulsarClient{
            url: "",
            operationTimeout: 30 * time.Seconds
        }
        for _, opt := range opts {
                opt(newObj)
        }
        if newObj.url == nil {
                return nil, errors.Errorf("URL is empty")
        }
        // More code...
        return newObj
}

func WithURL(url string) MyPulsarClientOption {
        return func(o *MyPulsarClient) {
                o.url = url
        }
}

func WithOperationTimeout(timeout time.Duration) MyPulsarClientOption {
        return func(o *MyPulsarClient) {
                o.operationTimeout = timeout
        }
}

// To use the class, go here
obj := NewMyPulsarClient(WithURL("pulsar://localhost:6650"), withOperationTimeout(1*time.Seconds))

Option 模式的优势

和我常用的 Builder 模式相比,Option 模式的第一个好处很明显,不再需要定义和维护 builder 对象了,字段只需要在真正的对象中保留一份即可,也就省去了构造函数中大段从 builder 对象拷贝参数到真实对象的操作,不容易出错。从可读性角度上看,它的代码量和 builder 模式基本相当,得为每一个参数生成赋值函数,函数名而不是直接构造函数参数赋值,可读性和 Builder 一样,都好于构造函数。最后,它也一定程度上允许类似链式调用的乱序调用,只要不是操作同一个参数的两个 With 函数同时出现在参数表里,参数的赋值顺序就是顺序无关的。

Option 模式的第二个优势是可以分散对参数检查的处理。上面我举的代码例子是简化的,实际上 Option 的形式可以进一步扩展,允许检查并返回错误。比如我们可以将上面的 MyPulsarClient 的例子做进一步的扩展,在 WithURL() 调用中做一些复杂的检查。如果在 Go 语言中照搬 Java 风格 Builder 模式就很难做到这一点,一个很重要的原因是为了支持链式书写风格,返回值无法返回错误信息。这样就只能将错误处理逻辑集中到构造函数中完成。当然,这一条可能会有一定争议,因为很多朋友也喜欢将错误处理集中到一处一并解决。而我自己的习惯是将错误处理拆散,这样便于编写单元测试;而且赋值和检验逻辑放在一起,可读性并没有下降。

type MyPulsarClientOption = func(*MyPulsarClient) error

func WithURL(url string) MyPulsarClientOption {
        return func(o *MyPulsarClient) error {
                if err := ValidateURLFormat(url); err != nil {
                        return err
                } 
                o.url = url
                return nil
        }
}
func NewMyPulsarClient(...opts) (*PulsarClient, error) {
        newObj := &MyPulsarClient{ ... }
        var err error
        for _, opt := range opts {
                err = opt(newObj)
                if err != nil {
                        return nil, err
                }
        }
        if newObj.url == nil {
                return nil, errors.Errorf("URL is empty")
        }
        // More code...
        return newObj
}

Option 模式的局限

Option 模式虽然好处不少,但它也有一些劣势。一个明显的缺陷是,Option 模式的构造函数表达了一种语义,即所有的参数都是可选的,不填也无妨。但实际工作场景中,很多对象至少得要求一个参数必须填好才能工作,比如上文说的 Pulsar 客户端,起码 URL 是必填的。当使用构造函数模式时,必填参数可以直接作为构造函数的参数,但 Option 模式只能将这些必填参数的检查推迟到执行时,在构造函数顺次执行万每一个 opt 后,再检查哪些必填参数不为空,并返回 error 。好在很多时候运行时检查必填参数也是必要的步骤,比如验证 URL 参数的格式正确,还不至于对程序员施加太大的负担。

另一个缺陷则和 Golang 这门语言的机制有关,就是名称冲突。Option 使用包内全局函数,而Go 不支持函数重载,这种冲突就无法通过编译。这种情况并不少见。假如我们尝试用 Option 模式改造上面的 HTTP 客户端和 Pulsar 客户端,就很容易引入两个 WithURL() 函数。而这两个 WithURL() 一旦出现在同一个包当中,就一定会出现编译错误。目前看,这个缺陷没有好的解决办法,只能尽量去拆分包结构,通过多个子包结构来隔离名字空间。再看 Go Pulsar 客户端的写法,其实是用结构体字段来隔离名字空间,思路不同,但目的是一致的。这个时候传统 Builder 模式就相对比较有优势。Go 虽然不支持函数重载,但允许不同对象的成员函数重名。这样就规避了名字冲突问题。

(注:之前因为一些自己早期印象的误导,我犯了个低级错误,总是认为 Go 的成员函数也不能重名。感谢 @Tutu-Lux 提醒,特此感谢。)

结论

虽然有这样或那样的小毛病,但目前看,Option 模式满足了一些对我来说很重要的需求:代码有一定简化,可读性有保证,便于单元测试。名字冲突是一个比较大的缺陷,但也有规避的手段。所以,接下来的一些项目里,我可能会考虑在代码中多用一些 Option 模式,看看还能不能发掘出一些东西。