本系列准备介绍微服务框架的相关内容,以我目前在用的kite框架为栗子,也扩展一些业界常见的实现,主要包括一下的部分:

  1. rpc框架基础
  2. kite的具体实现
  3. 服务治理概述
  4. 其他实现细节及优化


说起微服务,rpc肯定是绕不过去的,可以说rpc是整个微服务体系的基石。有了rpc,我们才能在将复杂的单体应用拆分为多个独立部署的微服务之后简单地实现服务间的调用,就像rpc的定义说的一样,像调用本地方法一样调用远程方法。

那么rpc是个怎么样的技术呢?我的理解,rpc的本质就是跨网络的请求。涉及到跨网络的请求,那必须要用到两个东西:序列化和协议。(这里解释一下,通常来说,通讯协议是包含序列化的,但是这里提到的协议是狭义的概念,表示的是通信双方约定好的通信的格式,对标的是OSI七层模型中的应用层。)下面我们来具体介绍一些相关内容。

序列化

在进程中直接操作的都是数据结构和对象,但是内存中的数据结构和对象是不能直接进行网络传输的,必须将其转换为二进制的数据,把内存中的对象转变为二进制数据的过程称之为序列化,反之,将二进制数据还原为相应对象的过程就是反序列化。

常见的序列化技术有json、thrift、protobuf等。(这里解释一下,protobuf是单纯的序列化技术,但是thrift是一套完整的rpc解决方案,序列化只是其中的一小部分内容,这里说thrift只是为了称呼方便,指代的是thrift中的序列化部分。) 其中json主要用在web应用前后端交互的场景下,其可读性强,但是性能和压缩率比较差。在后端rpc中常用的序列化技术主要是thrift和protobuf。

thrift

上图为thrift的架构图,可以看到thrift protocol层支持BinaryProtocol、CompactProtocol、JsonProtocol、MultiplexedProtocol,其中最常用的为BinaryProtocol、CompactProtocol,但是这里的协议是通讯协议或者说rpc协议,序列化协议主要支持binary、compact、json,下面简单介绍下binary和compact。

Binary

thrift的二进制编码采用TLV编码格式实现。一个TLV的值由tag、length、value三个字段构成,其中tag表示数据的类型,length表示数据的长度,value表示数据的值。一般情况下,tag和length的长度是固定的,length是可选的,value的长度是可变的。具体的细节可见下表。

可以看到thrift用一个字节来标识具体的数据类型,两个字节来标识对应字段的编号。对于固定长度的数据类型,没有length字段,对于变长的数据类型,有四个字节的length字段来表示数据长度。

Compact

压缩二进制协议和二进制协议基本是一样的,都是采用TLV的编码格式,唯一区别的点在于压缩二进制协议对于整形会采用varint+zigtag的方式进行编码。

varint编码方式对每个字节都只用后7位表示实际数据,最高位来表示下一个字节是否还属于当前数据,最高位为1则下一个字节还是当前数值范围,最高位为0则下一个字节不属于当前数值范围。

采用该方式进行编码时,对于小整数会占用更少的字节,对于大整数会占用更多的字节。对于i32来说,可能会占用1-5个字节,对i64来说,可能会占用1-10个字节。

下面以300(i32)为例来演示varint是如何编码的。

# 300的二进制表示如下,占4个字节32位
00000000 00000000 00000001 00101100

# 300以varint表示如下
10101100 00000010

对于无符号的整数来说,只会采用varint来进行编码。对于有符号的整数,因为最高位用来表示符号,对于负数,最高位为1,其实是很大的整数,反而会消耗更多的字节来表示。所以对于有符号整数,先采用zigzag将其映射到无符号的整数再进行varint编码。zigtag编码如下:

i32:
1) if n >= 0, m=2*n
2) if n < 0, m=2*|n| - 1
3) 采用位运算实现: m=(n << 1) ^ (n >> 31)

i64:
1) if n >= 0, m=2*n
2) if n < 0, m=2*|n| - 1
3) 采用位运算实现: m=(n << 1) ^ (n >> 63)

protobuf

protobuf的编码方式其实和thrift大同小异,同样采用了TLV的编码方式,对于整数采用varint和zigtag的方式。

当然区别也是有的。之前我们提到过,在thrift中会用一个字节来表示数据类型,两个字节来表示编号,这其实是非常浪费的。一方面thrift中的数据类型只有11种,另一方面绝大多数情况下我们定义的结构体中的字段都只有几个,编号不会很大。

所以protobuf对此进行了优化,tag占用一字节,其中较低的三位表示write type,代表数据类型,较高的五位表示编号,当编号大于16的时候编号会额外再占据一字节(关于protobuf如何判断编号是否额外占据一字节还不是很清楚,让我们暂时忽略细节<updatae, tag是使用varint来表示的,破案>)。
在这里插入图片描述

这里是另一篇关于pb动态解析的文章,有兴趣可以看一眼。

对比

关于序列化技术,最受关注的有两点:性能和压缩率。

  • 关于性能,从上面的介绍来看,我认为thrift和protobuf应该相差不大。因为两者都采用了TLV的编码格式,序列化和反序列化是应该都是只涉及到位运算,甚至因为protobuf做的一些优化比如编号放在tag中在编解码过程中可能需要消耗额外的工作量。
  • 关于压缩率,从上面的介绍也应该比较容易看出是protobuf > thrift compact > thrift binary 。

最后还有一点需要重复提到的是,protobuf是单纯的序列化技术,但是thrift是一套完整的rpc解决方案,很难去单独地使用其序列化技术,这可能也是在技术选型时比较重要的影响因素。

协议

再次强调,在文章最开始的时候我们提到过,这里的协议是指通信双方约定好的通信的格式。
协议的制定对于通信至关重要,这关系到信息能否有效地传递。大多数的协议都是header + body的形式。
下图为thrift binary protocol的格式,是由header + body组成。

----------------------------------------------------------------------------------------
|                              header                            |        body         |
|  magic  | method name length |  method name  | sequence number |       result        |
|    4    |         4          | N length size |        4        |          X          |   
----------------------------------------------------------------------------------------

header首先是一个4字节的magic字段,其中高16位为8001,低16位表示TMessageType。TMessageType目前有4种:

  • CALL = 1 调用消息,如0x80010001
  • REPLY = 2 应答消息,如0x80010002
  • EXCEPTION = 3 异常消息,如0x80010003
  • ONEWAY = 4 单向消息,属于调用消息,但是不需要应答,如0x80010004

之后是4字节的方法名称长度,不定长的方法名称,4个字节的sequence num。
然后就是body部分。body部分就是用上面讲的序列化方式将请求或者响应序列化后的二进制数据。
我们看到这header并没有消息长度这个字段,这是因为thrift在设计时的面向流的理念。这样在序列化时不需要先计算出整个消息的长度,这在消息很大时对性能有很大的提升。那么header中没有length如何判断消息结束呢?对于结构体来说,会设置一个带有特殊stop type的字段来标识结构体的结束。

thrift框架

接下来,继续以thrift为例,介绍rpc框架的基础。
thrift是以层级的结构来构建rpc框架,并对不同层级进行了抽象,分为Ttransport、
Tprotocol、Tprocessor三层。对于服务端来说,还有Tserver,Tserver来管理前面的三层。
简单的示意图如下,更多详细内容会在kite的具体实现中介绍。

参考文档:
https://thrift.apache.org/static/files/thrift-20070401.pdf


如果觉得本文对您有帮助,可以请博主喝杯咖啡~

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐