理解go-micro(一)| 前置知识准备

引言
学习golang不久后,因工作需要接触到了go-micro这一微服务框架。经过读源码,写业务代码,定制个性化插件,解决框架问题这些过程后,对它有了更深刻的理解。总的来说,这是一个功能较为齐全,抽象较为合理的微服务框架,非常适合用来强化golang的学习以及加深对微服务领域知识的理解,但是否达到了生产标准的要求至今仍是个未知数,需要更多的检验。
本系列文章基于asim/go-micro v3.5.2版本,读者可于https://github.com/asim/go-micro拉取源代码进行学习。
准备
抛开微服务的领域知识,go-micro的整体设计主要基于Functional Options以及Interface Oriented,掌握这两点基本上就把握住了它的代码风格,对于之后的学习、使用、扩展大有裨益。因此首先介绍这两种设计模式,为之后的深入理解做好铺垫。
Functional Options 一. 问题引入
在介绍Functional Options之前,我们先来考虑一个平时编程的常规操作:配置并初始化一个对象。例如生成一个Server对象,需要指明IP地址和端口,如下所示:

type Server struct { Addr string Port int }

很自然的,构造函数可以写成如下形式:
func NewServer(addr string, port int) (*Server, error) { return &Server{ Addr: addr, Port: port, }, nil }

这个构造函数简单直接,但现实中一个Server对象远不止Addr和Port两个属性,为了支持更多的功能,例如监听tcp或者udp,设置超时时间,限制最大连接数,需要引入更多的属性,此时Server对象变成了如下形式:
type Server struct { Addrstring Portint Protocol string Timeouttime.Duration MaxConns int }

为了迎合属性变化,构造函数会变成以下形式:
func NewServer(addr string, port int, protocol string, timeout time.Duration, maxConns int) (*Server, error) { return &Server{ Addr:addr, Port:port, Protocol: protocol, Timeout:timeout, MaxConns: maxConns, }, nil }

相信大家已经发现了,随着属性的增多,这种形式的构造函数会越来越长,最后臃肿不堪。如果这个对象只是自己开发和使用,把控好具体细节和复杂度,还是能接受的,只需要将参数换行就行。但如果这个对象作为库的公共成员被其他开发者使用,那么这个构造函数即API会带来以下问题:
  1. 使用者只从API签名无法判断哪些参数是必须的,哪些参数是可选的
    例如NewServer被设计为必须传入addr与port,默认设置为监听tcp,超时时间60s,最大连接数10000。在没有文档的情况下,现在一个使用者想快速使用这个API搭建demo,凭借经验他会传入addr和port,但对于其它参数,他是无能为力的。比如timeout传0是意味着采用默认值还是永不超时,还是说必须要传入有效的值才行,根本无法从API签名得知。此时使用者只能通过查看具体实现才能掌握API正确的用法。
  2. 增加或删除Server属性后,API大概率也会随之变动
    现在考虑到安全属性,需要支持TLS,那么API签名会变成如下形式并使得使用之前版本API的代码失效:
func NewServer(addr string, port int, protocol string, timeout time.Duration, maxConns int, tls *tls.Config) (*Server, error)

可以看到,这种API写法十分简单,但它把所有的复杂度都暴露给了使用者,糟糕的文档说明更是让这种情况雪上加霜。因此API尽量不要采用这种形式书写,除非可以确定这个对象非常简单且稳定。
二. 解决方案
为了降低复杂度,减少使用者的心智负担,有一些解决方案可供参考。
1. 伪重载函数
golang本身不支持函数的重载,为了达到类似的效果,只能构造多个API。每个API都带有完整的必填参数和部分选填参数,因此API的数量即为选填参数的排列组合。具体签名如下所示(未展示全部):
func NewServer(addr string, port int) (*Server, error) func NewServerWithProtocol(addr string, port int, protocol string) (*Server, error) func NewServerWithTimeout(addr string, port int, timeout time.Duration) (*Server, error) func NewServerWithProtocolAndTimeout(addr string, port int, protocol string, timeout time.Duration) (*Server, error)

相比于单个API包含所有参数,这种方式可以让使用者分清必填参数和可选参数,根据需求选择合适的API进行调用。然而,随着参数越来越多,API的数量也会随之膨胀,因此这并不是一个优雅的解决方案,只有在对象简单且稳定的情况下才推荐使用。
2. 构造配置对象
比较通用的解决方案就是构造一个Config对象包含所有参数或者可选参数,相信大家在各大开源库中也能见到这种方式。在本例中,相应的结构体和API如下所示:
type Config struct { Addrstring Portint Protocol string Timeouttime.Duration MaxConns int } func NewServer(c *Config) (*Server, error)type Config struct { Protocol string Timeouttime.Duration MaxConns int } func NewServer(addr string, port int, c *Config) (*Server, error)

前一种方式需要文档说明必填参数和可选参数,但好处是API显得很清爽,库中的其它构造对象API都能以这种方式进行编写,达到风格的统一。后一种方式通过API就能分清必填参数和可选参数,但每个对象的必填参数不尽相同,因此库中的构造对象API不能达到风格的统一。
这两种方式都能较好地应对参数的变化,增加参数并不会让使用之前API版本的代码失效,且可通过传零值的方式来使用对应可选参数的默认值。在开源库中大部分采用前一种方式,对Config对象做统一的文档描述,弥补了无法直接区分必填参数和可选参数的缺点,例如go-redis/redis中的配置对象(取名Options,原理相同):
// Options keeps the settings to setup redis connection. type Options struct { // The network type, either tcp or unix. // Default is tcp. Network string // host:port address. Addr string// Dialer creates new network connection and has priority over // Network and Addr options. Dialer func(ctx context.Context, network, addr string) (net.Conn, error)// Hook that is called when new connection is established. OnConnect func(ctx context.Context, cn *Conn) error// Use the specified Username to authenticate the current connection // with one of the connections defined in the ACL list when connecting // to a Redis 6.0 instance, or greater, that is using the Redis ACL system. Username string // Optional password. Must match the password specified in the // requirepass server configuration option (if connecting to a Redis 5.0 instance, or lower), // or the User Password when connecting to a Redis 6.0 instance, or greater, // that is using the Redis ACL system. Password string// Database to be selected after connecting to the server. DB int// Maximum number of retries before giving up. // Default is 3 retries; -1 (not 0) disables retries. MaxRetries int // Minimum backoff between each retry. // Default is 8 milliseconds; -1 disables backoff. MinRetryBackoff time.Duration // Maximum backoff between each retry. // Default is 512 milliseconds; -1 disables backoff. MaxRetryBackoff time.Duration// Dial timeout for establishing new connections. // Default is 5 seconds. DialTimeout time.Duration // Timeout for socket reads. If reached, commands will fail // with a timeout instead of blocking. Use value -1 for no timeout and 0 for default. // Default is 3 seconds. ReadTimeout time.Duration // Timeout for socket writes. If reached, commands will fail // with a timeout instead of blocking. // Default is ReadTimeout. WriteTimeout time.Duration// Type of connection pool. // true for FIFO pool, false for LIFO pool. // Note that fifo has higher overhead compared to lifo. PoolFIFO bool // Maximum number of socket connections. // Default is 10 connections per every available CPU as reported by runtime.GOMAXPROCS. PoolSize int // Minimum number of idle connections which is useful when establishing // new connection is slow. MinIdleConns int // Connection age at which client retires (closes) the connection. // Default is to not close aged connections. MaxConnAge time.Duration // Amount of time client waits for connection if all connections // are busy before returning an error. // Default is ReadTimeout + 1 second. PoolTimeout time.Duration // Amount of time after which client closes idle connections. // Should be less than server's timeout. // Default is 5 minutes. -1 disables idle timeout check. IdleTimeout time.Duration // Frequency of idle checks made by idle connections reaper. // Default is 1 minute. -1 disables idle connections reaper, // but idle connections are still discarded by the client // if IdleTimeout is set. IdleCheckFrequency time.Duration// Enables read only queries on slave nodes. readOnly bool// TLS Config to use. When set TLS will be negotiated. TLSConfig *tls.Config// Limiter interface used to implemented circuit breaker or rate limiter. Limiter Limiter }func NewClient(opt *Options) *Client

但这种方式并不是完美的,一是参数传零值表明使用默认值的方式消除了零值原本的含义,例如timeout设为0表示使用默认值60s,但0本身可表示永不超时;二是在只有可选参数没有必填参数的情况下,使用者只想使用默认值,但究竟是传入nil还是&Config{}也会让他们有点摸不着头脑,更重要的是,使用者可能并不想传入任何参数,但迫于API的形式必须传入;三是传入Config指针无法确定API是否会对Config对象做修改,因此无法复用Config对象。
总的来说,这种方式搭配详细的文档可以起到较好的效果,API变得清爽,扩展性较好,但还是向使用者完整地暴露了复杂度。如果完整地掌握各个参数的行为与意义是很有必要的,那么这种方式会非常适合。
3. Builder模式
Builder是一种经典设计模式,用来组装具有复杂结构的实例。在java中,Server对象使用Builder模式进行构建基本如下所示:
Server server = new Server.Builder("127.0.0.1", 8080) .MaxConnections(10000) .Build();

必填参数在Builder方法中传入,可选参数通过链式调用的方式添加,最后通过Build方法生成实例。java中的具体实现方法可参考https://www.jianshu.com/p/e2a...,这里不再赘述。
现在来讲讲怎么用golang实现Builder模式,先上代码:
type ServerBuilder struct { s*Server err error }// 首先使用必填参数进行构造 func (sb *ServerBuilder) Create(addr string, port int) *ServerBuilder { // 对参数进行验证,若出现错误则设置sb.err = err,这里假设没有错误,后续同理 sb.s = &Server{} sb.s.Addr = addr sb.s.Port = port return sb }func (sb *ServerBuilder) Protocol(protocol string) *ServerBuilder { if sb.err == nil { sb.s.protocol = protocol } return sb }func (sb *ServerBuilder) Timeout(timeout time.Duration) *ServerBuilder { if sb.err == nil { sb.s.Timeout = timeout } return sb }func (sb *ServerBuilder) MaxConns(maxConns int) *ServerBuilder { if sb.err == nil { sb.s.MaxConns = maxConns } return sb }func (sb *ServerBuilder) Build() (*Server, error) { return sb.s, sb.err }

因为golang使用返回错误值的方式来处理错误,所以要实现链式函数调用并兼顾错误处理就需要使用ServerBuilder来包装Server与error,每次设置可选参数前都检查一下ServerBuilder中的error。当然直接处理Server也是没问题的,但就需要在Server中加上error成员,这样做污染了Server,不太推荐。使用这种解决方案生成Server对象如下所示:
sb := ServerBuilder() srv, err := sb.Create("127.0.0.1", 8080) .Protocol("tcp") .MaxConns(10000) .Build()

这种方式通过Create函数即可确定必填参数,通过ServerBuilder的其它方法就可以确定可选参数,且扩展性好,添加一个可选参数后在ServerBuilder中添加一个方法并在调用该API处再加一行链式调用即可。
因为读的代码还是太少(继续滚去读了),没有找到完整使用Builder模式的知名开源库((lll¬ω¬)),比较明显的可以参考https://github.com/uber-go/za...,但zap混用了另外一种模式,也是最后要介绍的模式,所以看完本文的完整介绍后再看zap的实现比较妥当。
(这里写个小看法,想直接看正文的可以跳过。逛知乎看了很多golang和java卫道者的骂战,谁比谁牛逼暂无定论,但界限是一定要划清的。具体体现在gopher对依赖注入,设计模式等在java大行其道的方式非常抵制,认为不符合golang大道至简的设计思想。而javaer则嘲讽golang过于《大道至简》了。所以斗胆猜测誓要划清界限的gopher不想使用Builder模式,至少不能明目张胆地使用)。
4. Functional Options
【理解go-micro(一)| 前置知识准备】铺垫了这么久,终于来到了重头戏Functional Options。回顾一下之前的解决方案,除开政治不正确的Builder模式,总结一下对API的需求为:
①API应尽量具有自描述性,只需看函数签名就可以方便区分必填参数和选填参数
②支持默认值
③在只想使用默认参数的情况下,不需要传入nil或者空对象这种令人迷惑的参数

    推荐阅读