从零开始实现一个RPC框架(零)

8,413 阅读8分钟

前言

背景

最近决心开始学习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 //其他元数据
}

结语

第一篇文章就到此为止了,主要先做一下准备,整理一下思路,如果有不正确或者不合理的部分还请大家多多指教。