通信协议设计核⼼
1. 解析效率
2. 可扩展可升级
协议设计细节
1. 帧完整性判断
2. 序列化和反序列化
3. 协议升级
4. 协议安全
5. 数据压缩
设计⽬标
1. 解析效率:互联⽹业务具有⾼并发的特点,解析效率决定了使⽤协议的CPU成本; 编码⻓度:信息编码出来的⻓度,编码⻓度决定了使⽤协议的⽹络带宽及存储成本;
2. 易于实现:互联⽹业务需要⼀个轻量级的协议,⽽不是⼤⽽全的;
3. 可读性:编码后的数据的可读性决定了使⽤协议的调试及维护成本( 不同的序列化协议是有不同的应⽤的场景);
4. 兼容性: 互联⽹的需求具有灵活多变的特点,协议会经常升级,使⽤协议的双⽅是否可以独⽴升级协议、增减协议中的字段是⾮常重要的;
5. 跨平台跨语⾔:互联⽹的的业务涉及到不同的平台和语⾔,⽐如Windows⽤C++,Android⽤Java,Web⽤Js,IOS⽤object-c。
6. 安全可靠:防⽌数据被破解
协议:协议是⼀种约定,通过约定,不同的进程可以对⼀段数据产⽣相同的理解,从⽽可以相互协作,存在进程间通信的程序就⼀定需要协议。
为什么需要⾃⼰设计协议?
⽐如不同表的插头,还需要进⾏各种转换,如果我们两端进⾏通信没有约定好协议,那彼此是不知道对⽅
发送的数据是什么意义。
为了能让对端知道如何给消息帧分界,⽬前⼀般有以下做法:
1. 以固定⼤⼩字节数⽬来分界,如每个消息100个字节,对端每收⻬100个字节,就当成⼀个消息来解析;
2. 以特定符号来分界,如每个消息都以特定的字符来结尾(如\r\n),当在字节流中读取到该字符时,
则表明上⼀个消息到此为⽌ ;
3. 固定消息头+消息体结构,这种结构中⼀般消息头部分是⼀个固定字节⻓度的结构,并且消息头中会有⼀个特定的字段指定消息体的⼤⼩。收消息时,先接收固定字节数的头部,解出这个消息完整⻓度,按此⻓度接收消息体。这是⽬前各种⽹络应⽤⽤的最多的⼀种消息格式;header + body
4. 在序列化后的buffer前⾯增加⼀个字符流的头部,其中有个字段存储消息总⻓度,根据特殊字符(⽐如根据\n或者\0)判断头部的完整性。这样通常⽐3要麻烦⼀些,HTTP和REDIS采⽤的是这种⽅式。收消息的时候,先判断已收到的数据中是否包含结束符,收到结束符后解析消息头,解出这个消息完整⻓度,按此⻓度接收消息体
重点:
1. 消息边界
2. 版本区分
3. 消息类型区分
HTTP协议是我们最常⻅的协议,我们是否可以采⽤HTTP协议作为互联⽹后台的协议呢?这个⼀般是不适
当的,主要是考虑到以下2个原因:
1) HTTP协议只是⼀个框架,没有指定包体的序列化⽅式,所以还需要配合其他序列化的⽅式使⽤才能传
递业务逻辑数据。
2) HTTP协议解析效率低,⽽且⽐较复杂(不知道有没有⼈觉得HTTP协议简单,其实不是http协议简单,
⽽是HTTP⼤家⽐较熟悉⽽已)
有些情况下是可以使⽤HTTP协议的:
1) 对公⽹⽤户api,HTTP协议的穿透性最好,所以最适合;
2) 效率要求没那么⾼的场景;
3) 希望提供更多⼈熟悉的接⼝,⽐如新浪微、腾讯博提供的开放接⼝;
主流序列化协议:xml、json、protobuf
Protocol buffers 是⼀种语⾔中⽴,平台⽆关,可扩展的序列化数据的格式,可⽤于通信协议,数据存储
等。
Protocol buffers 在序列化数据⽅⾯,它是灵活的,⾼效的。相⽐于 XML 来说,Protocol buffers 更加
⼩巧,更加快速,更加简单。⼀旦定义了要处理的数据的数据结构之后,就可以利⽤ Protocol buffers 的
代码⽣成⼯具⽣成相关的代码。甚⾄可以在⽆需重新部署程序的情况下更新数据结构。只需使⽤
Protobuf 对数据结构进⾏⼀次描述,即可利⽤各种不同语⾔或从各种不同数据流中对你的结构化数据轻松
读写。
Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可⽤于通讯协议、数据存储等领域的语⾔⽆
关、平台⽆关、可扩展的序列化结构数据格式。tars brpc
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
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
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。
比如下面显示的
message 采用驼峰命名法。message 首字母大写开头。字段名采用下划线分隔法命名。
message SongServerRequest {
required string song_name = 1;
}
枚举类型采用驼峰命名法。枚举类型首字母大写开头。每个枚举值全部大写,并且采用下划线分隔法命名。每个枚举值用分号结束,不是逗号。
enum Foo {
FIRST_VALUE = 0;
SECOND_VALUE = 1;
}
服务名和方法名都采用驼峰命名法。并且首字母都大写开头。
service FooService {
rpc GetSomething(FooRequest) returns (FooResponse);
}
proto文件命名规则
可以使用“项目名.模块名.proto”的方式来命名,比如下面:IM表示项目名称,Login表示登录模块
proto命名空间
跟文件名保持一致就行,比如针对上面的IM.Login.proto文件,可以使用如下package+命名空间的方式:
引用文件
假如一个.proto文件需要引用另一个.proto文件,那么可以使用import “文件名”方式
比如下面就是IM.Login.proto文件引用IM.BaseDefine.proto文件的示例
多个平台要使用同一份proto文件
一个变量消息字段可以含有一个如下的类型—该表格展示了定义.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:
这里只讲重点原理,而不是完全去讲这个编码原理的实现。
把上一节讲的各种常用的变量类型归为4大类,包括:
为什么设计变长编码?
普通的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字节的类型。
这里重点:
message Tint32{
int32 n1 = 1;
}
message中包含类型为int32类型的字段,当a为负数时,其序列化之后将恒定占用10个字节,我们可以使用如下的测试代码(见2-protobuf\5-codec代码的Tint32()函数)
对于 int32 类型的数字 -5, 其序列化之后的⼆进制为
究其原因在于 Protobuf 的内部将 int32 类型的负数转换为 uint64 来处理。
比如整数-5:
在上⾯的讨论中, 我们了解了 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为蓝⾊: