前言
通信协议设计核⼼
1. 解析效率
2. 可扩展可升级
协议设计细节
1. 帧完整性判断
2. 序列化和反序列化
3. 协议升级
4. 协议安全
5. 数据压缩
设计⽬标
1. 解析效率:互联⽹业务具有⾼并发的特点,解析效率决定了使⽤协议的CPU成本; 编码⻓度:信息编码出来的⻓度,编码⻓度决定了使⽤协议的⽹络带宽及存储成本;
2. 易于实现:互联⽹业务需要⼀个轻量级的协议,⽽不是⼤⽽全的;
3. 可读性:编码后的数据的可读性决定了使⽤协议的调试及维护成本( 不同的序列化协议是有不同的应⽤的场景);
4. 兼容性: 互联⽹的需求具有灵活多变的特点,协议会经常升级,使⽤协议的双⽅是否可以独⽴升级协议、增减协议中的字段是⾮常重要的;
5. 跨平台跨语⾔:互联⽹的的业务涉及到不同的平台和语⾔,⽐如Windows⽤C++,Android⽤Java,Web⽤Js,IOS⽤object-c。
6. 安全可靠:防⽌数据被破解
1. 协议概述
协议:协议是⼀种约定,通过约定,不同的进程可以对⼀段数据产⽣相同的理解,从⽽可以相互协作,存在进程间通信的程序就⼀定需要协议。
为什么需要⾃⼰设计协议?
⽐如不同表的插头,还需要进⾏各种转换,如果我们两端进⾏通信没有约定好协议,那彼此是不知道对⽅
发送的数据是什么意义。
2. 判断消息的完整性
为了能让对端知道如何给消息帧分界,⽬前⼀般有以下做法:
1. 以固定⼤⼩字节数⽬来分界,如每个消息100个字节,对端每收⻬100个字节,就当成⼀个消息来解析;
2. 以特定符号来分界,如每个消息都以特定的字符来结尾(如\r\n),当在字节流中读取到该字符时,
则表明上⼀个消息到此为⽌ ;
3. 固定消息头+消息体结构,这种结构中⼀般消息头部分是⼀个固定字节⻓度的结构,并且消息头中会有⼀个特定的字段指定消息体的⼤⼩。收消息时,先接收固定字节数的头部,解出这个消息完整⻓度,按此⻓度接收消息体。这是⽬前各种⽹络应⽤⽤的最多的⼀种消息格式;header + body
4. 在序列化后的buffer前⾯增加⼀个字符流的头部,其中有个字段存储消息总⻓度,根据特殊字符(⽐如根据\n或者\0)判断头部的完整性。这样通常⽐3要麻烦⼀些,HTTP和REDIS采⽤的是这种⽅式。收消息的时候,先判断已收到的数据中是否包含结束符,收到结束符后解析消息头,解出这个消息完整⻓度,按此⻓度接收消息体
3. 协议设计
重点:
1. 消息边界
2. 版本区分
3. 消息类型区分
3.1 协议设计范例
3.1.1 范例1-IM即时通讯
3.1.2 范例2-云平台节点服务器
3.1.3 范例3-nginx
3.1.4 范例4-HTTP协议
HTTP协议是我们最常⻅的协议,我们是否可以采⽤HTTP协议作为互联⽹后台的协议呢?这个⼀般是不适
当的,主要是考虑到以下2个原因:
1) HTTP协议只是⼀个框架,没有指定包体的序列化⽅式,所以还需要配合其他序列化的⽅式使⽤才能传
递业务逻辑数据。
2) HTTP协议解析效率低,⽽且⽐较复杂(不知道有没有⼈觉得HTTP协议简单,其实不是http协议简单,
⽽是HTTP⼤家⽐较熟悉⽽已)
有些情况下是可以使⽤HTTP协议的:
1) 对公⽹⽤户api,HTTP协议的穿透性最好,所以最适合;
2) 效率要求没那么⾼的场景;
3) 希望提供更多⼈熟悉的接⼝,⽐如新浪微、腾讯博提供的开放接⼝;
3.2 序列化方法
- TLV编码及其变体(TLV是tag, length和value的缩写):⽐如Protobuf。
- ⽂本流编码:⽐如XML/JSON
- 固定结构编码: 基本原理是,协议约定了传输字段类型和字段含义,和TLV的⽅式类似,但是没有了
tag和len,只有value,⽐如TCP/IP - 内存dump:基本原理是,把内存中的数据直接输出,不做任何序列化操作。反序列化的时候,直接还
原内存。
3.2.1 常见的序列化方法
主流序列化协议:xml、json、protobuf
- XML指可扩展标记语⾔(eXtensible Markup Language)。是⼀种通⽤和重量级的数据交换格式。
以⽂本⽅式存储。 - JSON(JavaScript ObjectNotation, JS 对象简谱) 是⼀种通⽤和轻量级的数据交换格式。以⽂本结构
进⾏存储。 - protocol buffer是Google的⼀种独⽴和轻量级的数据交换格式。以⼆进制结构进⾏存储。
3.2.2 序列化、反序列化速度对⽐
测试10万次序列化:
测试10万次反序列化:
4. protobuf
Protocol buffers 是⼀种语⾔中⽴,平台⽆关,可扩展的序列化数据的格式,可⽤于通信协议,数据存储
等。
Protocol buffers 在序列化数据⽅⾯,它是灵活的,⾼效的。相⽐于 XML 来说,Protocol buffers 更加
⼩巧,更加快速,更加简单。⼀旦定义了要处理的数据的数据结构之后,就可以利⽤ Protocol buffers 的
代码⽣成⼯具⽣成相关的代码。甚⾄可以在⽆需重新部署程序的情况下更新数据结构。只需使⽤
Protobuf 对数据结构进⾏⼀次描述,即可利⽤各种不同语⾔或从各种不同数据流中对你的结构化数据轻松
读写。
Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可⽤于通讯协议、数据存储等领域的语⾔⽆
关、平台⽆关、可扩展的序列化结构数据格式。tars brpc
4.1 protobuf的安装和编译
1. 首先去git上下载git protobuf releases版本的tar.gz文件到服务器上,我现在下的是[protobuf-cpp-3.21.5.tar.gz]
wget https://github.com/protocolbuffers/protobuf/releases/download/v21.5/protobuf-cpp-3.21.5.tar.gz
2. 解压
tar zxvf protobuf-cpp-3.21.5.tar.gz protobuf-3.21.5/
3. 编译protobuf
cd protobuf-3.21.5/ ./configure make sudo make install sudo ldconfig
4. 显示版本信息
protoc --version
4.2 protobuf的使用
1. 根据消息的结构编写.proto文件
2. 使用protobuf官方提供的工具生产.pb.cc和.pb.h文件
protoc -I=input_dir --cpp_out=output_dir [*.proto |/input_dir/specific.proto] #input_dir为.proto所在的路径 #cpp_out为.cc和.h⽣成的位置 #/input_dir/specific.proto为指定某个proto文件 #*proto为所有proto文件
3. 在代码中使用这些类并编译
g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread
4.3 protobuf中option部分选项
optimize_for是⽂件级别的选项,Protocol Buffer定义三种优化级SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。
1. SPEED: 表示⽣成的代码运⾏效率⾼,但是由此⽣成的代码编译后会占⽤更多的空间。
2. CODE_SIZE: 和SPEED恰恰相反,代码运⾏效率较低,但是由此⽣成的代码编译后会占⽤更少的空
间,通常⽤于资源有限的平台,如Mobile。
3. LITE_RUNTIME: ⽣成的代码执⾏效率⾼,同时⽣成代码编译后的所占⽤的空间也是⾮常少。这是以
牺牲Protocol Buffer提供的反射功能为代价的。因此我们在C++中链接Protocol Buffer库时仅需链接
libprotobuf-lite,⽽⾮libprotobuf。
比如下面显示的
4.4 Protobuf的语法
4.4.1 Protocol Buffer 命名规范
message 采用驼峰命名法。message 首字母大写开头。字段名采用下划线分隔法命名。
message SongServerRequest { required string song_name = 1; }
枚举类型采用驼峰命名法。枚举类型首字母大写开头。每个枚举值全部大写,并且采用下划线分隔法命名。每个枚举值用分号结束,不是逗号。
enum Foo { FIRST_VALUE = 0; SECOND_VALUE = 1; }
服务名和方法名都采用驼峰命名法。并且首字母都大写开头。
service FooService { rpc GetSomething(FooRequest) returns (FooResponse); }
4.4.2 protobuf工程经验
- proto文件命名规则
可以使用“项目名.模块名.proto”的方式来命名,比如下面:IM表示项目名称,Login表示登录模块 - proto命名空间
跟文件名保持一致就行,比如针对上面的IM.Login.proto文件,可以使用如下package+命名空间的方式: - 引用文件
假如一个.proto文件需要引用另一个.proto文件,那么可以使用import “文件名”方式
比如下面就是IM.Login.proto文件引用IM.BaseDefine.proto文件的示例 - 多个平台要使用同一份proto文件
4.4.3 标量数值类型
一个变量消息字段可以含有一个如下的类型—该表格展示了定义.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:
5. protobuf的编码原理
这里只讲重点原理,而不是完全去讲这个编码原理的实现。
把上一节讲的各种常用的变量类型归为4大类,包括:
- 变长编码类型Varints
- 固定64bit类型
- 有长度标记类型
- 固定32bit类型
5.1 Varints编码(变长的类型才使用)
为什么设计变长编码?
普通的int数据类型,无论其值的大小,所占用的存储空间都是相等的,比如不管是0x12345678还是0x12都占用4字节,那是否让0x12在表示的时候只占用1个字节呢?是否可以根据数值的大小来动态地占用存储空间,使得值比较小的数字占用较少的字节数,值相对比较大的数字占用较多的字节数,这即是变长整形编码的基本思想。
采用变长整形编码的数字,其占用的字节数不是完全一致的,Variants编码使用每个字节的最高有效位作为标志位,而剩余的7位以二进制补码的形式来存储数字值本身,当最高有效位位1时,代表其后还跟有字节,当最高有效位为0时,代表已经是该数字的最后的一个字节。
在Protobuf中,使用的是Base128 Variants编码,在这种方式中,使用7bit(即7的2次方为128)来存储数字,在Protobuf中,Base128 Varints 采⽤的是⼩端序, 即数字的低位存放在⾼地址, 举例
来看, 对于数字 1, 我们假设 int 类型占 4 个字节, 以标准的整型存储, 其⼆进制表示应为
可⻅, 只有最后⼀个字节存储了有效数值, 前 3 个字节都是 0, 若采⽤ Varints 编码, 其⼆进制形式为
因为其没有后续字节, 因此其最⾼有效位为 0, 其余的 7 位以补码形式存放 1, 再⽐如数字 666, 其以标准的整型存储, 其⼆进制表示为
从上⾯的编码解码过程可以看出, 可变⻓整型编码对于不同⼤⼩的数字, 其所占⽤的存储空间是不同的。
问: 如果⼀个值为0xff ff ff ff那需要多少个字节存储?
答:0xff ff ff ff需要分配32个bit,使⽤base128 Varints 编码需要的字节数: 32/7=4.57, 只要
不整除就要进位, 就是需要5个字节存储。 从这⾥看得出来,<=28bit的整数适合使⽤变⻓编码,
如果整数都是32bit>=变量>28bit可以考虑使⽤fixed32,sfixed32等固定4字节的类型。
5.2 Zigzag 编码(针对负数的)
这里重点:
- 主要理解为什么负数不建议使用int类型
- 对于Zigzag的算法不必太细究。其目的是把多个1转为多个0表示。
Varints编码的实质在于去掉数字开头的0,因此可缩短数字所占的存储字节数,在上面的例子中,只举例说明了正数的Varints编码,但如果数字为负数,则采用Varints编码会恒定占用10个字节,原因在于负数的符号位为1,对于负数其从符号位开始的高位均为1,在Protobuf的具体实现中,会将此视为一个很大的无符号数,以C++语言的实现为例,对于int32类型的pb字段,对于如下定义的proto
message Tint32{ int32 n1 = 1; }
message中包含类型为int32类型的字段,当a为负数时,其序列化之后将恒定占用10个字节,我们可以使用如下的测试代码(见2-protobuf\5-codec代码的Tint32()函数)
对于 int32 类型的数字 -5, 其序列化之后的⼆进制为
究其原因在于 Protobuf 的内部将 int32 类型的负数转换为 uint64 来处理。
比如整数-5:
- 取 5 的 原 码 : 00000000 00000000 00000000 00000000 00000000 00000000 00000000
00000101, - 得 反 码 : 11111111 11111111 11111111 11111111 11111111 11111111 11111111
11111010, - 对反码加1最后得补码:11111111 11111111 11111111 11111111 11111111 11111111
11111111 11111011, - 即-5在计算机⾥⽤⼆进制表示结果11111111 11111111 11111111 11111111 11111111
11111111 11111111 11111011。
转为每7bit占用一个字节,
转换后的 uint64 数值的⾼位全为 1, 相当于是⼀个 8 字节的很⼤的⽆符号数, 因此采⽤ Base128
Varints 编码后将恒定占⽤ 10 个字节的空间, 可⻅ Varints 编码对于表示负数毫⽆优势, 甚⾄⽐普通
的固定 32 位存储还要多占 4 个字节。Varints 编码的实质在于设法移除数字开头的 0 ⽐特, ⽽对于
负数, 由于其数字⾼位都是 1, 因此 Varints 编码在此场景下失效, Zigzag 编码便是为了解决这个问
题, Zigzag 编码的⼤致思想是⾸先对负数做⼀次变换, 将其映射为⼀个正数, 变换以后便可以使⽤
Varints 编码进⾏压缩, 这⾥关键的⼀点在于变换的算法, ⾸先算法必须是可逆的, 即可以根据变换后
的值计算出原始值, 否则就⽆法解码, 同时要求变换算法要尽可能简单, 以避免影响 Protobuf 编码、
解码的速度, 我们假设 n 是⼀个 32 位类型的数字, 则 Zigzag 编码的计算⽅式为
要注意这⾥左边是逻辑移位, 右边是算术移位, 右边的含义实际是得到⼀个全 1 (对于负数) 或全 0 (对
于正数)的⽐特序列, 因为对于任意⼀个位数为 η 的有符号数 n, 其最⾼位为符号位, 剩下的 η - 1 位为数字位,
将其算术右移 η - 1 位, 由于是算术移位, 因此右移时左边产⽣的空位将由符号位来填充,
进⾏ η - 1 次算术右移之后便得到 η 位与原先的符号位相等的序列, 然后对两边按位异或便得到
Zigzag 编码, 我们⽤⼀个图示来直观地说明 Zigzag 编码的设计思想, 为了简化, 我们假定数字是 16
位的, 先来看负数的情形, 假设数字为 -5, 其在内存中的形式为
⾸先对其进⾏⼀次逻辑左移, 移位后空出的⽐特位由 0 填充
然后对原数字进⾏ 15 次算术右移, 得到 16 位全为原符号位(即 1)的数字
可以看到, 对负数使⽤ Zigzag 编码以后, 其⾼位的 1 全部变成了 0, 这样以来我们便可以使⽤
Varints 编码进⾏进⼀步地压缩, 再来看正数的情形, 对于 16 位的正数 5, 其在内存中的存储形式为
我们按照与负数相同的处理⽅法, 可以得到其 Zigzag 编码为
从上⾯的结果来看, ⽆论是正数还是负数, 经过 Zigzag 编码以后, 数字⾼位都是 0, 这样以来, 便可以
进⼀步使⽤ Varints 编码进⾏数据压缩, 即 Zigzag 编码在 Protobuf 中并不单独使⽤, ⽽是配合
Varints 编码共同来进⾏数据压缩
逆向原理不做进⼀步引申了,ZigZag的逆函数 = (n>>1)^ -(n&1),有兴趣参考:《⼩⽽巧的数
字压缩算法:zigzag》 https://blog.csdn.net/zgwangbo/article/details/51590186
sint32=Zigzag 编码 +varints编码合起来
sint32序列化:负数 -> Zigzag 编码 -> varints编码
sint32反序列化: varints解码 -> Zigzag 解码 -> 负数
重点在于:同样是表示-5,sint32只需要2个字节,int32需要11字节
5.3 Protobuf的数据组织
在上⾯的讨论中, 我们了解了 Protobuf 所使⽤的 Varints 编码和 Zigzag 编码的编码原理, 本节我
们来讨论 Protobuf 的数据组织⽅式, ⾸先来看⼀个例⼦, 假设客户端和服务端使⽤ protobuf 作为数
据交换格式, proto 的具体定义为
Request 中包含了⼀个名称为 age 的字段, 客户端和服务端双⽅都⽤同⼀份相同的 proto ⽂件是没有任
何问题的, 假设客户端⾃⼰将 proto ⽂件做了修改, 修改后的 proto ⽂件如下
在这种情形下, 服务端不修改应⽤程序仍能够正确地解码, 原因在于序列化后的 Protobuf 没有使⽤
字段名称, ⽽仅仅采⽤了字段编号, 与 json xml 等相⽐, Protobuf 不是⼀种完全⾃描述的协议格
式, 即接收端在没有 proto ⽂件定义的前提下是⽆法解码⼀个 protobuf 消息体的, 与此相对的,
json xml 等协议格式是完全⾃描述的, 拿到了 json 消息体, 便可以知道这段消息体中有哪些字段, 每
个字段的值分别是什么, 其实对于客户端和服务端通信双⽅来说, 约定好了消息格式之后完全没有必
要在每⼀条消息中都携带字段名称, Protobuf 在通信数据中移除字段名称, 这可以⼤⼤降低消息的⻓
度, 提⾼通信效率, Protobuf 进⼀步将通信线路上消息类型做了划分, 如下表所示
对于 int32, int64, uint32 等数据类型在序列化之后都会转为 Varints 编码, 除去两种已标记
为 deprecated 的类型, ⽬前 Protobuf 在序列化之后的消息类型(wire-type) 总共有 4
种, Protobuf 除了存储字段的值之外, 还存储了字段的编号以及字段在通信线路上的格式类型(wire-
type), 具体的存储⽅式为
field_num << 3 | wire type
即将字段标号逻辑左移 3 位, 然后与该字段的 wire type 的编号按位或, 在上表中可以看到, wire
type 总共有 6 种类型, 因此可以⽤ 3 位⼆进制来标识, 所以低 3 位实际上存储了其后所跟的数据的
wire type, 接收端可以利⽤这些信息, 结合 proto ⽂件来解码消息结构体, 我们以上⾯ proto 为例来
看⼀段 Protobuf 实际序列化之后的完整⼆进制数据, 假设 age 为 5, 由于 age 在 proto ⽂件中定义
的是 int32 类型, 因此序列化之后它的 wire type 为 0, 其字段编号为 1, 因此按照上⾯的计算⽅式,
即 1 << 3 | 0, 所以其类型和字段编号的信息只占 1 个字节, 即 00001000, 后⾯跟上字段值 5 的
Varints 编码, 所以整个结构体序列化之后为
有了字段编号和 wire type, 其后所跟的数据的⻓度便是确定的, 因此 Protobuf 是⼀种⾮常紧密的数
据组织格式, 其不需要特别地加⼊额外的分隔符来分割⼀个消息字段, 这可⼤⼤提升通信的效率, 规避
冗余的数据传输。
1. 当wire_type等于0的时候整个⼆进制结构为:Tag-Value
value的编码也采⽤Varints编码⽅式,故不需要额外的位来表示整个value的⻓度。因为Varint的msb位标
识下⼀个字节是否是有效的就起到了指示⻓度的作⽤。
⽐如:
2.当wire_type等于1、5的时候整个⼆进制结构也为:Tag-Value
因为都是取固定32位或者64位,因此也不需要额外的位来表示整个value的⻓度。
3.当wire_type等于2的时候整个⼆进制结构为:Tag-[Length]-Value
因为表示的是可变⻓度的值,需要有额外的位来指示⻓度。
repeat也是这种模式,此时length代表元素个数。
filed_num范围:
1. 1到15,仅使用1bytes。每个byte包含两个部分,⼀个是field_number⼀个是tag,其中field-number就是protobuf中每个值后等号后的数字(在C++和Java中,如果不设置这个值,则它是随机的,如果在Python中,不设置,它则不被处理。那么我们可以认为这个field_number是必须的。那么⼀个byte⽤来表达这个值就是00000000,其中红⾊表示是否有后续字节,如果为0表示没有也就是这是最后⼀个字节,蓝⾊部分表示field-number,绿⾊部分则是wire_type部分,表示数据类型。也就是(field_number << 3) |wire_type。其中wire_type只有3位,表示数据类型。那么能够表示field_number的就是4位蓝⾊的数
字,4位数字能够表达的最⼤范围就是1-15(其中0是无效的)。
2. 16到2047,与上⾯的规则其实类似(类似base128的⽅式),下⾯以2bytes为例⼦,那么就有
10000000 00000000,其中红⾊部分依然是符号位,因为每个byte的第⼀位都⽤来表示下⼀byte是否
和⾃⼰有关,那么对于>1byte的数据,第⼀位⼀定是1,因为这⾥假设是2byte,那么第⼆个byte的第
⼀位也是红⾊,刨除这两位,再扣掉3个wire_type位,剩下11位(2*8-2-3),能够表达的数字范围
就是2047(2 )。
当 filed_num > 15时, 以16、64、65为例,value值为1
int32 n1_int32 = 16;
field_number 和 wire_type的关系为: 有效位为红⾊,wire_type为绿⾊,field_number为蓝⾊:
5.4 编码总结
- Protobuf 采⽤ Varints 编码和 Zigzag 编码来编码数据, 其中 Varints 编码的思想是移除数字⾼
位的 0, ⽤变⻓的⼆进制位来描述⼀个数字, 对于⼩数字, 其编码⻓度短, 可提⾼数据传输效率, 但
由于它在每个字节的最⾼位额外采⽤了⼀个标志位来标记其后是否还跟有有效字节, 因此对于⼤
的正数, 它会⽐使⽤普通的定⻓格式占⽤更多的空间, 另外对于负数, 直接采⽤ Varints 编码将恒
定占⽤ 10 个字节, Zigzag 编码可将负数映射为⽆符号的正数, 然后采⽤ Varints 编码进⾏数据
压缩, 在各种语⾔的 Protobuf 实现中, 对于 int32 类型的数据, Protobuf 都会转为 uint64 ⽽后
使⽤ Varints 编码来处理, 因此当字段可能为负数时, 我们应使⽤ sint32 或 sint64, 这样
Protobuf 会按照 Zigzag 编码将数据变换后再采⽤ Varints 编码进⾏压缩, 从⽽缩短数据的⼆进
制位数 - Protobuf 不是完全⾃描述的信息描述格式, 接收端需要有相应的解码器(即 proto 定义)才可解析
数据格式, 序列化后的 Protobuf 数据不携带字段名, 只使⽤字段编号来标识⼀个字段, 因此更改
proto 的字段名不会影响数据解析(但这显然不是⼀种好的⾏为), 字段编号会被编码进⼆进制的
消息结构中, 因此我们应尽可能地使⽤⼩字段编号 - Protobuf 是⼀种紧密的消息结构, 编码后字段之间没有间隔, 每个字段头由两部分组成: 字段编
号和 wire type, 字段头可确定数据段的⻓度, 因此其字段之前⽆需加⼊间隔, 也⽆需引⼊特定的
数据来标记字段末尾, 因此 Protobuf 的编码⻓度短, 传输效率⾼。