乐趣区

关于golang:理解gomicro一-前置知识准备

引言

学习 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 或者空对象这种令人蛊惑的参数

退出移动版