Golang 常見設計模式之選項模式

熟悉 Python 開發的同學都知道,Python 有默認參數的存在,使得我們在實例化一個對象的時候,可以根據需要來選擇性的覆蓋某些默認參數,以此來決定如何實例化對象。當一個對象有多個默認參數時,這個特性非常好用,能夠優雅地簡化代碼。

而 Go 語言從語法上是不支持默認參數的,所以為了實現既能通過默認參數創建對象,又能通過傳遞自定義參數創建對象,我們就需要通過一些編程技巧來實現。對於這些程序開發中的常見問題,軟件行業的先行者們總結了許多解決常見場景編碼問題的最佳實踐,這些最佳實踐後來成為了我們所說的設計模式。其中選項模式在 Go 語言開發中會經常用到。

通常我們有以下三種方法來實現通過默認參數創建對象,以及通過傳遞自定義參數創建對象:

  • 使用多個構造函數

  • 默認參數選項

  • 選項模式

通過多構造函數實現

第一種方式是通過多構造函數實現,下面是一個簡單例子:

package main

import "fmt"

const (
    defaultAddr = "127.0.0.1"
    defaultPort = 8000
)

type Server struct {
    Addr string
    Port int
}

func NewServer() *Server {
    return &Server{
        Addr: defaultAddr,
        Port: defaultPort,
    }
}

func NewServerWithOptions(addr string, port int) *Server {
    return &Server{
        Addr: addr,
        Port: port,
    }
}

func main() {
    s1 := NewServer()
    s2 := NewServerWithOptions("localhost", 8001)
    fmt.Println(s1)  // &{127.0.0.1 8000}
    fmt.Println(s2)  // &{localhost 8001}
}

這裡我們為 Server 結構體實現了兩個構造函數:

  • NewServer:無需傳遞參數即可直接返回 Server 對象

  • NewServerWithOptions :需要傳遞 addr 和 port 兩個參數來構造 Server 對象

如果通過默認參數創建的對象即可滿足需求,不需要對 Server 進行定製時,我們可以使用 NewServer 來生成對象(s1)。而如果需要對 Server 進行定製時,我們則可以使用 NewServerWithOptions 來生成對象(s2)。

通過默認參數選項實現

另外一種實現默認參數的方案,是為要生成的結構體對象定義一個選項結構體,用來生成要創建對象的默認參數,代碼實現如下:

package main

import "fmt"

const (
    defaultAddr = "127.0.0.1"
    defaultPort = 8000
)

type Server struct {
    Addr string
    Port int
}

type ServerOptions struct {
    Addr string
    Port int
}

func NewServerOptions() *ServerOptions {
    return &ServerOptions{
        Addr: defaultAddr,
        Port: defaultPort,
    }
}

func NewServerWithOptions(opts *ServerOptions) *Server {
    return &Server{
        Addr: opts.Addr,
        Port: opts.Port,
    }
}

func main() {
    s1 := NewServerWithOptions(NewServerOptions())
    s2 := NewServerWithOptions(&ServerOptions{
        Addr: "localhost",
        Port: 8001,
    })
    fmt.Println(s1)  // &{127.0.0.1 8000}
    fmt.Println(s2)  // &{localhost 8001}
}

我們為 Server 結構體專門實現了一個 ServerOptions 用來生成默認參數,調用 NewServerOptions 函數即可獲得默認參數配置,構造函數 NewServerWithOptions 接收一個 *ServerOptions 類型作為參數。所以我們可以通過以下兩種方式來完成功能:

  • 直接將調用 NewServerOptions 函數的返回值傳遞給 NewServerWithOptions 來實現通過默認參數生成對象(s1)

  • 通過手動構造 ServerOptions 配置來生成定製對象(s2)

通過選項模式實現

以上兩種方式雖然都能夠完成功能,但卻有以下缺點:

  • 通過多構造函數實現的方案需要我們在實例化對象時分別調用不同的構造函數,代碼封裝性不強,會給調用者增加使用負擔。

  • 通過默認參數選項實現的方案需要我們預先構造一個選項結構,當使用默認參數生成對象時代碼看起來比較冗餘。

而選項模式可以讓我們更為優雅地解決這個問題。代碼實現如下:

package main

import "fmt"

const (
    defaultAddr = "127.0.0.1"
    defaultPort = 8000
)

type Server struct {
    Addr string
    Port int
}

type ServerOptions struct {
    Addr string
    Port int
}

type ServerOption interface {
    apply(*ServerOptions)
}

type FuncServerOption struct {
    f func(*ServerOptions)
}

func (fo FuncServerOption) apply(option *ServerOptions) {
    fo.f(option)
}

func WithAddr(addr string) ServerOption {
    return FuncServerOption{
        f: func(options *ServerOptions) {
            options.Addr = addr
        },
    }
}

func WithPort(port int) ServerOption {
    return FuncServerOption{
        f: func(options *ServerOptions) {
            options.Port = port
        },
    }
}

func NewServer(opts ...ServerOption) *Server {
    options := ServerOptions{
        Addr: defaultAddr,
        Port: defaultPort,
    }

    for _, opt := range opts {
        opt.apply(&options)
    }

    return &Server{
        Addr: options.Addr,
        Port: options.Port,
    }
}

func main() {
    s1 := NewServer()
    s2 := NewServer(WithAddr("localhost"), WithPort(8001))
    s3 := NewServer(WithPort(8001))
    fmt.Println(s1)  // &{127.0.0.1 8000}
    fmt.Println(s2)  // &{localhost 8001}
    fmt.Println(s3)  // &{127.0.0.1 8001}
}

乍一看我們的代碼複雜了很多,但其實調用構造函數生成對象的代碼複雜度是沒有改變的,只是定義上的複雜。

我們定義了 ServerOptions 結構體用來配置默認參數。因為 Addr 和 Port 都有默認參數,所以 ServerOptions 的定義和 Server 定義是一樣的。但有一定複雜性的結構體中可能會有些參數沒有默認參數,必須讓用戶來配置,這時 ServerOptions 的字段就會少一些,大家可以按需定義。

同時,我們還定義了一個 ServerOption 接口和實現了此接口的 FuncServerOption 結構體,它們的作用是讓我們能夠通過 apply 方法為 ServerOptions 結構體單獨配置某項參數。

我們可以分別為每個默認參數都定義一個 WithXXX 函數用來配置參數,如這裡定義的 WithAddr 和 WithPort ,這樣用戶就可以通過調用 WithXXX 函數來定製需要覆蓋的默認參數。

此時默認參數定義在構造函數 NewServer 中,構造函數的接收一個不定長參數,類型為 ServerOption,在構造函數內部通過一個 for 循環調用每個傳進來的 ServerOption 對象的 apply 方法,將用戶配置的參數依次賦值給構造函數內部的默認參數對象 options 中,以此來替換默認參數,for 循環執行完成後,得到的 options 對象將是最終配置,將其屬性依次賦值給 Server 即可生成新的對象。

總結

通過 s2 和 s3 的打印結果可以發現,使用選項模式實現的構造函數更加靈活,相較於前兩種實現,選項模式中我們可以自由的更改其中任意一項或多項默認配置。

雖然選項模式確實會多寫一些代碼,但多數情況下這都是值得的。比如 Google 的 gRPC 框架 Go 語言實現中創建 gRPC server 的構造函數 NewServer 就使用了選項模式,感興趣的同學可以看下其源碼的實現思想其實和這裡的示例程序如出一轍。

以上就是我關於 Golang 選項模式的一點經驗,希望今天的分享能夠給你帶來一些幫助。

推薦閱讀

服務端渲染基礎

雲原生灰度更新實踐