引言

学习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 {    Addr     string    Port     int    Protocol string    Timeout  time.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 {    Addr     string    Port     int    Protocol string    Timeout  time.Duration    MaxConns int}func NewServer(c *Config) (*Server, error)type Config struct {    Protocol string    Timeout  time.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

铺垫了这么久,终于来到了重头戏Functional Options。回顾一下之前的解决方案,除开政治不正确的Builder模式,总结一下对API的需要为:
①API应尽量具备自描述性,只需看函数签名就能够不便辨别必填参数和选填参数
②反对默认值
③在只想应用默认参数的状况下,不须要传入nil或者空对象这种令人蛊惑的参数