最近使用 GRPC
發現一個設計特別好的地方,非常值得借鑑。
我們在日常寫方法的時候,希望給某個字段設置一個默認值,不需要定製化的場景就不傳這個參數,但是 Golang
卻沒有提供像 PHP
、Python
這種動態語言設置方法參數默認值的能力。
低階玩家應對默認值問題
以一個購物車舉例。比如我有下面這樣一個購物車的結構體,其中 CartExts
是擴展屬性,它有自己的默認值,使用者希望如果不改變默認值時就不傳該參數。但是由於 Golang
無法在參數中設置默認值,只有以下幾個選擇:
提供一個初始化函數,所有的
ext
字段都做爲參數,如果不需要的時候傳該類型的零值,這把複雜度暴露給調用者;將
ext
這個結構體做爲一個參數在初始化函數中,與1
一樣,複雜度在於調用者;提供多個初始化函數,針對每個場景都進行內部默認值設置。
下面看下代碼具體會怎麼做
const (
CommonCart = "common"
BuyNowCart = "buyNow"
)
type CartExts struct {
CartType string
TTL time.Duration
}
type DemoCart struct {
UserID string
ItemID string
Sku int64
Ext CartExts
}
var DefaultExt = CartExts{
CartType: CommonCart, // 默認是普通購物車類型
TTL: time.Minute * 60, // 默認 60min 過期
}
// 方式一:每個擴展數據都做爲參數
func NewCart(userID string, Sku int64, TTL time.Duration, cartType string) *DemoCart {
ext := DefaultExt
if TTL > 0 {
ext.TTL = TTL
}
if cartType == BuyNowCart {
ext.CartType = cartType
}
return &DemoCart{
UserID: userID,
Sku: Sku,
Ext: ext,
}
}
// 方式二:多個場景的獨立初始化函數;方式二會依賴一個基礎的函數
func NewCartScenes01(userID string, Sku int64, cartType string) *DemoCart {
return NewCart(userID, Sku, time.Minute*60, cartType)
}
func NewCartScenes02(userID string, Sku int64, TTL time.Duration) *DemoCart {
return NewCart(userID, Sku, TTL, "")
}
上面的代碼看起來沒什麼問題,但是我們設計代碼最重要的考慮就是穩定與變化,我們需要做到 對擴展開放,對修改關閉 以及代碼的 高內聚。那麼如果是上面的代碼,你在 CartExts
增加了一個字段或者減少了一個字段。是不是每個地方都需要進行修改呢?又或者 CartExts
如果有非常多的字段,這個不同場景的構造函數是不是得寫非常多個?所以簡要概述一下上面的辦法存在的問題。
不方便對
CartExts
字段進行擴展;如果
CartExts
字段非常多,構造函數參數很長,難看、難維護;所有的字段構造邏輯冗餘在
NewCart
中,麪條代碼不優雅;如果採用
CartExts
做爲參數的方式,那麼就將過多的細節暴露給了調用者。
接下來我們來看看 GRPC
是怎麼做的,學習優秀的範例,提升自我的代碼能力。
從這你也可以體會到代碼功底牛逼的人,代碼就是寫的美!
GRPC 之高階玩家設置默認值
源碼來自:[email protected] 版本。爲了突出主要目標,對代碼進行了必要的刪減。
// dialOptions 詳細定義在 google.golang.org/grpc/dialoptions.go
type dialOptions struct {
// ... ...
insecure bool
timeout time.Duration
// ... ...
}
// ClientConn 詳細定義在 google.golang.org/grpc/clientconn.go
type ClientConn struct {
// ... ...
authority string
dopts dialOptions // 這是我們關注的重點,所有可選項字段都在這裏
csMgr *connectivityStateManager
// ... ...
}
// 創建一個 grpc 鏈接
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
cc := &ClientConn{
target: target,
csMgr: &connectivityStateManager{},
conns: make(map[*addrConn]struct{}),
dopts: defaultDialOptions(), // 默認值選項
blockingpicker: newPickerWrapper(),
czData: new(channelzData),
firstResolveEvent: grpcsync.NewEvent(),
}
// ... ...
// 修改改選爲用戶的默認值
for _, opt := range opts {
opt.apply(&cc.dopts)
}
// ... ...
}
上面代碼的含義非常明確,可以認爲 DialContext
函數是一個 grpc 鏈接的創建函數,它內部主要是構建 ClientConn
這個結構體,並做爲返回值。defaultDialOptions
函數返回的是系統提供給 dopts 字段的默認值,如果用戶想要自定義可選屬性,可以通過可變參數 opts 來控制。
經過上面的改進,我們驚奇的發現,這個構造函數非常的優美,無論 dopts 字段如何增減,構造函數不需要改動;defaultDialOptions
也可以從一個公有字段變爲一個私有字段,更加對內聚,對調用者友好。
那麼這一切是怎麼實現的?下面我們一起學習這個實現思路。
DialOption 的封裝
首先,這裏的第一個技術點是,DialOption
這個參數類型。我們通過可選參數方式優化了可選項字段修改時就要增加構造函數參數的尷尬,但是要做到這一點就需要確保可選字段的類型一致,實際工作中這是不可能的。所以又使出了程序界最高手段,一層實現不了,就加一層。
通過這個接口類型,實現了對各個不同字段類型的統一,讓構造函數入參簡化。來看一下這個接口。
type DialOption interface {
apply(*dialOptions)
}
這個接口有一個方法,其參數是 *dialOptions
類型,我們通過上面 for 循環處的代碼也可以看到,傳入的是 &cc.dopts
。簡單說就是把要修改的對象傳入進來。apply
方法內部實現了具體的修改邏輯。
那麼,這既然是一個接口,必然有具體的實現。來看一下實現。
// 空實現,什麼也不做
type EmptyDialOption struct{}
func (EmptyDialOption) apply(*dialOptions) {}
// 用到最多的地方,重點講
type funcDialOption struct {
f func(*dialOptions)
}
func (fdo *funcDialOption) apply(do *dialOptions) {
fdo.f(do)
}
func newFuncDialOption(f func(*dialOptions)) *funcDialOption {
return &funcDialOption{
f: f,
}
}
我們重點說 funcDialOption
這個實現。這算是一個高級用法,體現了在 Golang 裏邊函數是 一等公民。它有一個構造函數,以及實現了 DialOption
接口。
newFuncDialOption
構造函數接收一個函數做爲唯一參數,然後把傳入的函數保存到 funcDialOption
的字段 f
上。再來看看這個參數函數的參數類型是 *dialOptions
,與 apply
方法的參數是一致的,這是設計的第二個重點。
現在該看 apply
方法的實現了。它非常簡單,其實就是調用構造 funcDialOption
時傳入的方法。可以理解爲相當於做了一個代理。把 apply
要修改的對象丟到 f
這個方法中。所以重要的邏輯都是我們傳入到 newFuncDialOption
這個函數的參數方法實現的。
現在來看看 grpc 內部有哪些地方調用了 newFuncDialOption
這個構造方法。
newFuncDialOption 的調用
由於 newFuncDialOption
返回的 *funcDialOption
實現了 DialOption
接口,因此關注哪些地方調用了它,就可以順藤摸瓜的找到我們最初 grpc.DialContext
構造函數 opts 可以傳入的參數。
調用了該方法的地方非常多,我們只關注文章中列出的兩個字段對應的方法:
insecure
與timeout
。
// 以下方法詳細定義在 google.golang.org/grpc/dialoptions.go
// 開啓不安全傳輸
func WithInsecure() DialOption {
return newFuncDialOption(func(o *dialOptions) {
o.insecure = true
})
}
// 設置 timeout
func WithTimeout(d time.Duration) DialOption {
return newFuncDialOption(func(o *dialOptions) {
o.timeout = d
})
}
來體驗一下這裏的精妙設計:
首先對於每一個字段,提供一個方法來設置其對應的值。由於每個方法返回的類型都是
DialOption
,從而確保了grpc.DialContext
方法可用可選參數,因爲類型都是一致的;返回的真實類型是
*funcDialOption
,但是它實現了接口DialOption
,這增加了擴展性。
grpc.DialContext 的調用
完成了上面的程序構建,現在我們來站在使用的角度,感受一下這無限的風情。
opts := []grpc.DialOption{
grpc.WithTimeout(1000),
grpc.WithInsecure(),
}
conn, err := grpc.DialContext(context.Background(), target, opts...)
// ... ...
當然這裏要介紹的重點就是 opts
這個 slice ,它的元素就是實現了 DialOption
接口的對象。而上面的兩個方法經過包裝後都是 *funcDialOption
對象,它實現了 DialOption
接口,因此這些函數調用後的返回值就是這個 slice 的元素。
現在我們可以進入到 grpc.DialContext
這個方法內部,看到它內部是如何調用的。遍歷 opts,然後依次調用 apply
方法完成設置。
// 修改改選爲用戶的默認值
for _, opt := range opts {
opt.apply(&cc.dopts)
}
經過這樣一層層的包裝,雖然增加了不少代碼量,但是明顯能夠感受到整個代碼的美感、可擴展性都得到了改善。接下來看一下,我們自己的 demo 要如何來改善呢?
改善 DEMO 代碼
首先我們需要對結構體進行改造,將 CartExts
變成 cartExts
, 並且需要設計一個封裝類型來包裹所有的擴展字段,並將這個封裝類型做爲構造函數的可選參數。
const (
CommonCart = "common"
BuyNowCart = "buyNow"
)
type cartExts struct {
CartType string
TTL time.Duration
}
type CartExt interface {
apply(*cartExts)
}
// 這裏新增了類型,標記這個函數。相關技巧後面介紹
type tempFunc func(*cartExts)
// 實現 CartExt 接口
type funcCartExt struct {
f tempFunc
}
// 實現的接口
func (fdo *funcCartExt) apply(e *cartExts) {
fdo.f(e)
}
func newFuncCartExt(f tempFunc) *funcCartExt {
return &funcCartExt{f: f}
}
type DemoCart struct {
UserID string
ItemID string
Sku int64
Ext cartExts
}
var DefaultExt = cartExts{
CartType: CommonCart, // 默認是普通購物車類型
TTL: time.Minute * 60, // 默認 60min 過期
}
func NewCart(userID string, Sku int64, exts ...CartExt) *DemoCart {
c := &DemoCart{
UserID: userID,
Sku: Sku,
Ext: DefaultExt, // 設置默認值
}
// 遍歷進行設置
for _, ext := range exts {
ext.apply(&c.Ext)
}
return c
}
經過這一番折騰,我們的代碼看起來是不是非常像 grpc 的代碼了?還差最後一步,需要對 cartExts
的每個字段包裝一個函數。
func WithCartType(cartType string) CartExt {
return newFuncCartExt(func(exts *cartExts) {
exts.CartType = cartType
})
}
func WithTTL(d time.Duration) CartExt {
return newFuncCartExt(func(exts *cartExts) {
exts.TTL = d
})
}
對於使用者來說,只需如下處理:
exts := []CartExt{
WithCartType(CommonCart),
WithTTL(1000),
}
NewCart("dayu", 888, exts...)
總結
是不是非常簡單?我們再一起來總結一下這裏代碼的構建技巧:
把可選項收斂到一個統一的結構體中;並且將該字段私有化;
定義一個接口類型,這個接口提供一個方法,方法的參數應該是可選屬性集合的結構體的指針類型,因爲我們要修改其內部值,所以一定要指針類型;
定義一個函數類型,該函數應該跟接口類型中的方法保持一致的參數,都使用可選項收斂的這個結構體指針作爲參數;(非常重要)
定義一個結構體,並實現
2
中的接口類型;(這一步並非必須,但這是一種良好的編程風格)利用實現了接口的類型,封裝可選字段對應的方法;命令建議用 With + 字段名 的方式。
按照上面的五步大法,你就能夠實現設置默認值的高階玩法。
如果你喜歡這個類型的文章,歡迎留言點贊!