Categories
程式開發

[Go] 設置各種選項的最佳套路


背景

在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 等。 這種做法簡單得令人髮指,不過卻有一些問題。

因為沒有初始化方法,部分字段在使用的時候是需要先判斷一下調用者是否初始化的。 例如這個例子裡面,如果*net.Dialer 沒有初始化,那麼運行時會直接panic。 為了解決#1 的問題,我們還需要在使用這些字段的時候判斷一下是否初始化過,如果沒有初始化,就使用默認值。 使用方法#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 := 3
config := 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 模式已經非常不錯了,但是在下面兩種情況下仍然有些麻煩:

有一些對象的字段希望只在生成的時候配置一次,之後就不能再修改了。 這個時候用Set 就不能很好地保證這一點。 有時候我們希望我們提供出去的庫的功能是以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 {...}

實現步驟

首先,我們需要創建一個Option 類型。

type Option interface {
apply(*myDialer)
}

接下來我們讓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)
}
}

現在我們來實現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))
// 修改多个 Option
d := NewMyDialer(RetryOption(5), TimeoutOption(time.Minute), DialerOption(&net.Dialer{
KeepAlive: 3*time.Second,
}))

補充

Rob Pike 是在2014年寫Blog 總結這個套路的,當時他的Option 不是一個interface,而是一個function。 使用上略有差異。 目前普遍認為函數轉單方法接口這種做法更靈活,建議大家使用這個方式。

總結

最後我說一個我總結這個套路的心得。

首先,最初我在尋找一個創建對象的最佳套路時,主要的方向還是看那五個創建型模式(工廠、抽象工廠、生成器、單例、原型),看來看去也沒有找到合適的,沒想到截止目前找到的最佳套路是命令模式。 再次說明套路重要,對套路的創新更加重要。

其次,我想感嘆一下,作為 [email protected]" 這個頂級郵箱的擁有者,Rob Pike 老爺子仍然堅持親自寫代碼,並在代碼細節上如此盡善盡美,令人敬仰。而我們國內技術圈卻經常花大量時間討論架構師應不應該寫代碼,甚至架構師是否需要會寫代碼,這可能也是許多技術文章字裡行間散發著一股傷痕文學氣息的原因之一吧。