引言
学习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会带来以下问题:
- 使用者只从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. 构造配置对象
比较通用的解决方案就是构造一个Options对象包含所有参数或者可选参数,相信大家在各大开源库中也能见到这种方式。在本例中,相应的结构体和API如下所示:
type Options struct {
Addr string
Port int
Protocol string
Timeout time.Duration
MaxConns int
}
func NewServer(o *Options) (*Server, error)
type Options struct {
Protocol string
Timeout time.Duration
MaxConns int
}
func NewServer(addr string, port int, c *Options) (*Server, error)
前一种方式需要文档说明必填参数和可选参数,但好处是API显得很清爽,库中的其它构造对象API都能以这种方式进行编写,达到风格的统一。后一种方式通过API就能分清必填参数和可选参数,但每个对象的必填参数不尽相同,因此库中的构造对象API不能达到风格的统一。
这两种方式都能较好地应对参数的变化,增加参数并不会让使用之前API版本的代码失效,且可通过传零值的方式来使用对应可选参数的默认值。在开源库中大部分采用前一种方式,对Config对象做统一的文档描述,弥补了无法直接区分必填参数和可选参数的缺点,例如go-redis/redis中的配置对象:
// 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还是&Options{}也会让他们有点摸不着头脑,更重要的是,使用者可能并不想传入任何参数,但迫于API的形式必须传入;三是传入Options指针无法确定API是否会对Config对象做修改,因此无法复用Options对象。
总的来说,这种方式搭配详细的文档可以起到较好的效果,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模式的知名开源库,比较明显的可以参考https://github.com/uber-go/za...,但zap混用了另外一种模式,也是最后要介绍的模式,所以看完本文的完整介绍后再看zap的实现比较妥当。
4. Functional Options
铺垫了这么久,终于来到了重头戏Functional Options。回顾一下之前的解决方案,除开政治不正确的Builder模式,总结一下对API的需求为:
①API应尽量具有自描述性,只需看函数签名就可以方便区分必填参数和选填参数。
②支持默认值。
③扩展性好,添加参数后使用旧版本API的代码依然可用。
④在只想使用默认参数的情况下,不需要传入nil或者空对象这种令人迷惑的参数,需要什么参数就传入什么,不过分暴露复杂度。
其实使用构造配置对象,即将所有参数都放入Options并辅以统一文档这一解决方案就可以满足前两个需求,关键在于解决第四个需求,它体现的是一种可变参数的特性。相信聪明的读者已经想到API签名该是什么样了,如下所示:
func NewServer(...Option) (*Server, error)
看着很美好,但关键问题在于参数类型各式各样,如果Option的类型为Interface{},那么API将丧失自描述性,只看API连可以传入什么参数都无法确定了,所以我们应该抽象一下参数的公共特性。这些参数虽然类型各异,但它们本质上都是对Options的修改。有两种方式进行抽象:
type Option func(*Options)
type Option interface {
apply(*Options)
}
第二种方式属于Uber的做法,具体可参考https://github.com/xxjwxc/ube...。这里主要介绍第一种方式,也是go-micro采用的方式。将Option抽象为对Options进行修改的函数类型后,可选参数便可通过闭包的方式被Option携带,并交由NewServer逐个处理,此时代码如下所示:
type Server struct {
opts *Options
}
type Options struct {
Addr string
Port int
Protocol string
Timeout time.Duration
MaxConns int
}
type Option func(*Options)
// 必填
func WithAddr(addr string) Option {
return func(o *Options) {
o.Addr = addr
}
}
// 必填
func WithPort(port int) Option {
return func(o *Options) {
o.Port = port
}
}
func WithProtocol(protocol string) Option {
return func(o *Options) {
o.Protocol = protocol
}
}
func WithTimeout(timeout time.Duration) Option {
return func(o *Options) {
o.Timeout = timeout
}
}
func WithMaxConns(maxConns int) Option {
return func(o *Options) {
o.MaxConns = maxConns
}
}
func NewDefaultOptions() Options {
return Options{
Protocol: "tcp",
Timeout: 60 * time.Second,
MaxConns: 10000,
}
}
func NewServer(opts ...Option) (*Server, error) {
options := NewDefaultOptions()
for _, opt := range opts {
opt(&options)
}
// 检查参数合法性,这里不详细展开
if err := examine(&options); err != nil {
return nil, err
}
return &Server{
opts: &options
}, nil
}
可以看到,所有参数都通过闭包的方式由Option带给NewServer,NewServer在调用NewDefaultOptions生成默认Options后,再对该Options依次执行传入的Option函数修改具体的参数项,最后使用examine函数对这些参数做统一的参数校验。例如现在想构建一个部署在本机,监听8888端口的UDP服务器,具体的调用方式如下:
srv, err := NewServer(
WithAddr("127.0.0.1"),
WithPort(8888),
WithProtocol("udp"),
)
通过这种方式,除了拥有构造配置对象的优点外,使用者无需为传入nil还是空Options对象而疑惑,需要什么参数就传入什么参数对应的Option函数,最大程度地降低了API暴露给使用者的复杂度。如果需要掌握所有参数的使用,查看包下的所有Option函数即可,完美地适配了使用者对API的需求,也是这种方式被成为Functional Options的原因。
go-micro对所有关键对象都使用了这种构造方式,每个包下都有options.go这一文件,所以要使用某个关键对象时,直接翻看对应包下的options.go文件即可搞清楚该对象的所有依赖项。例如github.com/asim/go-micro/v3/registry包下的options.go文件(只包含关键代码):
type Options struct {
Addrs []string
Timeout time.Duration
Secure bool
TLSConfig *tls.Config
// Other options for implementations of the interface
// can be stored in a context
Context context.Context
}
// Addrs is the registry addresses to use
func Addrs(addrs ...string) Option {
return func(o *Options) {
o.Addrs = addrs
}
}
func Timeout(t time.Duration) Option {
return func(o *Options) {
o.Timeout = t
}
}
// Secure communication with the registry
func Secure(b bool) Option {
return func(o *Options) {
o.Secure = b
}
}
// Specify TLS Config
func TLSConfig(t *tls.Config) Option {
return func(o *Options) {
o.TLSConfig = t
}
}
func RegisterTTL(t time.Duration) RegisterOption {
return func(o *RegisterOptions) {
o.TTL = t
}
}
Interface Oriented
一. 概念简介
接触过面向对象语言的读者对接口一定不会陌生,golang接口的独特之处在于它是隐式实现,即不需要像java一样直接声明实现了某接口。具体实现可参考https://draveness.me/golang/d...,这里主要讲下接口的思想以及编程时如何以接口为切入点进行思考。
接口出现的前置条件在于分层与抽象。TCP/IP网络结构是分层,应用层的HTTP协议利用传输层的TCP协议提供的面向连接、可靠传输等特性而无需关注TCP的具体实现细节;进程与操作系统是分层,每个进程利用操作系统提供的独占处理器与内存空间的抽象而无需关注具体的调度和分配细节;MVC架构也是分层,层次之间各司其职,利用API获取层次提供的服务。
分层和抽象的实例处处可见,那为什么要分层和抽象呢?关键在于人能同时处理的关键点是有限的,必须采用分而治之的思想各个击破。相信大家写Web程序大多都是使用框架,由框架来屏蔽解析HTTP协议、管理连接的建立和中断这些细节,只需专注于业务需求,即“站在巨人的肩膀上”。在框架的帮助下,使用者只需要在具体的处理函数即Controller下利用框架注入的Context,获取HTTP请求的参数和内容等进行业务处理。这里的Context其实就是框架提供的接口,框架保证正确解析请求并存于Context,并提供一系列API来获取所需信息,而无需使用者关注其中的各种细节。
前面所举的例子都是各路大神的经典杰作,是由他们提供给我们使用的,那么我们在日常的编程中如何以接口为切入点进行思考和使用呢?从本质上来说,接口是位于上下游间的一个中间层,因此我们可以把上游或下游做为基准点,即使用自顶向下或自底向上的方法来思考和实现具体的接口。
先说说自顶向下,这也是最常用的方法,因为大部分场景下,我们都会以实际需求出发,站在一个高层次上思考问题。举个例子,现在我们想实现一个键值对服务器,那么站在使用者的角度上,最直接的需求就是根据键查询、更新或删除值,此时接口就可以这样设计(使用golang):
type KV interface {
Get(key string) (value []byte, err error)
Put(key string, value []byte) (err error)
Delete(key string) (err error)
}
后续有需求例如压缩,设立过期时间等,可以往这个接口继续添加方法。处于上游的使用者只需利用这个接口完成上层代码的编写,而无需关注具体的实现细节。等到需求和建模工作逐渐清晰,接口也趋于稳定,此时就可以着手实现接口了。在实现的过程中,也会很自然地使用这种自顶向下的方法,将关注点放在上游使用者的需求上,层层递进下去,直到系统调用这一编程者接触到的最低层次。
所以使用自顶向下方法的关键点在于清楚地认识到当前所处的层次以及上游对下游提出的需求,进行合理的建模,层层递进下去从而达到屏蔽众多细节的目的。但是,这种方法面对模型易变,需求变更频繁的上下游就有点力不从心了,此时应该选择自底向上的方法。
如果说自顶向下的核心是要什么,那么自底向上的核心就是有什么。在编程初期,并不着急抽象出接口,而是采用硬编码的方式让上层直接依赖下层,随着需求的不断涌入,下层所提供的服务也越来越完善。到一个较为合适的时间点,例如经过了一个大版本,下层变动较少并趋于稳定,此时就可以考虑对下层进行抽象,将下层提供的核心方法抽取出来做为接口提供给上层,使得下层的具体实现可以替换。
设计接口和抽象接口的过程因人、因项目而异,所以也很难有一个类似公式或口诀的确定方法。但思考的切入点是可以确定的,即搞清楚当前所处的层次,上下游是什么,上游的需求以及下游所能提供的服务,不断思考从而不断加深对模型的理解,逐渐增强接口设计能力。
二. go-micro的层次与接口设计
go-micro采用自顶向下的方式对微服务的各个模块进行抽象和建模,整体层次与依赖如下图所示(忽略部分依赖项):
每个模块的具体细节在后续文章中会一一展现,我们先来考虑一下这幅图是如何得到的。上一节说到,go-micro使用Functional Options的方式管理和注入每个模块的依赖,只要查看这些配置函数,就能知道当前模块的所有依赖项。所以我们先看一下go-micro的源码结构,如下图所示:
源码中,每一个package就是一个微服务中的重要模块,从最外层的micro package开始,micro.go中定义了这一模块的重要接口Service:
type Service interface {
// The service name
Name() string
// Init initialises options
Init(...Option)
// Options returns the current options
Options() Options
// Client is used to call services
Client() client.Client
// Server is for handling requests and events
Server() server.Server
// Run the service
Run() error
// The service implementation
String() string
}
而options.go中则记录了Service对象所需要的所有依赖以及依赖注入函数(截取部分):
type Options struct {
Auth auth.Auth
Broker broker.Broker
Cmd cmd.Cmd
Config config.Config
Client client.Client
Server server.Server
Store store.Store
Registry registry.Registry
Runtime runtime.Runtime
Transport transport.Transport
Profile profile.Profile
// Before and After funcs
BeforeStart []func() error
BeforeStop []func() error
AfterStart []func() error
AfterStop []func() error
// Other options for implementations of the interface
// can be stored in a context
Context context.Context
Signal bool
}
func Broker(b broker.Broker) Option {
return func(o *Options) {
o.Broker = b
// Update Client and Server
o.Client.Init(client.Broker(b))
o.Server.Init(server.Broker(b))
}
}
func Client(c client.Client) Option {
return func(o *Options) {
o.Client = c
}
}
func Server(s server.Server) Option {
return func(o *Options) {
o.Server = s
}
}
func Registry(r registry.Registry) Option {
return func(o *Options) {
o.Registry = r
// Update Client and Server
o.Client.Init(client.Registry(r))
o.Server.Init(server.Registry(r))
// Update Broker
o.Broker.Init(broker.Registry(r))
}
}
func Selector(s selector.Selector) Option {
return func(o *Options) {
o.Client.Init(client.Selector(s))
}
}
func Transport(t transport.Transport) Option {
return func(o *Options) {
o.Transport = t
// Update Client and Server
o.Client.Init(client.Transport(t))
o.Server.Init(server.Transport(t))
}
}
结合Functional Options,可以很清晰的看到Service依赖的模块以及注入相应依赖时发生的行为。例如Registry函数,将Registry实例注入Service、Client、Server、Broker中;Selector函数,将Selector实例注入Client中。那么go-micro的整体结构和依赖图的绘制也就顺理成章了。这里提一个小插曲,Options中的Context放下不表,在之后介绍开发自定义模块时会提到。
相信读者已经发现了使用和阅读go-micro源码的方式:从外向内,每个package对应一个微服务模块,查看该模块下的接口和依赖项,也就把握了这个模块所能提供的服务以及在整体层次中所处的位置。熟悉整体结构后,若忘记了某模块的使用细节和具体的依赖项,便可以找到该模块对应的源码进行查询,做到真正的指哪打哪。
总结
本章主要介绍了go-micro的整体设计风格:Functional Options和Interface Oriented,掌握这两点后,其实就可以比较轻松地阅读go-micro源码了。在后续章节中,将结合微服务的具体领域知识,对go-micro的重要模块例如Client、Server、Registry等做解析,敬请期待。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。