Golang 中常見的 option 設計探討

目錄

相信大家平時也常聽說函數式選項模式,今天生態君給大家帶來一篇關於常見 option 探討的文章,希望大家看完能吸收應用到工作中。原文見 https://blog.kennycoder.io/2021/09/06/Golang-%E5%B8%B8%E8%A6%8B%E7%9A%84-option-%E8%A8%AD%E8%A8%88%E6%8E%A2%E8%A8%8E/。

在寫 Golang 的時候常常會需要封裝 struct 的操作,而通常會針對該 struct 做一個 New func 的操作,爲的就是方便 inject 相對應的 dependency 進去。那麼就會碰到需要有 option 的時候,所謂 option 的時候,是指說有些字段設置是可以給 client 自由設定的,此外如果 client 沒有設定,會有所謂的預設值。那麼這樣的設計在 Golang 要怎麼去實現以及不同方式的優缺點在哪,來看一下吧。

  1. https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis

  2. https://github.com/uber-go/guide/blob/master/style.md#functional-options

  3. https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html

爲了方便示範,我們以 http server 來進行封裝,先來看 http. Server 有哪些 field:

type Server struct {
  Addr string
  Handler Handler 
  TLSConfig *tls.Config
  ReadTimeout time.Duration
  ReadHeaderTimeout time.Duration
  WriteTimeout time.Duration
  IdleTimeout time.Duration
  MaxHeaderBytes int
  TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
  ConnState func(net.Conn, ConnState)
  ErrorLog *log.Logger
  BaseContext func(net.Listener) context.Context
  ConnContext func(ctx context.Context, c net.Conn) context.Context
  ...
}

這些是公開的 field,也就是說可以讓 client 自由給值的字段。

Bad approach

在一開始功能還不復雜的時候,只允許 client 設定某個字段,其他字段用默認值或是 zero value 就好:

func NewServer(addr string) *http.Server {
  return &http.Server{Addr: addr}
}

OK,當然只有 addr 是不夠的,之後會希望可以 inject handler:

func NewServer(addr string, handler http.Handler) *http.Server {
  return &http.Server{Addr: addr, Handler: handler}
}

OK,這時候又希望可以自定義 timeout 或是 TLS 等等的設置:

func NewServer(addr string, handler http.Handler, readTimeout time.Duration, tlsConfig *tls.Config) *http.Server {
  return &http.Server{Addr: addr, Handler: handler, ReadTimeout: readTimeout, TLSConfig: tlsConfig}
}

以上的方式,會隨着你的字段越多你的 function 的 parameter 就越來越長。

Bad approach v2

全部擠在同一個 function 不好,那麼拆開呢?

func NewServer(addr string) *http.Server {
  return &http.Server{Addr: addr}
}

func NewServerWithHandler(addr string, handler http.Handler) *http.Server {
  return &http.Server{Addr: addr, Handler: handler}
}

func NewServerWithReadTimeout(addr string, handler http.Handler, readTimeout time.Duration) *http.Server {
  return &http.Server{Addr: addr, Handler: handler, ReadTimeout: readTimeout}
}

...

恩... 看起來有好一點點,但是一樣隨着參數越來越多,你每多一個參數你就要多一個 function?這樣還是一樣很不好。

Simple approach

那其實要解決 bad apporach,有一種最簡單的方法,把所有可能會給 client 設定的可選參數都包裝成另外一個 struct 叫做 config,不就解決了嗎?

type Config struct {
  Handler Handler 
  TLSConfig *tls.Config
  ReadTimeout time.Duration
  ...
}

func NewServer(addr string, cfg Config) *http.Server {
  return &http.Server{Addr: addr, Handler: cfg.Handler, ReadTimeout: cfg.ReadTimeout, ...}
}

這樣看起來真的很不錯,不再需要考慮要多新增參數或是新增 function,只因爲 client 的可選參數增加,只要在 Config 多加字段就可以了。

但問題來了,前面我們說到如果需要給預設值怎麼辦呢?

NewServer 這邊去檢查每一個 config 的參數是不是爲 zero value,如果是的話就當做 client 沒有給參數,需要用預設值:

func NewServer(addr string, cfg Config) *http.Server {
  if cfg.ReadTimeout == 0 {
    cfg.ReadTimeout = 3 * time.Second
  }
  if ...
  return &http.Server{Addr: addr, Handler: cfg.Handler, ReadTimeout: cfg.ReadTimeout, ...}
}

看起來也是不錯的解決方案,問題是有時候可能 client 端就是想要設定 zero value 作爲參數的值,那上面的做法就無法判斷了。

此外對於 client 端而言也會很 confuse:

server.NewServer(":8080", server.Config{})

client 端都會需要提供第二個參數,就算不想使用其他參數,還是要給個空 struct,來設定 zero value。

這時候有些人可能會選擇將第二個參數設爲 pointer 的類型,這樣 client 可以直接給 nil

server.NewServer(":8080", nil)

雖然這樣的做法在語意上更強烈了,client 明確的說不想要設定可選參數。但事實上對於 server 的封裝是會給預設值的,這樣又容易讓 client 端以爲其他參數都會是 zero value,而沒有預設值。總之,這樣的方式讓可選參數的設計變得很不直觀,而且可讀性也不好。

Good approach

那麼有沒有比較好的方式可以讓可讀性更強,而且在設定 default value 的時候也方便呢?

可以這樣設計:

func NewServer(addr string, options ...func(server *http.Server)) *http.Server {
  server := &http.Server{Addr: addr, ReadTimeout: 3 * time.Second}
  for _, opt := range options {
    opt(server)
  }
  return server
}

通過不定長度的方式代表可以給多個 options,以及每一個 option 是一個 func 型態,其參數型態爲 *http. Server。那我們就可以在 NewServer 這邊先給 default value,然後通過 for loop 將每一個 options 對其 Server 做的參數進行設置,這樣 client 端不僅可以針對他想要的參數進行設置,其他沒設置到的參數也不需要特地給 zero value 或是默認值,完全封裝在 NewServer 就可以了。

這樣的做法就是將 http. Server struct 可以給 client 設值的 field 給 export 出來,讓 client 端可以給相對應的值:

func main() {
  readTimeoutOption := func(server *http.Server) {
    server.ReadTimeout = 5 * time.Second
  }
  handlerOption := func(server *http.Server) {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(writer http.ResponseWriter, request *http.Request) {
      writer.WriteHeader(http.StatusOK)
    })
    server.Handler = http.NewServeMux()
  }
  s := server.NewServer(":8080", readTimeoutOption, handlerOption)
}

那當然,這樣的話 client 端就要對 Option 的參數瞭解是什麼意思,才能知道要怎麼給值。

Good approach v2

Uber 這邊有提供更加強版的方式,第一種版本的延伸來看一下:

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

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

  // ...
}

這樣的設計方式就又更細粒度了一點,以及將所有 option 給值的方式又再進行了封裝。

可以看到通過設計一個 Option interface,裏面用了 apply function,以及使用一個 options struct 將所有的 field 都放在這個 struct 裏面,每一個 field 又會用另外一種 struct 或是 custom type 進行封裝,並 implement apply function,最後再提供一個 public function:WithLogger 去給 client 端設值。

這樣的做法好處是可以針對每一個 option 作更細的 custom function 設計,例如選項的 description 爲何?可以爲每一個 option 再去 implement Stringer interface,之後提供 option 描述就可以調用 toString 了,設計上更加的方便!

例如:

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}
func (l loggerOption) String() string {
  return "logger description..."
}

Third-Party Library 的作法

那我們可以來看一下一些知名的 library 的 option 設置的做法:

go-pg

go-pg[1] 是針對 Postgres 的 Golang ORM 的封裝,來看一下怎麼進行 Postgres 的連接:

func Connect(opt *Options) *DB {
  opt.init()
  return newDB(
    context.Background(),
    &baseDB{
      opt:   opt,
      pool:  newConnPool(opt),
      fmter: orm.NewFormatter(),
    },
  )
}

go-pg 的設計比較簡單,有點類似上面我們提到 simple approach 的解法,通過一個 Options Struct 來將所有可以給 client 端設置的 field 封裝在裏面:

type Options struct {
  Network string
  Addr string
  ...
}

func (opt *Options) init() {
  if opt.Network == "" {
    opt.Network = "tcp"
  }
  
  if opt.Addr == "" {
    switch opt.Network {
    case "tcp":
      host := env("PGHOST", "localhost")
      port := env("PGPORT", "5432")
      opt.Addr = fmt.Sprintf("%s:%s", host, port)
    case "unix":
      opt.Addr = "/var/run/postgresql/.s.PGSQL.5432"
    }
  }
}

並且將給 default value 的設計封裝在 init function 裏面,然後通過 client Connect 的時候先調用 opt.init (),藉此判斷 zero value 的情況並給予相對應的 default value。

這樣的做法也是一種方式,但就是要多判斷 zero value 的情況。

elastic

elastic[2] 這個是封裝 elasticSearch 的 client 的 library。

這個 library 的設計就比較像是 Good approach 的設計方式了,來看看:

type ClientOptionFunc func(*Client) error

首先,先定義 ClientOptionFunc type 來將 options 的設計封裝,並且特別的是 return err,這其實很常見的,因爲要檢查每一個 option 給這樣的值是否正確。

接着爲每一個 option 提供相對應的 public func,例如:

func SetURL(urls ...string) ClientOptionFunc {
  return func(c *Client) error {
    switch len(urls) {
    case 0:
      c.urls = []string{DefaultURL}
    default:
      c.urls = urls
    }
    // Check URLs
    for _, urlStr := range c.urls {
      if _, err := url.Parse(urlStr); err != nil {
        return err
      }
    }
    return nil
  }
}

那最後就是將每一個 option 結合再一起給 Client 進行設值的動作:

func NewClient(options ...ClientOptionFunc) (*Client, error) {
  return DialContext(context.Background(), options...)
}
func DialContext(ctx context.Context, options ...ClientOptionFunc) (*Client, error) {
  // Set up the client
  c := &Client{
    c:                         http.DefaultClient,
    conns:                     make([]*conn, 0),
    cindex:                    -1,
    scheme:                    DefaultScheme,
    decoder:                   &DefaultDecoder{},
    healthcheckEnabled:        DefaultHealthcheckEnabled,
    healthcheckTimeoutStartup: DefaultHealthcheckTimeoutStartup,
    healthcheckTimeout:        DefaultHealthcheckTimeout,
    healthcheckInterval:       DefaultHealthcheckInterval,
    healthcheckStop:           make(chan bool),
    snifferEnabled:            DefaultSnifferEnabled,
    snifferTimeoutStartup:     DefaultSnifferTimeoutStartup,
    snifferTimeout:            DefaultSnifferTimeout,
    snifferInterval:           DefaultSnifferInterval,
    snifferCallback:           nopSnifferCallback,
    snifferStop:               make(chan bool),
    sendGetBodyAs:             DefaultSendGetBodyAs,
    gzipEnabled:               DefaultGzipEnabled,
    retrier:                   noRetries, // no retries by default
    retryStatusCodes:          nil,       // no automatic retries for specific HTTP status codes
    deprecationlog:            noDeprecationLog,
  }
  
  // Run the options on it
  for _, option := range options {
    if err := option(c); err != nil {
      return nil, err
    }
  }
  ...
  return c, nil
}

一樣會先給 Client struct 的每一個值進行 default value 的設置,接着通過 for loop options 來爲 client 進行設值的動作,完美的解決不用特地檢查 zero value 的情況。而只要有一個 option 設置的時候有 return err 就 break loop 並且將 error message 傳給 client 端。

總結

今天這篇文章主要是探討在 options 的設計有哪幾種方式以及其優缺點爲何,個人現在是覺得 elastic 這樣的設計不錯,也就是 Good approach 的一種方式。而 Uber 提供的加強版 v2,則是當如果你想要對每一個 option 進行特別的設計的時候,例如另外 implment 其他 interface,此外更重要是 Uber 提供的設計在 unit test 上更加方便的測試每一個 option,因爲有 interface 可以更容易的進行 mock。

原文鏈接:https://blog.kennycoder.io/2021/09/06/Golang - 常見的 - option - 設計探討 /

轉自:Go 生態

參考資料

[1]  go-pg: https://github.com/go-pg/pg

[2]  elastic: https://github.com/olivere/elastic

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mzI8-KoRBhH-fGdfcyqI-w