理解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会带来以下问题:
- 使用者只从API签名无法判断哪些参数是必须的,哪些参数是可选的
例如NewServer被设计为必须传入addr与port,默认设置为监听tcp,超时时间60s,最大连接数10000。在没有文档的情况下,现在一个使用者想快速使用这个API搭建demo,凭借经验他会传入addr和port,但对于其它参数,他是无能为力的。比如timeout传0是意味着采用默认值还是永不超时,还是说必须要传入有效的值才行,根本无法从API签名得知。此时使用者只能通过查看具体实现才能掌握API正确的用法。 - 增加或删除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或者空对象这种令人迷惑的参数
推荐阅读
- 一个人的旅行,三亚
- 一个小故事,我的思考。
- 《真与假的困惑》???|《真与假的困惑》??? ——致良知是一种伟大的力量
- 开学第一天(下)
- 一个人的碎碎念
- 2018年11月19日|2018年11月19日 星期一 亲子日记第144篇
- 遇到一哭二闹三打滚的孩子,怎么办┃山伯教育
- 第326天
- Y房东的后半生14
- 奔向你的城市