Golang技巧之默認值設置的高階玩法

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

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

低階玩家應對默認值問題

以一個購物車舉例。好比我有下面這樣一個購物車的結構體,其中 CartExts 是擴展屬性,它有本身的默認值,使用者但願若是不改變默認值時就不傳該參數。可是因爲 Golang 沒法在參數中設置默認值,只有如下幾個選擇:安全

  1. 提供一個初始化函數,全部的 ext 字段都作爲參數,若是不須要的時候傳該類型的零值,這把複雜度暴露給調用者;
  2. ext 這個結構體作爲一個參數在初始化函數中,與 1 同樣,複雜度在於調用者;
  3. 提供多個初始化函數,針對每一個場景都進行內部默認值設置。

下面看下代碼具體會怎麼作app

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 也能夠從一個公有字段變爲一個私有字段,更加對內聚,對調用者友好。google

那麼這一切是怎麼實現的?下面咱們一塊兒學習這個實現思路。設計

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 + 字段名 的方式。

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

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

相關文章
相關標籤/搜索