Golang技巧之默認值的設置

最近使用 GRPC 發現一個設計特別好的地方,非常值得借鑑。

我們在日常寫方法的時候,希望給某個字段設置一個默認值,不需要定製化的場景就不傳這個參數,但是 Golang 卻沒有提供像 PHPPython 這種動態語言設置方法參數默認值的能力。

低階玩家應對默認值問題

以一個購物車舉例。比如我有下面這樣一個購物車的結構體,其中 CartExts 是擴展屬性,它有自己的默認值,使用者希望如果不改變默認值時就不傳該參數。但是由於 Golang 無法在參數中設置默認值,只有以下幾個選擇:

  1. 提供一個初始化函數,所有的 ext 字段都做爲參數,如果不需要的時候傳該類型的零值,這把複雜度暴露給調用者;

  2. ext 這個結構體做爲一個參數在初始化函數中,與 1 一樣,複雜度在於調用者;

  3. 提供多個初始化函數,針對每個場景都進行內部默認值設置。

下面看下代碼具體會怎麼做

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 如果有非常多的字段,這個不同場景的構造函數是不是得寫非常多個?所以簡要概述一下上面的辦法存在的問題。

  1. 不方便對 CartExts 字段進行擴展;

  2. 如果 CartExts 字段非常多,構造函數參數很長,難看、難維護;

  3. 所有的字段構造邏輯冗餘在 NewCart 中,麪條代碼不優雅;

  4. 如果採用 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 可以傳入的參數。

調用了該方法的地方非常多,我們只關注文章中列出的兩個字段對應的方法:insecuretimeout


// 以下方法詳細定義在 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
 })
}

來體驗一下這裏的精妙設計:

  1. 首先對於每一個字段,提供一個方法來設置其對應的值。由於每個方法返回的類型都是 DialOption ,從而確保了 grpc.DialContext 方法可用可選參數,因爲類型都是一致的;

  2. 返回的真實類型是 *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...)

總結

是不是非常簡單?我們再一起來總結一下這裏代碼的構建技巧:

  1. 把可選項收斂到一個統一的結構體中;並且將該字段私有化;

  2. 定義一個接口類型,這個接口提供一個方法,方法的參數應該是可選屬性集合的結構體的指針類型,因爲我們要修改其內部值,所以一定要指針類型;

  3. 定義一個函數類型,該函數應該跟接口類型中的方法保持一致的參數,都使用可選項收斂的這個結構體指針作爲參數;(非常重要)

  4. 定義一個結構體,並實現 2 中的接口類型;(這一步並非必須,但這是一種良好的編程風格)

  5. 利用實現了接口的類型,封裝可選字段對應的方法;命令建議用 With + 字段名 的方式。

按照上面的五步大法,你就能夠實現設置默認值的高階玩法。

如果你喜歡這個類型的文章,歡迎留言點贊!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章