目前我在字节工作,字节这边服务端基本上都是Thrift-RPC和http服务(序列化有 json/pb),内部基础设施建设也比较给力,比如我在职的部门就是做API相关的,主要是做API管理和API网关的,对于接口协议这块也是有比较深入的了解!本文主要是介绍 Thrift
协议,以thrift 协议发展历史和发展背后的故事,以及在service mesh 下 thrift 的发展!再其次就是介绍基于PB/Thrift IDL打造API管理和API网关的部分核心能力!如果你也想了解PB,可以看我写的这篇文章: PB协议讲解!
整体介绍
thrift 协议整体设计比较复杂,但是呢语法支持度也比较高,能满足大部分业务需求,完全可以通过编写IDL把所有接口定义能做的事情都做了!这个是区别于pb的,但是PB压缩率更高!再其次就是thrift的代码产物体积要大于PB,因为PB是使用反射实现的序列化和反序列化,thrift使用的是更具代码生成实现的序列化反序列化,不过这些其实和脚手架实现有关,但是现状确实如此!所以NA侧都喜欢用PB,存储侧也喜欢用PB定义,如果想要了解PB的协议介绍可以看我写的这篇文章:PB协议讲解
thrift 即包含了RPC的协议层、传输层!PB只能解决协议层,需要GRPC/其他rpc框架进行传输!
这里提到的协议层指的是例如 HTTP1.1 需要基于 TCP进行传输,那么HTTP1.1是协议层,TCP是传输层!这里说的是一个相对的概念!
- 架构图就不聊了,网上一堆,就一个rpc通信框架其实架构图都一样!下文主要介绍: 消息协议(协议层) + 传输协议(传输层),有兴趣可以看下字节开源的 Kitex!
- thrift 是 facebook 开源的一个rpc协议,诞生时间比较早,应该是10年之前了,所以国内互联网公司基本上都用的thrift协议,原因就是出来的比较早的成熟的RPC框架!但是也存在一个问题就是,古老的协议往往不满足现在的服务架构,所以后期也做了进一步的升级,但是老的业务还在跑,升级比较麻烦,也就导致很多公司thrift并没有用到新特性!例如字节这边thrift主要是用的 binary 协议!
- 由于本人主写Go,然后大家可以看下 https://github.com/apache/thrift/tree/master/lib/go go的实现,协议上!
- thrift 语法丰富,详细可以看文档:https://thrift.apache.org/docs/ !
1 | typedef string Birthday |
消息协议 (Protocol or Framed协议)
主要是详细介绍了消息传输的时候协议的主要编解码方式,注意thrift采用的是大端编码!
1. TBinaryProtocol (原始协议)
消息协议介绍,有两种协议格式!主要是由于历史原因,导致有两种协议并存!下面介绍基本来自于官方文档: thrift-binary-protocol
1. 消息协议一(新编码,严格模式)
1 | Binary protocol Message, strict encoding, 12+ bytes: |
- 前两个字节表示版本号:**高位第一个bit固定为1(因为为了区分下面协议二)**,其他17个bit(
vvvvvvvvvvvvvvv
)表示版本。 - 第三个字节为无用字节:
unused
是一个被忽略的字节。 - 第四个字节为消息类型:
mmm
是消息类型,一个无符号的 3 位整数,同时高位5bit必须为0(有些SDK会校验)。
1 | // 取头部四个字节: size = readInt32() |
name length
:消息名长度,占用4字节!大端!(注意大小端只是针对于number类型,而且大小端转换基本无开销)name
: 消息名,utf8编码seq id
: 占用四字节,大端!(一般rpc多路复用都有,因为要并发发送请求么,不能同一个连接 发送接收完 A 再发送接收 B请求, 像Http1.1就是PingPong协议,HTTP2也是有一个seq id ,这东西做的简单点就全局自增一个id即可!)
这里补充下位运算,位运算中
&
一般作用就是取值,|
一般作用就是Set值!
2. 消息协议二 (旧编码,非严格模式)
这个假如客户端/server端开启了严格模式,则不能兼容次协议
1 | Binary protocol Message, old encoding, 9+ bytes: |
- name length(四字节): 这里为了兼容上面协议一,所以高位第一个bit必须为0!也就是name length必须要有符号的正数!
- name:消息名称
- mmm: 消息类型,同上
- seq id: 消息ID
3. 结构体
这个协议比较简单,就是个基本的编码协议!!!其实就是一个 tlv 编码!
1. 结构体
1 | Binary protocol field header and field value: |
tttttttt
字段类型,带符号的 8 位整数。一个字节!field id
field-id,一个大端序的有符号 16 位整数。两个字节!field-value
编码的字段值!
1 | 字段类型 |
2. list / set
1 | Binary protocol list (5+ bytes) and elements: |
tttttttt
表示元素类型,编码为 int8size
表示全部元素个数,编码为 int32,仅正值elements
全部元素,顺序排列
3. map
1 | Binary protocol map (6+ bytes) and key value pairs: |
kkkkkkkk
是关键元素类型,编码为 int8vvvvvvvv
是值元素类型,编码为 int8size
是 map的size,编码为 int32,仅正值key value pairs
是编码的键和值,意思就是先读key,再读value,再读key,再读value
4. string/binary
1 | Binary protocol, binary data, 4+ bytes: |
byte length
是字节数组的长度bytes
是字节数组的字节
string 类型就是utf-8 编码为字节流,然后传输的时候用 binary 类型即可!
5. 其他类型
基本类型就是占用固定字节!比如Bool一个字节,i64 8个字节之类的!! 枚举等同于i32!
2. TCompactProtocol (改进协议,和PB基本类似!)
名字显而易见,就是用来压缩的,压缩算法和pb很像,就是 zigzag(处理负数)+ varint (正数),这俩东西可以看我讲的PB协议的文章 ,不过也做了很多取巧的地方,下面内容基本来自官方文档: thrift-compact-protocol
1. 消息协议
1 | Compact protocol Message (4+ bytes): |
这个协议也很简单,就是更紧凑
- 第一个字节: 协议ID,TCompactProtocol ID为 130,二进制编码为
10000010
- 第二个字节: version + type, 其中version是低5bit,type是高3bit , 其中
COMPACT_VERSION
= 1 - seq id 4字节,var int 编码
- name len 为4字节,也是var int 编码
- name: 消息名称
1 | // 消息类型 |
2. 结构体
1. 结构体
1 | Compact protocol field header (short form) and field value: |
dddd
是字段 delta,一个无符号的 4 位整数,严格正数。tttt
是字段类型 id,一个无符号的 4 位整数。field id
字段 id,一个有符号的 16 位整数,编码为 zigzag int!field-value
编码的字段值。
1 | 字段的类型,其中下面的 map/list 类型,也很简单就是可以把BOOLEAN_FALSE当作BOOL类型即可,就不重复写了! |
这里比较特殊的就是:bool类型是不占用field value
字节的,其次就是有两种编码协议,它的原理确实比pb 还要取巧,哈哈哈!
首先我们在编码的时候,field_id 一般都是顺序自增的!也就是基本上是1,2,3…n ,这时候由于 type占用4bit,此时可以用剩余的4bit存储field_id的增量即可!这个就是为啥pb可以做到 field_id < 15
的时候字节占用一个了!
1 | // start |
例如下面这个例子field_id: 1,2,3,4,5,30,31,32
1 | 0000tttt, 1 # 1 |
这里还有个细节点就是 bool 类型!bool 类型是不占用 field_value 的!
2. list/set
1 | Compact protocol list header (1 byte, short form) and elements: |
ssss
是len(list/map)的大小,4 位无符号整数,值0
-14
tttt
是元素类型,一个 4 位无符号整数size
是大小,var int (int32),正值15
或更高elements
是编码元素
这个其实我就不用说了,假如size<=15的话那么可以使用第一种了!
3. map
1 | Compact protocol map header (1 byte, empty map): |
size
是len(map)的大小,一个 var int (int32),严格的正值kkkk
是关键元素类型,一个 4 位无符号整数vvvv
是值元素类型,一个 4 位无符号整数key value pairs
是编码的键和值
其实这个更不用说了,就是当size等于0时,就写入第一种协议!
4. binary、string
1 | Binary protocol, binary data, 1+ bytes: |
- byte length 采用varint, 四字节编码
- bytes body
string类型传输的时候是utf8编码的binary类型!
5. 其他类型
占用固定字节,采用varint编码
传输协议 (Transport)
其实上面部分讲到的消息协议已经是一个完整的协议,你直接基于tcp流发送请求响应协议即可!但是为啥这块还跑出个传输协议!对的上面协议其实是有问题!因此下列列出了几个传输协议!下面这块内容基本来自于官方文档: rpc 协议介绍
Buffered(Unframed)协议
也就是上面讲解的TBinaryProtocol
和 TCompactProtocol
协议!这个协议特点就是没有payload的总长度,导致做异步处理成为难点!
Framed 协议
现在问题就是异步比较流行,所以需要再发送数据的时候采用Framed
编码,先写size,再写payload!当server端处理的时候可以根据frame_size
来获取payload,然后交给 processs处理!官方的解释是为了支持 async 编程!其实我觉得就是为了更加高效罢了,不需要把整个包解析出来,也就是传输层可以节省很多性能开销!
其实这个协议是有问题的,就是假如消息体过大的话!那么内存开销就比较大,因为需要先写到内存里,然后再头部加四个字节!
THeader 协议 (service mesh协议)
但是随着后端的技术不断发展,传统的微服务架构不能满足发展,普通的传输协议不满足于现状,因此当出现服务网格service mesh的时候,出现问题了,因为需要做流量治理,我们需要在协议里注入一些服务信息进行流量治理等等,因此后来faceboot推出了THeaderProtocol
。其实原来的老协议也能做,那就是可以在 message中定义一些 公共字段来注入流量信息,但是存在问题就是需要把整个包解出来,浪费性能!而header协议不需要,其实目前字节现在就是两种协议并存的现状!
一般流量治理主要有几大块:全链路trace+log、acl鉴权、tls流量加密、染色分流、多机房(集群)调度+LB、限流、过载保护[自适应限流、服务优先级]等,还有一些功能型能力比如压缩之类的!如果没有这个协议做这块也太难了,哈哈哈!
协议如下:
1 | 0 1 2 3 4 5 6 7 8 9 a b c d e f 0 1 2 3 4 5 6 7 8 9 a b c d e f |
LENGTH
:(4字节大端) 整个消息的长度除了 len自身4字节,比如整个消息长度为69,那么LENGTH
=65HEADER MAGIC
:2字节,魔数FLAGS
: 2字节,header FlagsSEQUENCE NUMBER
(4字节): seq idHeader Size
: 头部字节 / 4Header
头部介绍:(采用Compact
编码)PROTOCOL ID
: 表示协议ID,4字节NUM TRANSFORMS
: 表示len(TRANSFORMS)
,4字节TRANSFORMS
: 如果有的话才会传输,每个transform
是一个4字节int类型,这个可以做pyload
的压缩!INFO
是Theader
的核心功能,可以做很高的拓展!
1
2
3info type: 4字节
例如 InfoKeyValue 类型是 map<string,string> 的一个header类型,这里就是write一个map!
例如字节这边拓展了InfoIDIntKeyValue:map<int,string> 和 InfoIDACLToken: string 等类型!padding
填充字节,头部字节数必须是4的倍数
PAYLOAD
真实数据,具体分为UnframedBinary
和UnframedCompact
协议
具体细节我就不讲了,其实就是可以携带一些header信息,在传输的时候可以携带上,然后头部有头部编码的协议!
header信息可以做些什么呢,它包含一些 log 、trace、acl、流量调度的信息、服务优先级之类的!!!做流量染色!
其次就是mesh其实只需要关注于这些信息,payload部分它不关心,所以他不需要解pyload 部分!
整体概述一下全部协议
1 | // Unframed 又成为 Buffered协议 |
如何创建协议
1 | import ( |
基于Thrift/PB IDL的API管理
目前由于内部存在Thrift RPC 和 http服务并存,而且对外比如前端/客户端/OpenAPI,基本上都是需要Http形式暴露出去,因此对于字节这边建设了API网关去做协议转换!同样会存在一个问题,就是HTTP -> RPC 直接协议转换,会涉及到 我是query、header、body、code等绑定到哪个字段的问题,所以有一套IDL注解规范去帮助通过写IDL来定义http接口!同时也更加有效的降低成本接入api网关!不过这个功能只是网关的协议转换模块!其实协议转换不光光http->thrift ,而且还存在 pb in http - > thrift,因为字节核心的客户端很多都是用的PB定义的body,压缩率比较高,例如抖音和TikTok!
基于IDL去定义API好处是,我们可以通过IDL实现多版本的接口管理,而且实现 rpc/http等接口的元数据管理,更加方便用户进行使用!同时对于rpc 服务我们提供了在线编辑+脚手架能力,业务可以在平台上编写完成idl后直接可以生成代码!对于调用方我们可以直接在接口管理平台查看代码生成产物进行更新依赖!
其次就是我们有移动端网关的建设,主要是服务端NA (app)端,由于na端需要代码生成,因此我们也打通了多服务、裁剪接口的代码生成,idl可以是 pb / thrift 等!同时我们也在网关侧实现了API聚合的能力,基于GraphQL
等能力建设的!
再其次我们构建了接口测试、抓包、mock等其他功能,打通了内部的环境,使得抓包、mock可以做到在平台上一键/且无侵入式就可以实现等功能,更加方便了研发的使用!关键是可以去除tls加密,再也不需要用charles
!
还有我们打造了一个完整的API研发流程,帮助用户更加高效的接口定义、开发、测试、上线、发布网关!还有其他能力我也就不一一介绍了!