目录
Bad approach
Bad approach v2
Simple approach
Good approach
Good approach v2
Third-Party Library 的作法
go-pg
elastic
总结
相信大家平时也常听说函数式选项模式,今天生态君给大家带来一篇关于常见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要怎么去实现以及不同方式的优缺点在哪,来看一下吧。
为了方便示范,我们以 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 自由给值的字段。
在一开始功能还不复杂的时候,只允许 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 就越来越长。
全部挤在同一个 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?这样还是一样很不好。
那其实要解决 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,而没有预设值。总之,这样的方式让可选参数的设计变得很不直观,而且可读性也不好。
那么有没有比较好的方式可以让可读性更强,而且在设定 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 的参数了解是什么意思,才能知道要怎么给值。
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..."
}
那我们可以来看一下一些知名的 library 的 option 设置的做法:
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[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生态
参考资料
go-pg: https://github.com/go-pg/pg
[2]elastic: https://github.com/olivere/elastic
推荐阅读