Golang - 常見的 option 設計探討

再寫 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 就好:

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

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

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

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

1
2
3
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 不好,那麼拆開呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
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,不就解決了嗎?

1
2
3
4
5
6
7
8
9
10
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 沒有給參數,需要用預設值:

1
2
3
4
5
6
7
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:

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

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

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

1
server.NewServer(":8080", nil)

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

Good approach

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

可以這樣設計:

1
2
3
4
5
6
7
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 端可以給相對應的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 這邊有提供更加強版的方式,第一種版本的延伸來看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 了,設計上更加的方便!

例如:

1
2
3
4
5
6
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 是針對 Postgres 的 Golang ORM 的封裝,來看一下怎麼進行 Postgres 的連接:

1
2
3
4
5
6
7
8
9
10
11
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 封裝在裡面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 這個是封裝 elasticSearch 的 client 的 library。

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

1
type ClientOptionFunc func(*Client) error

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 進行設值的動作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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。