前言
背景
最近决心开始学习go语言,但是苦于没有实际的应用场景,学习始终停留在hello world层面,看过的教程和资料印象也不深刻。于是决定从go自带的rpc实现开始切入,了解一下go语言在实际场景下是如何使用的,包括异常处理、代理和过滤、go routine的用法等等,同时也简单了解了一下其他rpc的go语言实现,比如thrift和grpc等等。一阵走马观花,稍微加深了印象,也开始慢慢体会到go语言和java语言的种种差异和共性。接下来,为了进一步巩固学习效果,也算是为了对自己目前为止的职业生涯做一次复习和汇报,决定使用go语言从零开始构建一个比较完整的RPC(或者说是微服务)框架。
微服务框架和RPC框架
本文中提到RPC框架,指的是提供基础的RPC调用支持的框架;而本文中提到的微服务框架,指的是包含一些服务治理相关的功能(比如服务注册发现、负载均衡、链路追踪等)的RPC框架。
调研
在动手开始做之前,需要先了解学习一下其他现有的产品,可以从中学习一下优秀的经验和方法,这里列举一下初步了解到的几个框架:
- grpc google推出的微服务框架,支持10种语言,支持基于http2的双向的流式通讯
- go-micro 一个开源的微服务框架,比较独特的是支持Async Messaging,像是mq一样的subpub功能
- thrift-go thrift是facebook捐献给apache的rpc框架(不包含服务治理相关的功能),根据官方文档,thrift支持20种语言的RPC调用
- rpcx rpcx是一个国人开发并开源的微服务框架,宣传的特性是“快、易用却功能强大”,官网上的介绍提到性能是grpc的两倍。这里附上作者(应该是)的博客
以上就是目前了解过的几个已有的框架,比较惭愧的是了解得都不够深入,后续还要持续学习。
Pluggable Interfaces
值得一提的是除了thrift,其他三个称得上微服务框架的产品,其特性都包含Pluggable Interfaces,也就是可以通过插件替换部分功能。通过插件实现可替换的功能,实际上在一个微服务框架中基本是最低要求了,否则后续的功能扩展将会变得十分困难,相信我,这里是饱含血泪的经验之谈。
需求分析
在开始着手设计甚至是编写代码以前,我们首先分析一下我们的需求(来自学习软件工程中的成果)。同时对于一部分可能不太熟悉RPC相关细节的同学来说,对我们后面要做的事情心中也能够有一个大致的概念。这里就直接列举几个功能性需求:
-
支持RPC调用,包括同步调用和异步调用
-
支持服务治理的相关功能,包括:
- 服务注册与发现
- 服务负载均衡
- 限流和熔断
- 身份认证
- 监控和链路追踪
- 健康检查,包括端到端的心跳以及注册中心对服务实例的检查
-
支持插件,对于有多种实现的功能(比如负载均衡),需要以插件的形式提供实现,同时需要支持自定义插件 至于非功能性需求比如性能要好,要够稳定这类的暂时不重点关注。
系统设计
分层
有了大致的需求,接下来就可以开始着手设计了。首先我们将框架划分为若干层,层与层之间约定通过接口交互。这里就不要问为什么需要分层了,非要问就是经验。分层作为一种经典到不能在经典的设计模式,几乎在软件开发过程中无处不在,在RPC框架当中也十分适用,下面画出大致的层次图:
- service 是面向用户的接口,比如客户端和服务端实例的初始化和运行等等
- client和server表示客户端和服务端的实例,它们负责发出请求和返回响应
- selector 表示负载均衡,或者叫做loadbanlancer,它负责决定具体要向哪个server发出请求
- registery 表示注册中心,server在初始化完毕甚至是运行时都要向注册中心注册自身的相关信息,这样client才能从注册中心查找到需要的server
- codec 表示编解码,也就是将对象和二进制数据互相转换
- protocol 表示通信协议,也就是二进制数据是如何组成的,RPC框架中很多功能都需要协议层的支持
- transport 表示通讯,它负责具体的网络通讯,将按照protocol组装好的二进制数据通过网络发送出去,并根据protocol指定的方式从网络读取数据
上面提到的各个层,除了service,实际上可以提供多种实现,所以应该都以plugin的方式实现。
这样一来按照我们划分的层次,一个客户端从发出请求到收到响应的流程大概就是这样:
服务端的逻辑比较类似,这里就不画图了。
过滤器链
通过上面的层次划分可以看到,一个请求或者响应实际上会依次穿过各个层然后通过网络发送或者到达用户逻辑,所以我们采用类似过滤器链一样的方式处理请求和响应,以此来达到对扩展开放,对修改关闭的效果。这样一来对于一些附加功能比如熔断降级和限流、身份认证等功能都可以在过滤器中实现。
消息协议
接下来设计具体的消息协议,所谓消息协议大概就是两台计算机为了互相通信而做的约定。举个例子,TCP协议约定了一个TCP数据包的具体格式,比如前2个byte表示源端口,第3和第4个byte表示目标端口,接下来是序号和确认序号等等。而在我们的RPC框架中,也需要定义自己的协议。一般来说,网络协议都分为head和body部分,head是一些元数据,是协议自身需要的数据,body则是上一层传递来的数据,只需要原封不动的接着传递下去就是了。
接下来我们就试着定义自己的协议:
-------------------------------------------------------------------------------------------------
|2byte|1byte |4byte |4byte | header length |(total length - header length - 4byte)|
-------------------------------------------------------------------------------------------------
|magic|version|total length|header length| header | body |
-------------------------------------------------------------------------------------------------
根据上面的协议,一个消息体由以下几个部分严格按照顺序组成:
- 两个byte的magic number开头,这样一来我们就可以快速的识别出非法的请求
- 一个byte表示协议的版本,目前可以一律设置为0
- 4个byte表示消息体剩余部分的总长度(total length)
- 4个byte表示消息头的长度(header length)
- 消息头(header),其长度根据前面解析出的长度(header length)决定
- 消息体(body),其长度为前面解析出的总长度减去消息头所占的长度(total length - 4 - header length)
协议中消息头的数据主要是RPC调用过程中的元数据,元数据跟方法参数和响应无关,主要记录额外的信息以及实现附属功能比如链路追踪、身份认证等等;消息体的数据则是由实际的请求参数或者响应编码而来。 在实际的处理中,消息头在发送端通常是一个结构体,在发送时会被编码成二进制添加在消息头的前面,在接收端接收时又解码成一个结构体,交给程序进行处理。这里试着列举消息头包含的各个信息:
type Header struct {
Seq uint64 //序号, 用来唯一标识请求或响应
MessageType byte //消息类型,用来标识一个消息是请求还是响应
CompressType byte //压缩类型,用来标识一个消息的压缩方式
SerializeType byte //序列化类型,用来标识消息体采用的编码方式
StatusCode byte //状态类型,用来标识一个请求是正常还是异常
ServiceName string //服务名
MethodName string //方法名
Error string //方法调用发生的异常
MetaData map[string]string //其他元数据
}
功能设计
RPC调用的第一步,就是在服务端定义要对外暴露的方法,在grpc或者是thrift中,这一步我们需要编写语言无关的idl文件,然后通过idl文件生成对应语言的代码。而在我们的框架里,出于简单起见,我们不采用idl的方式,直接在代码里定义接口和方法。这里先规定对外的方法必须遵守以下几个条件:
- 对外暴露的方法,其所属的类型和其自身必须是对外可见(Exported)的,也就是首字母必须是大写的
- 方法的参数必须为三个,而且第一个必须是context.Context类型
- 第三个方法参数必须是指针类型
- 方法返回值必须是error类型
- 客户端通过"Type.Method"的形式来引用服务方法,其中Type是方法实现类的全类名,Method就是方法名称
为什么要有这几个规定呢,具体的原因是这样的:因为java中的RPC框架场用到的动态代理在go语言中并不支持,所以我们需要显式地定义方法的统一格式,这样在RPC框架中才能统一地处理不同的方法。所以我们规定了方法的格式:
- 方法的第一个参数固定为Context,用于传递上下文信息
- 第二个参数是真正的方法参数
- 第三个参数表示方法的返回值,调用完成后它的值就会被改变为服务端执行的结
- 方法的返回值固定为error类型,表示方法调用过程中发生的错。
这里我们需要注意的是,服务提供者在对外暴露时并不需要以接口的形式暴露,只要服务提供者有符合规则的方法即可;而客户端在调用方法时指定的是服务提供者的具体类型,不能指定接口的名称,即使服务提供者实现了这个接口。
contet.Context
context是go语言提供的关于请求上下文的抽象,它携带了请求deadline、cancel信号的信息,还可以传递一些上下文信息,非常适合作为RPC请求的上下文,我们可以在context中设置超时时间,还可以将一些参数无关的元数据通过context传递到服务端。
实际上,方法的固定格式以及用Call和Go来表示同步和异步调用都是go自带的rpc里的规则,只是在参数里增加了context.Context。不得不说go自带的rpc设计确实十分优秀,值得好好学习理解。
接口定义
client和server
首先是面向使用者的RPC框架中的客户端和服务端接口:
type RPCServer interface {
//注册服务实例,rcvr是receiver的意思,它是我们对外暴露的方法的实现者,metaData是注册服务时携带的额外的元数据,它描述了rcvr的其他信息
Register(rcvr interface{}, metaData map[string]string) error
//开始对外提供服务
Serve(network string, addr string) error
}
type RPCClient interface {
//Go表示异步调用
Go(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call
//Call表示异步调用
Call(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}) error
Close() error
}
type Call struct {
ServiceMethod string // 服务名.方法名
Args interface{} // 参数
Reply interface{} // 返回值(指针类型)
Error error // 错误信息
Done chan *Call // 在调用结束时激活
}
selector和registery
这次先实现RPC调用部分,这两层暂时忽略,后续再实现。
codec
接下来我们需要选择一个序列化协议,这里就选之前使用过的messagepack。之前设计的通信协议分为两个部分:head和body,这两个部分都需要进行序列化和反序列化。head部分是元数据,可以直接采用messagepack序列化,而body部分是方法的参数或者响应,其序列化由head中的SerializeType决定,这样的好处就是为了后续扩展方便,目前也使用messagepack序列化,后续也可以采用其他的方式序列化。
序列化的逻辑也定义为接口:
type Codec interface {
Encode(value interface{}) ([]byte, error)
Decode(data []byte, value interface{}) error
}
protocol
确定好了序列化协议之后,我们就可以定义消息协议相关的接口了。协议的设计参考上一篇文章:从零开始实现一个RPC框架(零)
接下来就是协议的接口定义:
//Messagge表示一个消息体
type Message struct {
*Header //head部分, Header的定义参考上一篇文章
Data []byte //body部分
}
//Protocol定义了如何构造和序列化一个完整的消息体
type Protocol interface {
NewMessage() *Message
DecodeMessage(r io.Reader) (*Message, error)
EncodeMessage(message *Message) []byte
}
根据之前的设计,所以交互都通过接口进行,这样方便扩展和替换。
transport
协议的接口定义好了之后,接下来就是网络传输层的定义:
//传输层的定义,用于读取数据
type Transport interface {
Dial(network, addr string) error
//这里直接内嵌了ReadWriteCloser接口,包含Read、Write和Close方法
io.ReadWriteCloser
RemoteAddr() net.Addr
LocalAddr() net.Addr
}
//服务端监听器定义,用于监听端口和建立连接
type Listener interface {
Listen(network, addr string) error
Accept() (Transport, error)
//这里直接内嵌了Closer接口,包含Close方法
io.Closer
}
具体实现
各个层次的接口定义好了之后,就可以开始搭建基础的框架了,这里不附上具体的代码了,具体代码可以参考github链接 ,这里大致描述一下各个部分的实现思路。
Client
客户端的功能比较简单,就是将参数序列化之后,组装成一个完整的消息体发送出去。请求发送出去的同时,将未完成的请求都缓存起来,每收到一个响应就和未完成的请求进行匹配。
发送请求的核心在Go和send方法,Go的功能是组装参数,send方法是将参数序列化并通过传输层的接口发送出去,同时将请求缓存到pendingCalls中。而Call方法则是直接调用Go方法并阻塞等待知道返回或者超时。 接收响应的核心在input方法,input方法在client初始化完成时通过go input() 执行。input方法包含一个无限循环,在无限循环中读取传输层的数据并将其反序列化,并将反序列化得到的响应与缓存的请求进行匹配。
注:send和input方法的命名也是从go自带的rpc里学来的。
Server
服务端在接受注册时,会过滤服务提供者的各个方法,将合法的方法缓存起来。
服务端的核心逻辑是serveTransport方法,它接收一个Transport对象,然后在一个无限循环中从Transport读取数据并反序列化成请求,根据请求指定的方法查找自身缓存的方法,找到对应 的方法后通过反射执行对应的实现并返。执行完成后再根据返回结果或者是执行发生的异常组装成一个完整的消息,通过Transport发送出去。
服务端在反射执行方法时,需要将实现者作为执行的第一个参数,所以参数比方法定义中的参数多一个。
codec和protocol
这两个部分就比较简单了,codec基本上就是使用messagepack实现了对应的接口;protocol的实现就是根据我们定义的协议进行解析。
线程模型
在执行过程中,除了客户端的用户线程和服务端用来执行方法的服务线程,还分别增加了客户端轮询线程和服务端监听线程,大致的示意图如下:
这里的线程模型比较简单,服务端针对每个建立的连接都会创建一个线程(goroutine),虽说goroutine很轻量,但是也不是完全没有消耗的,后续可以再进一步进行优化,比如把读取数据反序列化和执行方法拆分到不同的线程执行,或者把goroutine池化等等。
基础支撑部分
升级版的Client和Server
首先让我们来重新定义Client和Server:SGClient和SGServer。SGClient封装了上一节定义的RPCClient的操作,提供服务治理的相关特性;SGServer则由上一节定义的RPCServer升级而来,支持服务治理的相关特性。这里的SG(service governance)表示服务治理。 这里直接贴上相关的定义:
type SGClient interface {
Go(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}, done chan *Call) (*Call, error)
Call(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error
}
type sgClient struct {
shutdown bool
option SGOption
clients sync.Map //map[string]RPCClient
serversMu sync.RWMutex
servers []registry.Provider
}
type RPCServer interface {
Register(rcvr interface{}, metaData map[string]string) error
Serve(network string, addr string) error
Services() []ServiceInfo
Close() error
}
type SGServer struct { //原来的RPCServer
codec codec.Codec
serviceMap sync.Map
tr transport.ServerTransport
mutex sync.Mutex
shutdown bool
Option Option
}
拦截器
在之前的文章提到过,我们需要提供过滤器一样的使用方式,来达到对扩展开放对修改关闭的目标。我们这里采用高阶函数的方式来定义方切面和法拦截器,首先定义几个切面:
//客户端切面
type CallFunc func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error
type GoFunc func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call
//服务端切面
type ServeFunc func(network string, addr string) error
type ServeTransportFunc func(tr transport.Transport)
type HandleRequestFunc func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport)
以上几个是RPC调用在客户端和服务端会经过的几个函数,我们将其定义为切面,然后再定义对应的拦截器:
//客户端拦截器
packege client
type Wrapper interface {
WrapCall(option *SGOption, callFunc CallFunc) CallFunc
WrapGo(option *SGOption, goFunc GoFunc) GoFunc
}
//f服务端拦截器
package server
type Wrapper interface {
WrapServe(s *SGServer, serveFunc ServeFunc) ServeFunc
WrapServeTransport(s *SGServer, transportFunc ServeTransportFunc) ServeTransportFunc
WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc
}
这样一来,用户可以通过实现Wapper接口来对客户端或者服务端的行为进行增强,比如将请求参数和结果记录到日志里,动态的修改参数或者响应等等。我们的框架自身 的相关功能也可以通过Wrapper实现。目前客户端实现了用于封装元数据的MetaDataWrapper和记录请求和响应的LogWrapper;服务端目前在DefaultWrapper实现了用于服务注册、监听退出信号以及请求计数的逻辑。
因为go并不提供抽象类的方式,所以对于某些实现类可能并不需要拦截所有切面(比如只拦截Call不想拦截Go),这种情况直接返回参数里的函数对象就可以了。
服务治理部分
服务注册与发现
在这之前,我们的RPC服务调用都是通过在客户端指定服务端的ip和端口来调用的,这种方式十分简单但也场景十分有限,估计只能在测试或者demo中使用。所以我们需要提供服务注册和发现相关的功能,让客户端的配置不再与实际的IP绑定,而是通过独立的注册中心获取服务端的列表,并且能够在服务端节点变更时获得实时更新。
首先定义相关的接口(代码地址):
//Registry包含两部分功能:服务注册(用于服务端)和服务发现(用于客户端)
type Registry interface {
Register(option RegisterOption, provider ...Provider) //注册
Unregister(option RegisterOption, provider ...Provider) //注销
GetServiceList() []Provider //获取服务列表
Watch() Watcher //监听服务列表的变化
Unwatch(watcher Watcher) //取消监听
}
type RegisterOption struct {
AppKey string //AppKey用于唯一标识某个应用
}
type Watcher interface {
Next() (*Event, error) //获取下一次服务列表的更新
Close()
}
type EventAction byte
const (
Create EventAction = iota
Update
Delete
)
type Event struct { //Event表示一次更新
Action EventAction
AppKey string
Providers []Provider //具体变化的服务提供者(增量而不是全量)
}
type Provider struct { //某个具体的服务提供者
ProviderKey string // Network+"@"+Addr
Network string
Addr string
Meta map[string]string
}
AppKey
我们使用AppKey这样一个概念来标识某个服务,比如com.meituan.demo.rpc.server。服务端在启动时将自身的相关信息(包括AppKey、ip、port、方法列表等)注册到注册中心;客户端在需要调用时只需要根据服务端的AppKey到注册中心查找即可。
目前暂时只实现了直连(peer2peer)和基于内存(InMemory)的服务注册,后续再接入其他独立的组件如etcd或者zookeeper等等。
负载均衡
有了服务注册与发现之后,一个客户端所面对的可能就不只有一个服务端了,客户端在发起调用前需要从多个服务端中选择一个出来进行实际的通信,具体的选择策略有很多,比如随机选择、轮询、基于权重选择、基于服务端负载或者自定义规则等等。
这里先给出接口定义:
//Filter用于自定义规则过滤某个节点
type Filter func(provider registry.Provider, ctx context.Context, ServiceMethod string, arg interface{}) bool
type SelectOption struct {
Filters []Filter
}
type Selector interface {
Next(providers []registry.Provider, ctx context.Context, ServiceMethod string, arg interface{}, opt SelectOption) (registry.Provider, error)
}
目前暂时只实现了随机负载均衡,后续再实现其他策略比如轮询或者一致性哈希等等,用户也可以选择实现自己的负载均衡策略。
容错处理
长连接以及网络重连
为了减少频繁创建和断开网络连接的开销,我们维持了客户端到服务端的长连接,并把创建好的连接(RPCClient对象)用map缓存起来,key就是对应的服务端的标识。客户端在调用前根据负载均衡的结果检索到缓存好的RPCClient然后发起调用。当我们检索不到对应的客户端或者发现缓存的客户端已经失效时,需要重新建立连接(重新创建RPCClient对象)。
func (c *sgClient) selectClient(ctx context.Context, ServiceMethod string, arg interface{}) (provider registry.Provider, client RPCClient, err error) {
//根据负载均衡决定要调用的服务端
provider, err = c.option.Selector.Next(c.providers(), ctx, ServiceMethod, arg, c.option.SelectOption)
if err != nil {
return
}
client, err = c.getClient(provider)
return
}
func (c *sgClient) getClient(provider registry.Provider) (client RPCClient, err error) {
key := provider.ProviderKey
rc, ok := c.clients.Load(key)
if ok {
client := rc.(RPCClient)
if client.IsShutDown() {
//如果已经失效则清除掉
c.clients.Delete(key)
}
}
//再次检索
rc, ok = c.clients.Load(key)
if ok {
//已经有缓存了,返回缓存的RPCClient
client = rc.(RPCClient)
} else {
//没有缓存,新建一个然后更新到缓存后返回
client, err = NewRPCClient(provider.Network, provider.Addr, c.option.Option)
if err != nil {
return
}
c.clients.Store(key, client)
}
return
}
目前的实现当中,每个服务提供者只有一个对应的RPCClient,后续可以考虑类似连接池的实现,即每个服务提供者对应多个RPCClient,每次调用前从连接池中取出一个RPCClient。
集群容错
在分布式系统中,异常是不可避免的,当发生调用失败时,我们可以选择要采取的处理方式,这里列举了常见的几种:
type FailMode byte
const (
FailFast FailMode = iota //快速失败
FailOver //重试其他服务器
FailRetry //重试同一个服务器
FailSafe //忽略失败,直接返回
)
具体实现比较简单,就是根据配置的容错选项和重试次数决定是否重试;其他包括FailBack(延时一段时间后重发)、Fork以及Broadcast等等暂时没有实现。
优雅退出
在收到程序退出信号时,server端会尝试优先处理完当前还未结束的请求,等请求处理完毕之后再退出,当超出了指定的时间(默认12s)仍未处理完毕时,server端会直接退出。
func (s *SGServer) Close() error {
s.mutex.Lock()
defer s.mutex.Unlock()
s.shutdown = true
//等待当前请求处理完或者直到指定的时间
ticker := time.NewTicker(s.Option.ShutDownWait)
defer ticker.Stop()
for {
if s.requestInProcess <= 0 { //requestInProcess表示当前正在处理的请求数,在wrapper里计数
break
}
select {
case <-ticker.C:
break
}
}
return s.tr.Close()
}
zookeeper注册中心
实现我们之前的注册中心的接口即可,这里使用了docker的libkv而不是直接用zk客户端(从rpcx那学的),libkv封装了对于几种存储服务的操作,包括Consul、Etcd、Zookeeper和BoltDB,后续如果要支持其他类型的存储就得自己写客户端了。基于zk的注册中心的定义如下:
type ZookeeperRegistry struct {
AppKey string //一个ZookeeperRegistry实例和一个appkey关联
ServicePath string //数据存储的基本路径位置,比如/service/providers
UpdateInterval time.Duration //定时拉取数据的时间间隔
kv store.Store //封装过的zk客户端
providersMu sync.RWMutex
providers []registry.Provider //本地缓存的列表
watchersMu sync.Mutex
watchers []*Watcher //watcher列表
}
初始化部分逻辑如下:
func NewZookeeperRegistry(AppKey string, ServicePath string, zkAddrs []string,
updateInterval time.Duration, cfg *store.Config) registry.Registry {
zk := new(ZookeeperRegistry)
zk.AppKey = AppKey
zk.ServicePath = ServicePath
zk.UpdateInterval = updateInterval
kv, err := libkv.NewStore(store.ZK, zkAddrs, cfg)
if err != nil {
log.Fatalf("cannot create zk registry: %v", err)
}
zk.kv = kv
basePath := zk.ServicePath
if basePath[0] == '/' { //路径不能以"/"开头
basePath = basePath[1:]
zk.ServicePath = basePath
}
//先创建基本路径
err = zk.kv.Put(basePath, []byte("base path"), &store.WriteOptions{IsDir: true})
if err != nil {
log.Fatalf("cannot create zk path %s: %v", zk.ServicePath, err)
}
//显式拉取第一次数据
zk.doGetServiceList()
go func() {
t := time.NewTicker(updateInterval)
for range t.C {
//定时拉取数据
zk.doGetServiceList()
}
}()
go func() {
//后台watch数据
zk.watch()
}()
return zk
}
我们在初始化注册中心时执行两个后台任务:定时拉取和监听数据,相当于推拉结合的方式。同时监听获得的数据是全量数据,因为实现起来简单一些,后续如果服务列表越来越大时,可能需要加上基于版本号的机制或者只传输增量数据。这里额外指出几个要点:
- 后台定时拉取数据并缓存起来
- 查询时直接返回缓存
- 注册时在zk添加节点,注销时在zk删除节点
- 监听时并不监听每个服务提供者,而是监听其父级目录,有变更时再统一拉取服务提供者列表,这样可以减少watcher的数目,逻辑也更简单一些
- 因为第4点,所以注册和注销时需要更改父级目录的内容(lastUpdate)来触发监听
具体的注册注销逻辑这里不再列举,参考:github
客户端心跳
如果我们使用zk作为注册中心,更简单的做法可能是直接将服务提供者作为临时节点添加到zk上,这样就可以利用临时节点的特性实现动态的服务发现。但是我们使用的libkv库并不支持临时节点的功能,而且除了zk其他存储服务比如etcd等可能也不支持临时节点的特性,所以我们注册到注册中心的都是持久节点。在这种情况下,可能某些由于特殊情况无法访问的服务提供者并没有及时地将自身从注册中心注销掉,所以客户端需要额外的能力来判断一个服务提供者是否可用,而不是完全依赖注册中心。
所以我们需要增加客户端心跳的支持,客户端可以定时向服务端发送心跳请求,服务端收到心跳请求时可以直接返回,只要通知客户端自身仍然可用就行。客户端可以根据设置的阈值,对心跳失败的服务提供者进行降级处理,直到心跳恢复或者服务提供者被注销掉。客户端发送心跳逻辑如下:
func (c *sgClient) heartbeat() {
if c.option.HeartbeatInterval <= 0 {
return
}
//根据指定的时间间隔发送心跳
t := time.NewTicker(c.option.HeartbeatInterval)
for range t.C {
if c.shutdown {
t.Stop()
return
}
//遍历每个RPCClient进行心跳检查
c.clients.Range(func(k, v interface{}) bool {
err := v.(RPCClient).Call(context.Background(), "", "", nil)
c.mu.Lock()
if err != nil {
//心跳失败进行计数
if fail, ok := c.clientsHeartbeatFail[k.(string)]; ok {
fail++
c.clientsHeartbeatFail[k.(string)] = fail
} else {
c.clientsHeartbeatFail[k.(string)] = 1
}
} else {
//心跳成功则进行恢复
c.clientsHeartbeatFail[k.(string)] = 0
c.serversMu.Lock()
for i, p := range c.servers {
if p.ProviderKey == k {
delete(c.servers[i].Meta, protocol.ProviderDegradeKey)
}
}
c.serversMu.Unlock()
}
c.mu.Unlock()
//心跳失败次数超过阈值则进行降级
if c.clientsHeartbeatFail[k.(string)] > c.option.HeartbeatDegradeThreshold {
c.serversMu.Lock()
for i, p := range c.servers {
if p.ProviderKey == k {
c.servers[i].Meta[protocol.ProviderDegradeKey] = true
}
}
c.serversMu.Unlock()
}
return true
})
}
}
鉴权
鉴权的实现比较简单,客户端可以在元数据中携带鉴权相关的信息,而服务端可以通过指定的Wrapper进行鉴权。服务端Wrapper的代码如下:
type AuthFunc func(key string) bool
type ServerAuthInterceptor struct {
authFunc AuthFunc
}
func NewAuthInterceptor(authFunc AuthFunc) Wrapper {
return &ServerAuthInterceptor{authFunc}
}
func (sai *ServerAuthInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
if auth, ok := ctx.Value(protocol.AuthKey).(string); ok {
//鉴权通过则执行业务逻辑
if sai.authFunc(auth) {
requestFunc(ctx, response, response, tr)
return
}
}
//鉴权失败则返回异常
s.writeErrorResponse(response, tr, "auth failed")
}
}
熔断降级
暂时实现了简单的基于时间窗口的熔断器,实现如下:
type CircuitBreaker interface {
AllowRequest() bool
Success()
Fail(err error)
}
type DefaultCircuitBreaker struct {
lastFail time.Time
fails uint64
threshold uint64
window time.Duration
}
func NewDefaultCircuitBreaker(threshold uint64, window time.Duration) *DefaultCircuitBreaker {
return &DefaultCircuitBreaker{
threshold: threshold,
window: window,
}
}
func (cb *DefaultCircuitBreaker) AllowRequest() bool {
if time.Since(cb.lastFail) > cb.window {
cb.reset()
return true
}
failures := atomic.LoadUint64(&cb.fails)
return failures < cb.threshold
}
func (cb *DefaultCircuitBreaker) Success() {
cb.reset()
}
func (cb *DefaultCircuitBreaker) Fail() {
atomic.AddUint64(&cb.fails, 1)
cb.lastFail = time.Now()
}
func (cb *DefaultCircuitBreaker) reset() {
atomic.StoreUint64(&cb.fails, 0)
cb.lastFail = time.Now()
}
支持多种数据源的注册中心
在上一篇文章里我们借助libkv实现了基于zookeeper的服务注册与发现,这次我们更进一步,将我们的ZookeeperRegistry改造成支持多种数据源的Registry。实际上的改造也比较简单,最重要的注册、发现以及通知等都已经完成了,我们只需要将底层的数据源类型改造为可配置的即可。代码如下:
//Registry的定义,就是从上一篇的ZookeeperRegistry改过来的
type KVRegistry struct {
AppKey string //KVRegistry
ServicePath string //数据存储的基本路径位置,比如/service/providers
UpdateInterval time.Duration //定时拉取数据的时间间隔
kv store.Store //store实例是一个封装过的客户端
providersMu sync.RWMutex
providers []registry.Provider //本地缓存的列表
watchersMu sync.Mutex
watchers []*Watcher //watcher列表
}
//初始化逻辑,根据backend参数的不同支持不同的底层数据源
func NewKVRegistry(backend store.Backend,addrs []string,AppKey string,cfg *store.Config,ServicePath string,updateInterval time.Duration) registry.Registry {
//libkv中需要显式初始化数据源
switch backend {
case store.ZK:
zookeeper.Register()
case store.ETCD:
etcd.Register()
case store.CONSUL:
consul.Register()
case store.BOLTDB:
boltdb.Register()
}
r := new(KVRegistry)
r.AppKey = AppKey
r.ServicePath = ServicePath
r.UpdateInterval = updateInterval
//生成实际的数据源
kv, err := libkv.NewStore(backend, addrs, cfg)
if err != nil {
log.Fatalf("cannot create kv registry: %v", err)
}
r.kv = kv
//省略了后面的初始化逻辑,因为和之前没有改动
return r
}
这里实际上是偷懒了,可以看出来这里完全就是对libkv的包装,所以能够支持的数据源也仅限libkv支持的几种,包括:boltdb、etcd、consul、zookeeper。后续如果要支持其他的注册中西比如eureka或者narcos,就得自己写接入代码了。
限流
当前的限流是基于Ticker实现的,同时支持服务端和客户端的限流,具体的逻辑参考了gobyexample.com/rate-limiti…
首先列举限流器接口的定义:
type RateLimiter interface {
//获取许可,会阻塞直到获得许可
Acquire()
//尝试获取许可,如果不成功会立即返回false,而不是一直阻塞
TryAcquire() bool
//获取许可,会阻塞直到获得许可或者超时,超时时会返回一个超时异常,成功时返回nil
AcquireWithTimeout(duration time.Duration) error
}
客户端的实现如下(基于Wrapper):
type RateLimitInterceptor struct {
//内嵌了defaultClientInterceptor,defaultClientInterceptor类实现了Wrapper的所有方法,我们只需要覆盖自己需要实现的方法即可
defaultClientInterceptor
Limit ratelimit.RateLimiter
}
var ErrRateLimited = errors.New("request limited")
func (r *RateLimitInterceptor) WrapCall(option *SGOption, callFunc CallFunc) CallFunc {
return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error {
if r.Limit != nil {
//进行尝试获取,获取失败时直接返回限流异常
if r.Limit.TryAcquire() {
return callFunc(ctx, ServiceMethod, arg, reply)
} else {
return ErrRateLimited
}
} else {//若限流器为nil则不进行限流
return callFunc(ctx, ServiceMethod, arg, reply)
}
}
}
func (r *RateLimitInterceptor) WrapGo(option *SGOption, goFunc GoFunc) GoFunc {
return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call {
if r.Limit != nil {
//进行尝试获取,获取失败时直接返回限流异常
if r.Limit.TryAcquire() {
return goFunc(ctx, ServiceMethod, arg, reply, done)
} else {
call := &Call{
ServiceMethod: ServiceMethod,
Args:arg,
Reply: nil,
Error: ErrRateLimited,
Done: done,
}
done <- call
return call
}
} else {//若限流器为nil则不进行限流
return goFunc(ctx, ServiceMethod, arg, reply, done)
}
}
}
服务端的限流实现如下(基于Wrapper):
type RequestRateLimitInterceptor struct {
//这里内嵌了defaultServerInterceptor,defaultServerInterceptor类实现了Wrapper的所有方法,我们只需要覆盖自己需要实现的方法即可
defaultServerInterceptor
Limiter ratelimit.RateLimiter
}
func (rl *RequestRateLimitInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
if rl.Limiter != nil {
//进行尝试获取,获取失败时直接返回限流异常
if rl.Limiter.TryAcquire() {
requestFunc(ctx, request, response, tr)
} else {
s.writeErrorResponse(response, tr, "request limited")
}
} else {//如果限流器为nil则直接返回
requestFunc(ctx, request, response, tr)
}
}
}
可以看到这里的限流逻辑非常简单,只支持全局限流,没有区分各个方法,但要支持也很简单,在Wrapper里维护一个方法到限流器的map,在限流时根据具体的方法名获取不同的限流器进行限流判断即可;同时这里限流也是基于单机的,不支持集群限流,要支持集群级别的限流需要独立的数据源进行次数统计等等,这里暂时不涉及了。
链路追踪
链路追踪在大型分布式系统中可以有效地帮助我们进行故障排查、性能分析等等。链路追踪通常包括三部分工作:数据埋点、数据收集和数据展示,而到RPC框架这里实际上就只涉及数据埋点了。目前业界有许多链路追踪的产品,而他们各自的api和实现都不一样,要支持不同的产品需要做很多额外的兼容改造工作,于是就有了opentracing规范。opentracing旨在统一各个不同的追踪产品的api,提供标准的接入层。而我们这里就直接集成opentracing,用户可以在使用时绑定到不同的opentracing实现,比较主流的opentracing实现有zipkin和jaeger。
客户端链路追踪的实现(同样基于Wrapper):
//目前只做了同步调用支持
type OpenTracingInterceptor struct {
defaultClientInterceptor
}
func (*OpenTracingInterceptor) WrapCall(option *SGOption, callFunc CallFunc) CallFunc {
return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error {
var clientSpan opentracing.Span
if ServiceMethod != "" { //不是心跳的请求才进行追踪
//先从当前context获取已存在的追踪信息
var parentCtx opentracing.SpanContext
if parent := opentracing.SpanFromContext(ctx); parent != nil {
parentCtx = parent.Context()
}
//开始埋点
clientSpan := opentracing.StartSpan(
ServiceMethod,
opentracing.ChildOf(parentCtx),
ext.SpanKindRPCClient)
defer clientSpan.Finish()
meta := metadata.FromContext(ctx)
writer := &trace.MetaDataCarrier{&meta}
//将追踪信息注入到metadata中,通过rpc传递到下游
injectErr := opentracing.GlobalTracer().Inject(clientSpan.Context(), opentracing.TextMap, writer)
if injectErr != nil {
log.Printf("inject trace error: %v", injectErr)
}
ctx = metadata.WithMeta(ctx, meta)
}
err := callFunc(ctx, ServiceMethod, arg, reply)
if err != nil && clientSpan != nil {
clientSpan.LogFields(opentracingLog.String("error", err.Error()))
}
return err
}
}
服务端链路追踪的实现(同样基于Wrapper):
type OpenTracingInterceptor struct {
defaultServerInterceptor
}
func (*OpenTracingInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
if protocol.MessageTypeHeartbeat != request.MessageType {
meta := metadata.FromContext(ctx)
//从metadata中提取追踪信息
spanContext, err := opentracing.GlobalTracer().Extract(opentracing.TextMap, &trace.MetaDataCarrier{&meta})
if err != nil && err != opentracing.ErrSpanContextNotFound {
log.Printf("extract span from meta error: %v", err)
}
//开始服务端埋点
serverSpan := opentracing.StartSpan(
request.ServiceName + "." + request.MethodName,
ext.RPCServerOption(spanContext),
ext.SpanKindRPCServer)
defer serverSpan.Finish()
ctx = opentracing.ContextWithSpan(ctx, serverSpan)
}
requestFunc(ctx, request, response, tr)
}
}
可以看到我们实现链路追踪的逻辑主要就是两部分:
- 根据请求方法名等信息生成链路信息
- 通过rpc metadata传递追踪信息
前面也提到了,RPC框架的工作也仅限于数据埋点而已,剩下的数据收集和数据展示部分需要依赖具体的产品。用户需要在程序里设置具体的实现,类似这样:
//mocktracker是mock的追踪,只限于测试目的使用
opentracing.SetGlobalTracer(mocktracer.New())
//或者使用jaeger
import (
"github.com/uber/jaeger-client-go/config"
"github.com/uber/jaeger-lib/metrics/prometheus"
)
metricsFactory := prometheus.New()
tracer, closer, err := config.Configuration{
ServiceName: "your-service-name",
}.NewTracer(
config.Metrics(metricsFactory),
)
//设置tracer
opentracing.SetGlobalTracer(tracer)
基于标签的路由策略
最后我们来实现基于服务端元数据的规则路由,用户在实际使用过程中,肯定有一些特殊的路由要求,比如“我们的服务运行在不同的idc,我希望能够尽量保证同idc相互调用”,或者“我希望能够在运行时切断某个服务提供者的流量”,这些需求都可以抽象成基于标签的路由。我们给每个服务提供者都打上不同的标签,客户端在调用时会根据自己的需要过滤出符合某些标签的服务提供者。
而标签的具体实现就是将标签放到服务提供者的元数据里,这些元数据会被注册到注册中心,也会被客户端服务发现时获取到,客户端在调用前进行过滤即可。
代码实现:
//服务端注册时,将我们设置的tags作为元数据注册到注册中心
func (w *DefaultServerWrapper) WrapServe(s *SGServer, serveFunc ServeFunc) ServeFunc {
return func(network string, addr string, meta map[string]interface{}) error {
//省略注册shutdownHook的逻辑
...
if meta == nil {
meta = make(map[string]interface{})
}
//注入tags
if len(s.Option.Tags) > 0 {
meta["tags"] = s.Option.Tags
}
meta["services"] = s.Services()
provider := registry.Provider{
ProviderKey: network + "@" + addr,
Network: network,
Addr: addr,
Meta: meta,
}
r := s.Option.Registry
rOpt := s.Option.RegisterOption
r.Register(rOpt, provider)
log.Printf("registered provider %v for app %s", provider, rOpt)
return serveFunc(network, addr, meta)
}
}
//客户端实现,基于tags进行过滤
func TaggedProviderFilter(tags map[string]string) Filter {
return func(provider registry.Provider, ctx context.Context, ServiceMethod string, arg interface{}) bool {
if tags == nil {
return true
}
if provider.Meta == nil {
return false
}
providerTags, ok := provider.Meta["tags"].(map[string]string)
if !ok || len(providerTags) <= 0{
return false
}
for k, v := range tags {
if tag, ok := providerTags[k];ok {
if tag != v {
return false
}
} else {
return false
}
}
return true
}
}
这里的实现当中,服务端和客户端的标签在注册前就已经设置好了,只能满足比较简单的策略,后续再考虑实现运行时修改标签的支持了。
原理
http gateway可以接收来自客户端的http请求并将其转换为rpc请求然后交给服务端处理,再将服务端处理过后的结果通过http响应返回给客户端。
http gateway的大致原理就是将我们的RPC协议中header部分放到http header中,然后RPC协议中的body部分放到http body即可。
实现
首先我们需要定义http header中各个字段的名称:
const (
HEADER_SEQ = "rpc-header-seq" //序号, 用来唯一标识请求或响应
HEADER_MESSAGE_TYPE = "rpc-header-message_type" //消息类型,用来标识一个消息是请求还是响应
HEADER_COMPRESS_TYPE = "rpc-header-compress_type" //压缩类型,用来标识一个消息的压缩方式
HEADER_SERIALIZE_TYPE = "rpc-header-serialize_type" //序列化类型,用来标识消息体采用的编码方式
HEADER_STATUS_CODE = "rpc-header-status_code" //状态类型,用来标识一个请求是正常还是异常
HEADER_SERVICE_NAME = "rpc-header-service_name" //服务名
HEADER_METHOD_NAME = "rpc-header-method_name" //方法名
HEADER_ERROR = "rpc-header-error" //方法调用发生的异常
HEADER_META_DATA = "rpc-header-meta_data" //其他元数据
)
然后我们需要启动一个http server,用来接收http请求。这里我们使用go自带的api,默认使用5080端口,如果发现端口已经被占用了,就递增端口。
func (s *SGServer) startGateway() {
port := 5080
ln, err := net.Listen("tcp", ":" + strconv.Itoa(port))
for err != nil && strings.Contains(err.Error(), "address already in use") {
port++
ln, err = net.Listen("tcp", ":" + strconv.Itoa(port))
}
if err != nil {
log.Printf("error listening gateway: %s", err.Error())
}
log.Printf("gateway listenning on " + strconv.Itoa(port))
//避免阻塞,使用新的goroutine来执行http server
go func() {
err := http.Serve(ln, s)
if err != nil {
log.Printf("error serving http %s", err.Error())
}
}()
}
接下来我们需要实现ServeHTTP函数:
func (s *SGServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
//如果url不对则直接返回
if r.URL.Path != "/invoke" {
rw.WriteHeader(404)
return
}
//如果method不对则直接返回
if r.Method != "POST" {
rw.WriteHeader(405)
return
}
//构造新的请求
request := protocol.NewMessage(s.Option.ProtocolType)
//根据http header填充request的header
request, err := parseHeader(request, r)
if err != nil {
rw.WriteHeader(400)
}
//根据http body填充request的data
request, err = parseBody(request, r)
if err != nil {
rw.WriteHeader(400)
}
//构造context
ctx := metadata.WithMeta(context.Background(), request.MetaData)
response := request.Clone()
response.MessageType = protocol.MessageTypeResponse
//处理请求
response = s.process(ctx, request, response)
//返回相应
s.writeHttpResponse(response, rw, r)
}
func parseBody(message *protocol.Message, request *http.Request) (*protocol.Message, error) {
data, err := ioutil.ReadAll(request.Body)
if err != nil {
return nil, err
}
message.Data = data
return message, nil
}
func parseHeader(message *protocol.Message, request *http.Request) (*protocol.Message, error) {
headerSeq := request.Header.Get(HEADER_SEQ)
seq, err := strconv.ParseUint(headerSeq, 10, 64)
if err != nil {
return nil, err
}
message.Seq = seq
headerMsgType := request.Header.Get(HEADER_MESSAGE_TYPE)
msgType, err := protocol.ParseMessageType(headerMsgType)
if err != nil {
return nil, err
}
message.MessageType = msgType
headerCompressType := request.Header.Get(HEADER_COMPRESS_TYPE)
compressType, err := protocol.ParseCompressType(headerCompressType)
if err != nil {
return nil, err
}
message.CompressType = compressType
headerSerializeType := request.Header.Get(HEADER_SERIALIZE_TYPE)
serializeType, err := codec.ParseSerializeType(headerSerializeType)
if err != nil {
return nil, err
}
message.SerializeType = serializeType
headerStatusCode := request.Header.Get(HEADER_STATUS_CODE)
statusCode, err := protocol.ParseStatusCode(headerStatusCode)
if err != nil {
return nil, err
}
message.StatusCode = statusCode
serviceName := request.Header.Get(HEADER_SERVICE_NAME)
message.ServiceName = serviceName
methodName := request.Header.Get(HEADER_METHOD_NAME)
message.MethodName = methodName
errorMsg := request.Header.Get(HEADER_ERROR)
message.Error = errorMsg
headerMeta := request.Header.Get(HEADER_META_DATA)
meta := make(map[string]interface{})
err = json.Unmarshal([]byte(headerMeta), &meta)
if err != nil {
return nil, err
}
message.MetaData = meta
return message, nil
}
func (s *SGServer) writeHttpResponse(message *protocol.Message, rw http.ResponseWriter, r *http.Request) {
header := rw.Header()
header.Set(HEADER_SEQ, string(message.Seq))
header.Set(HEADER_MESSAGE_TYPE, message.MessageType.String())
header.Set(HEADER_COMPRESS_TYPE, message.CompressType.String())
header.Set(HEADER_SERIALIZE_TYPE, message.SerializeType.String())
header.Set(HEADER_STATUS_CODE, message.StatusCode.String())
header.Set(HEADER_SERVICE_NAME, message.ServiceName)
header.Set(HEADER_METHOD_NAME, message.MethodName)
header.Set(HEADER_ERROR, message.Error)
metaDataJson, _ := json.Marshal(message.MetaData)
header.Set(HEADER_META_DATA, string(metaDataJson))
_, _ = rw.Write(message.Data)
}
最后我们只需要在wrapper中启动http server即可。
func (w *DefaultServerWrapper) WrapServe(s *SGServer, serveFunc ServeFunc) ServeFunc {
return func(network string, addr string, meta map[string]interface{}) error {
//省略前面的部分
...
//启动gateway
s.startGateway()
return serveFunc(network, addr, meta)
}
}
客户端测试代码:
func MakeHttpCall() {
//声明参数并序列化,放到http请求的body中
arg := service.Args{A: rand.Intn(200), B: rand.Intn(100)}
data, _ := msgpack.Marshal(arg)
body := bytes.NewBuffer(data)
req, err := http.NewRequest("POST", "http://localhost:5080/invoke", body)
if err != nil {
log.Println(err)
return
}
req.Header.Set(server.HEADER_SEQ, "1")
req.Header.Set(server.HEADER_MESSAGE_TYPE, protocol.MessageTypeRequest.String())
req.Header.Set(server.HEADER_COMPRESS_TYPE,protocol.CompressTypeNone.String())
req.Header.Set(server.HEADER_SERIALIZE_TYPE,codec.MessagePack.String())
req.Header.Set(server.HEADER_STATUS_CODE,protocol.StatusOK.String())
req.Header.Set(server.HEADER_SERVICE_NAME,"Arith")
req.Header.Set(server.HEADER_METHOD_NAME,"Add")
req.Header.Set(server.HEADER_ERROR,"")
meta := map[string]interface{}{"key":"value"}
metaJson, _ := json.Marshal(meta)
req.Header.Set(server.HEADER_META_DATA,string(metaJson))
response, err := http.DefaultClient.Do(req)
if err != nil {
log.Println(err)
return
}
if response.StatusCode != 200 {
log.Println(response)
} else if response.Header.Get(server.HEADER_ERROR) != "" {
log.Println(response.Header.Get(server.HEADER_ERROR))
} else {
data, err = ioutil.ReadAll(response.Body)
result := service.Reply{}
msgpack.Unmarshal(data, &result)
fmt.Println(result.C)
}
}
结语
这个系列到此就告一段落了,但是还有很多需要改进和丰富的地方甚至是错误,后续再以单独文章的形式更新。