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

{"type":"doc","content":[{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"背景"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 Go 裏面寫一個 struct 時,經常會遇到要給 struct 裏面的各個字段提供設置功能。這個問題看起來很簡單很容易,實際上困擾了不少人,連 Go 的三巨頭之一 Rob Pike 都曾經爲之苦惱了一段時間,後來找到了最佳實踐後還爲此開心地寫了一篇 Blog。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我最早是在 GRPC 的代碼裏發現這個套路的,後來在今年7月 Go 官方 Blog 裏又看到了對這個套路的推薦,以及 Rob Pike 的 Blog 鏈接。我自己在代碼裏嘗試之後感覺很好,又推薦給同事嘗試,大家都很喜歡。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"示範案例"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我用這樣一個需求案例來對比一下各種套路的優劣。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們要寫一個 struct,它的核心功能是創建一個網絡連接 net.Conn 的實例,也就是實現下面這個方法:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type MyDialer struct {\n\tdialer *net.Dialer\n}\n\nfunc (d *MyDialer) DialContext(ctx context.Context, addr net.Addr) (net.Conn, error) {\n\treturn d.dialer.DialContext(ctx, addr.Network(), addr.String())\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"針對這個 Dialer ,我們增加兩個選項,一個是連接超時,一個是重試次數。代碼就變成了這樣:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type MyDialer struct {\n\tdialer *net.Dialer\n\ttimeout time.Duration\n\tretry int\n}\n\nfunc (d *MyDialer) DialContext(ctx context.Context, addr net.Addr) (conn net.Conn, err error) {\n\tfor i := 0; i < d.retry+1; i++ {\n\t\td.dialer.Timeout = d.timeout\n\t\tconn, err = d.dialer.DialContext(ctx, addr.Network(), addr.String())\n\t\tif err == nil {\n\t\t\treturn conn, err\n\t\t}\n\t}\n\treturn nil, err\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"現在問題來了,我們需要完成一個構造 MyDialer 的方法,在構造時可以指定超時和重試的配置。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個問題很簡單,對不對?實際上並非如此,我們來看一下怎麼設計。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"常規套路"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在說最佳套路之前,先梳理一下常見的常規套路。分析這些套路的優劣,有助於理解最佳套路爲何是最佳的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"常規套路大致可以分三種:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"字段導出爲公共"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在生成方法上增加配置字面量"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"提供 Set 系列方法"}]}]}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"常規套路1:導出字段"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先我們可以考慮一種最簡單的方式,把 MyDialer 裏面需要對外設置的字段都導出。"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type MyDialer struct {\n\tDialer *net.Dialer\n\tTimeout time.Duration\n\tRetry int\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Go 標準庫中大部分結構體都是這樣處理的,例如 http.Client 等。這種做法簡單得令人髮指,不過卻有一些問題。"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"因爲沒有初始化方法,部分字段在使用的時候是需要先判斷一下調用者是否初始化的。例如這個例子裏面,如果 *net.Dialer 沒有初始化,那麼運行時會直接 panic。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"爲了解決 #1 的問題,我們還需要在使用這些字段的時候判斷一下是否初始化過,如果沒有初始化,就使用默認值。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"使用方法 #2 又引入一個更麻煩的問題,默認值如果不是一個類型的零值,那就無法判斷字段的值是未被初始化,還是調用者有意設置的。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"考慮一下這樣的代碼:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func (d *Dialer) DialContext(ctx context.Context, addr net.Addr) (conn net.Conn, err error) {\n\tif d.Dialer == nil {\n\t\td.Dialer = &net.Dialer{}\n\t}\n\tif int64(d.Timeout) == 0 {\n\t\td.Timeout = time.Second // 使用默認的超時\n\t}\n\tif d.Retry == 0 {\n\t\t// 完了……到底是調用者不想重試,還是他忘了設置?\n\t\t// d.Retry = 2\n\t}\n}\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"常規套路2:使用 Config 結構體"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"第二種常規套路是設置一個 New 方法,使用一個 Config 結構體。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們先說不使用 Config 結構體的方法:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func NewMyDialer(dialer *net.Dialer, timeout time.Duration, retry int) *MyDialer {\n\treturn &MyDialer{\n\t\tdialer: dialer,\n\t\ttimeout: timeout,\n\t\tretry: retry,\n\t}\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在很多語言裏面,這是最典型的寫法。但是這種寫法對於 Go 來說很不合適,原因在於 Go 不支持多態函數,如果以後增加了新的字段,在很多語言裏面(例如 Java 或 C++),只要再聲明一個參數不同的新的 New 方法就可以了,編譯器會自動根據調用處的參數格式選擇對應的方法,但是 Go 就不行了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了避免這種問題,很多庫會使用 Config 結構體:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type Config struct {\n\tDialer *net.Dialer\n\tTimeout time.Duration\n\tRetry int\n}\n\n// 這樣調用:\n// dialer := MyDialer(&Config{Timeout: 3*time.Second})\n\nfunc NewMyDialer(config *Config) *MyDialer {\n\td := MyDialer{\n\t\tdialer: config.Dialer,\n\t\ttimeout: config.Timeout,\n\t\tretry: config.Retry,\n\t}\n\t// 再檢查一下設置是否正確\n\tif d.dialer == nil {\n\t\td.dialer = &net.Dialer{}\n\t}\n\tif int64(d.timeout) == 0 {\n\t\td.timeout = time.Second\n\t}\n\tif d.retry == 0 {\n\t\t// 問題又來了,調用者是不是故意設置retry爲0的呢?\n\t}\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 Config 模式最麻煩的問題就在於對配置零值的處理。以至於有段時間看到很多人走這樣的邪路:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type Config struct {\n\t// ... other fields\n\tRetry *int\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通過配置項指針是否爲"},{"type":"codeinline","content":[{"type":"text","text":"nil"}]},{"type":"text","text":"來判斷是否爲調用者故意設置。不過使用上很麻煩:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"// 直接用字面量會無法編譯:\nconfig := Config{\n\tRetry: &3,\n}\n// 必須創造一個臨時變量:\nr := 3\nconfig := Config{\n\tRetry: &r,\n}\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"常用套路3:提供 Set 方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"提供 Set 方法是另一種常見套路,配合上 New 方法使用,幾乎能滿足絕大多數情況。"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type MyDialer struct{...}\n\nfunc NewMyDialer() *MyDialer {\n\treturn &MyDialer{\n\t\tdialer: &net.Dialer{},\n\t\ttimeout: time.Second,\n\t\tretry: 2,\n\t}\n}\n\nfunc (d *MyDialer) SetRetry(r int) {\n\td.retry = r\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在許多場景下,Set 模式已經非常不錯了,但是在下面兩種情況下仍然有些麻煩:"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"有一些對象的字段希望只在生成的時候配置一次,之後就不能再修改了。這個時候用 Set 就不能很好地保證這一點。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"有時候我們希望我們提供出去的庫的功能是以 interface 來表示的,這樣可以更容易地將實現替換掉。在這種情況下使用 Set 模式會大大增加 interface 的方法數量,從而增加替換實現的成本。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"舉例來說:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"// 接下來 MyDialer 以接口方式提供\ntype MyDialer interface {\n\tDialContext(ctx context.Context, addr net.Addr) (net.Conn, error)\n}\n\n// 而 myDialer 作爲 MyDialer 接口的實現,是不導出的\ntype myDialer struct {...}\n\nfunc NewMyDialer() MyDialer {\n\treturn &myDialer{}\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在這種設計下,如果使用 Set 模式,就需要爲 MyDialer 這個接口增加 "},{"type":"codeinline","content":[{"type":"text","text":"SetRetry"}]},{"type":"text","text":", "},{"type":"codeinline","content":[{"type":"text","text":"SetTimeout"}]},{"type":"text","text":", "},{"type":"codeinline","content":[{"type":"text","text":"SetDialer"}]},{"type":"text","text":" 這一系列方法,使用方如果在寫單測等時候需要替換掉 MyDialer 的話,也需要在自己的測試替身(Test Double)實現上增加這三個方法。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"Option Types 套路"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Rob Pike 把這個套路稱爲 Option Types ,我就沿用這個方法。這種看上去似乎是23種經典設計模式中的命令模式的一種形態。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Options Types 套路的核心思路是創建一個新的Option類型,這個類型負責修改配置,被調用方接收這個類型來修改自己的選型,調用方創建這個類型傳給被調用方。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們繼續剛纔的例子,現在假設我們分別設計了 MyDialer 的接口和實現,讓調用者使用 MyDialer 接口,但是我們提供 New 方法創建 MyDialer 的實現 myDialer"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"// MyDialer 是導出的接口類型\ntype MyDialer interface {\n\tDialContext(context.Context, net.Addr) (net.Conn, error)\n}\n\n// myDialer 是未導出的接口實現\ntype myDialer struct {...}\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"實現步驟"}]},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"首先,我們需要創建一個 Option 類型。"}]}]}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type Option interface {\n\tapply(*myDialer)\n}\n"}]},{"type":"numberedlist","attrs":{"start":"2","normalizeStart":"2"},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"接下來我們讓 myDialer 可以處理這個類型。"}]}]}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"// 我們可以在構造方法中使用\nfunc NewMyDialer(opts ...Option) MyDialer {\n\t// 首先我們將默認值填上\n\td := &myDialer{\n\t\ttimeout: time.Second,\n\t\tretry: 2,\n\t}\n\t// 接下來用傳入的 Option 修改默認值,如果不需要修改默認值,\n\t// 就不需要傳入對應的 Option\n\tfor _, opt := range opts {\n\t\topt.apply(d)\n\t}\n\t// 最後再檢查一下,如果 Option 沒有傳入自定義的必要字段,我\n\t// 們在這裏補一下。\n\tif d.dialer == nil {\n\t\td.dialer = &net.Dialer{}\n\t}\n\treturn d\n}\n\n// 我們也可以提供單獨的方法,並隨接口導出,提供類似 Set 模式的功能。\nfunc (d *myDialer) ApplyOptions(opts ...Option) {\n\tfor _, opt := range opts {\n\t\topt.apply(d)\n\t}\n}\n"}]},{"type":"numberedlist","attrs":{"start":"3","normalizeStart":"3"},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"現在我們來實現Option類型。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"先用常規方式寫一種囉嗦的寫法:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type retryOpt struct {\n\tretry int\n}\n\nfunc RetryOption(r int) Option {\n\treturn &retryOpt{retry:r}\n}\n\nfunc (o *retryOpt) apply(d *myDialer) {\n\td.retry = o.retry\n}\n\ntype timeoutOpt struct {\n\ttimeout time.Duration\n}\n\nfunc TimeoutOption(d time.Duration) Option {\n\treturn &timeoutOpt{timeout: d}\n}\n\nfunc (o *retryOpt) apply(d *myDialer) {\n\td.timeout = o.timeout\n}\n// ... dialer 的 Opt 類似\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"常規方式裏面需要一個實現 Option 接口的類型,和一個該類型的構造方法。所以我們設置3個字段,就需要寫9段代碼。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面我們用"},{"type":"text","marks":[{"type":"strong"}],"text":"函數轉單方法接口"},{"type":"text","text":"的套路,來簡化實現 Option 的代碼。"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type optFunc func(*myDialer)\n\nfunc (f optFunc) apply(d *myDialer) {\n\tf(d)\n}\n\nfunc RetryOption(r int) Option {\n\treturn optFunc(func(d *myDialer) {\n\t\td.retry = r\n\t})\n}\n\nfunc TimeoutOption(timeout time.Duration) Option {\n\treturn optFunc(func(d *myDialer) {\n\t\td.timeout = timeout\n\t})\n}\n\nfunc DialerOption(dialer *net.Dialer) Option {\n\treturn optFunc(func(d *myDialer) {\n\t\td.dialer = dialer\n\t})\n}\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"使用示例"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下來我們使用這個 MyDialer,看看有多方便:"}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"// 無自定義 Option,全部使用默認的\nd := NewMyDialer()\n// 只修改 Retry,並且 Retry 是0次\nd := NewMyDialer(RetryOption(0))\n// 修改多個 Option\nd := NewMyDialer(RetryOption(5), TimeoutOption(time.Minute), DialerOption(&net.Dialer{\n\tKeepAlive: 3*time.Second,\n}))\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"補充"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Rob Pike 是在2014年寫 Blog 總結這個套路的,當時他的 Option 不是一個 interface,而是一個function。使用上略有差異。目前普遍認爲函數轉單方法接口這種做法更靈活,建議大家使用這個方式。"}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"總結"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最後我說一個我總結這個套路的心得。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先,最初我在尋找一個創建對象的最佳套路時,主要的方向還是看那五個創建型模式(工廠、抽象工廠、生成器、單例、原型),看來看去也沒有找到合適的,沒想到截止目前找到的最佳套路是命令模式。再次說明套路重要,對套路的創新更加重要。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其次,我想感嘆一下,作爲 "},{"type":"link","attrs":{"href":"mailto:[email protected]","title":null},"content":[{"type":"text","text":"[email protected]"}]},{"type":"text","text":" 這個頂級郵箱的擁有者,Rob Pike 老爺子仍然堅持親自寫代碼,並在代碼細節上如此盡善盡美,令人敬仰。而我們國內技術圈卻經常花大量時間討論架構師應不應該寫代碼,甚至架構師是否需要會寫代碼,這可能也是許多技術文章字裏行間散發着一股傷痕文學氣息的原因之一吧。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章