Golang技巧之預設值設定的高階玩法

大愚Talk發表於2020-07-01

最近使用 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 之高階玩家設定預設值

原始碼來自:grpc@v1.28.1 版本。為了突出主要目標,對程式碼進行了必要的刪減。

// 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 + 欄位名 的方式。

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

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

相關文章