通信协议设计核心
- 解析效率
- 可扩展可升级
协议设计细节
- 帧完整性判断
- 序列化和反序列化
- 协议升级
- 协议安全
- 数据压缩
1、消息的完整性
判断消息的完整性,关键在于判断消息的起始位置和结束位置,即序列化。
- 以特定符号来分界。当字节流中读取到该字符,表明上一个消息结束。例:
\r\n
- header + body。消息头 header 固定字节长度,其中有个字段 len 指定消息体结构 body 的大小。接收消息时,先接受固定字节数的头部,解除这个消息的完整长度,按此长度接收消息体。如何确定消息的起始位置,一种方案是从 [0] 位置开始解析,但是一旦 tcp 传输出现问题,消息无法解析。另一种方案是 header 用2个字节做 sync word 同步字,通过 sync word 确定消息的起始位置。
- 序列化的 buffer 前面增加一个字符流的头部。其中有个字段存储消息总长度,根据特殊字符判断头部的完整性。接收消息时,先判断已收到的数据中是否包含结束符,收到结束符后解析消息头,解出这个消息完整长度,按此长度接收消息体。代表:http, redis
'$6\r\nfoobar\r\n'
2、协议设计
根据不同的业务,设计不同的协议,但重点在于
- 消息边界
- 版本区分:尽量提前,因为后续字段内容改变。
- 消息类型区分
2.1、协议设计实例
2.1.1、nginx 协议
- 消息边界:同步字 + 消息头和消息体。
- 版本区分:版本号
- 消息类型:id 区分
typedef struct { ngx_char_t magic[2]; // sync word,通信协议数据包的开始标志 ngx_short_t version; // 版本号 ngx_short_t type; // 类型:序列化方式:json, xml, binary, protobuf and so on ngx_short_t len; // 消息体 body 长度 ngx_uint_t seq; // 序列号:保证业务可靠 ngx_short_t id; // 消息id,区分消息类型 ngx_char_t reserve[2]; // 预留字节 } ngx_message_head_t;
应用层数据也需要序列号保证可靠传输, 这是因为 tcp 数据传输可靠,但是并不代表业务可靠。考虑这种场景,服务器收到消息,然后宕机,没有及时处理消息,这时客户发送的信息丢失,需要保证客户的信息正确到达,比如微信的提示重发功能。
2.1.2、redis 协议
- 消息边界:字符流头部
- 版本区分:?
- 消息类型:字符串的第一个字符。
RESP (Serialization Protocol) 协议:先发送⼀个字符串表示参数个数,然后再逐个发送参数,每个参数发送的时候,先发送⼀个字符串表示参数的数据长度,再发送参数的内容。
协议的不同部分使用以 CRLF 即\r\n
结束
// 客户端使用 RESP 数组将多行字符串 Bulk Strings 命令发送到 redis 服务器,所以开始是 * *<参数数量>CRLF$<参数1的长度>CRLF<参数1的数据>CRLF...$<参数n的长度>CRLF<参数n的数据>CRLF // 写作 *<参数数量>\r\n$<参数1的长度>\r\n<参数1的数据>\r\n...$<参数n的长度>\r\n<参数n的数据>\r\n
RESP 支持的数据类型,通过第一个字符判断
+
Simple Strings
+OK\r\n-
Errors:
-Error <message>\r\n:
Integers
:<数值>\r\n$
Bulk Strings
$<数据长度>\r\n<数据内容>\r\n*
Arrays
*<元素个数n>\r\n<元素内容>...<元素n>
来看下面例子
在 redis-cli 客户端,发送一条命令 set key value
,对应的报文为:
*3CRLF$3CRLFsetCRLF$3CRLFkeyCRLF$5CRLFvalue
执行成功 OK
,对应的报文为:
+OK\r\n
若执行失败,比如命令 set 输入错误
-ERR unknown command `ket`, with args beginning with: `key`, `value`, \r\n
2.1.3、实例:即时通信协议
- 消息边界:同步字 + 消息头和消息体。
- 版本区分:版本号
- 消息类型:appid, service_id, command_id
unsigned short length; // header + body len unsigned short version; // 版本号 unsigned int seq_num; // 序列号 // 消息类型识别 unsigned short appid; // 对外SDK提供服务时,⽤来识别不同的客户 unsigned short service_id; // 对应命令的分组 login msg unsigned short command_id; // 分组里的子命令 login requeset login respond unsigned short reserve; // 预留字节 unsigned char[] body; // 具体数据,使用 Protobuf 序列化,不同的命令对应的类对象不一样
2.2、序列化方法
对 body 中存储的数据,要进行序列化(对象 -> 存储介质)和反序列化(存储介质 -> 对象)。
对于网络传输过程:
body 序列化 -> 封装协议 -> 发送 -> 网络传输 -> 接收 -> 解析协议 header+body -> 反序列化 body
为什么需要序列化方法?
序列化方法对每个字段有边界的约束。而字段大多是变长的,需要人为规定起始和结束位置,说到底还是消息完整性的问题(消息边界)。
例如:xml 中 <字段描述>表示字段起始,</字段描述>表示字段结束;json 中字段描述 : 字段
,:
表示字段起始,,
表示字段结束。Protobuf 字段描述和前两者都不同,采用的是字段编号,以二进制形式存储,结构紧凑,传输速度快。
2.2.1、序列化方法
主流序列化协议
- XML:是一种通用和重量级的数据交换格式。 以文本格式进行存储,适用于本地等。
- JSON:是⼀种通用和轻量级的数据交换格式。以文本格式进行存储,适用于http,api等
- Protobuf:是⼀种独立和轻量级的数据交换格式。以二进制格式进行存储,适用于 rpc, 游戏,即时通讯等
2.3、协议安全
2.4、数据压缩
数据压缩:文本的情况下压缩,二进制压缩(视屏、图片)没多大意义
常见的压缩方式有
- deflate nginx
- gzip
- lzw
2.5、协议升级
协议升级即增加字段。
- 通过版本号指明协议版本
- 支持协议头部可拓展,一个字段指明头部的长度
3、Protobuf
Protocol buffers 是 Google 开源的一种语言无关,平台无关,可扩展的序列化数据的格式。适合做数据存储或 RPC 数据交换格式,可用于通信协议,数据存储等领域。
3.1、安装编译
protobuf 官网
# 解压 tar zxvf protobuf-cpp-3.8.0.tar.gz # 编译 cd protobuf-3.8.0/ ./configure make sudo make install sudo ldconfig # 显示版本信息 protoc --version
3.2、工作流程
Protobuf 协议
接口描述语言 IDL,Interface description language
可以看到,对于序列化协议来说,使用方只需要关注业务对象本身,即 idl 定义 (.proto),序列化和反序列化的代码只需要通过工具生成即可。
使用 protobuf 的方法
- 编写 proto 文件
- 调用 proto 文件,将 proto 文件生成对应的 .http://pb.cc 和 .pb.h 文件,让程序去调用
protoc -I=/.proto文件路径 --cpp_out=./.cc和.h生成路径 .proto文件路径 - 编译:
-lprotobuf
3.3、标量数值类型
Protobuf 标量数值类型
3.4、编码原理
3.4.1、Varints 编码
变长整型编码:根据数值的大小动态占用存储空间,小数字占用较少字节数,短编码,
Varints 编码的实质在于设法移除数字开头的 0。具体来说,使用每个字节的最高位作为标志位,而剩余的 7 位以二进制补码的形式来存储数字值本身,当最高有效位为 1 时,代表其后还有字节,当最高有效位为 0 时,代表是该数字的最后一个字节。
protobuf 使用的是 Base128 Varints 编码,小端序。
- 每个字节用 7bit 存储数值的信息
- 用 1 bit (该字节最高位) 标记结束,=1 还没有结束,=0 表示结束
对于大数字来说,使用 Varints 编码,意味着占用较多的字节数。对于数字的位数不超过 28 bit 适合使用变长编码。若数字位数超过 28 bit,例如 32 bit,使用变长编码需要的存储空间为 [32 /7 ] = 5 个字节。因此使用 fixed32, sfixed32 固定 4 字节的类型更合适。
3.4.2、Zigzag 编码
对于负数, 直接使用 Varints 编码固定占用 10 个字节(负数符号位是1),无法减少数值所占用的空间。因此,采用Zigzag 编码先将负数映射为无符号正数。然后采用 Varints 编码进行压缩。对于一个 n 位的数字来说,先对原数字逻辑左移 1 位,再对源数字算数右 移n - 1 位,将得到的逻辑移位和算数移位的结果按位异或,得到Zigzag编码。其计算方式为:
(X << 1) ^ (X >> (n - 1))
3.4.3、数据组织
序列化后的 protobuf 不使用字段名,只使用字段编号来标识一个字段,因此改变 proto 字段名不会影响数据解析,字段编号会被编码进二进制的消息结构中,所以频繁出现的消息元素应尽可能使用小字段编号。
相较于完全自描述的 json, xml等协议格式,即拿到到消息体,就可以知道字段和字段值分别是什么。protobuf 不是⼀种完全自描述的协议格式,接收端需要有相应的解码器(proto 文件定义)才能解码 protobuf 消息体的。接收对于通信双方来说,约定好了消息格式,没有必要在每条消息中都携带字段名称,移除这些字段,可以降低消息的长度,提高通信效率。
目前 protobuf 在序列化之后的消息类型除去已经 deprecated 的,总共有 4 种,
Type | Meaning | Used For |
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
5 | 32-bit | fixed32, sfixed32, fl |
protobuf 是一种紧密的消息结构,编码后字段间没有间隔,编码长度短,传输效率高。每个字段头由两部分组成:字段编号和字段类型 wire type,字段头可确定数据段的长度,因此字段间无需加入分隔符。
字段头的具体存储方式为:
field_num << 3 | wire type
将字段编号逻辑左移 3 位, 然后与该字段类型按位或。由于字段类型共有 6 种,因此可以用 3 位二进制来标识,所以低 3 位存储数据的 wire type。接收端利用这些信息,结合 proto 文件来解码消息结构体。
3.5、实例
例1:Google 的测试用例 - 电话簿
第一步:编写 .proto 文件
字段修饰符有两种形式
- singular:消息中该字段有0个或者1个,默认字段修饰符。bug: 显示写上该关键字报错
- repeated:消息中该字段可以重复任意次(包括0次),重复的值的顺序会被保留。
// addressbook.proto syntax = "proto3"; // 语法版本 package tutorial; // 包的名称 import "google/protobuf/timestamp.proto"; // 导入包 // 定义消息类型:每个字段包括: 字段类型 + 字段名 + 字段编号 // 序列化后的 protobuf 不保存字段名,只保留字段编号和字段类型 message Person { string name = 1; int32 id = 2; string email = 3; // 嵌套消息类型 enum PhoneType { MOBILE = 0; HOME = 1; WORK = 2; } message PhoneNumber { string number = 1; PhoneType type = 2; } // 字段修饰符 repeated,该字段可以重复任意次 0 ~ n,一个人可以拥有多个电话 repeated PhoneNumber phones = 4; google.protobuf.Timestamp last_updated = 5; } // Our address book file is just one of these. message AddressBook { // 字段修饰符 repeated,该字段可以重复任意次 0 ~ n,电话号码可以有多位 repeated Person people = 1; }
第二步:将 .proto 文件生成对应的 .cc 和 .h文件
# 将当前目录下的所有 proto ⽂件⽣成 .pb.cc 和 .pb.h protoc -I=./ --cpp_out=./ *.proto
可以看到本地生成了 http://addressbook.pb.cc 和 addressbook.pb.h 两个文件。
第三步:编译
# 编译 g++ -std=c++11 -o add_person add_person.cc addressbook.pb.cc -lprotobuf -lpthread g++ -std=c++11 -o list_people list_people.cc addressbook.pb.cc -lprotobuf -lpthread # 测试 ./add_person book ./list_people book
代码实现中可以通过 Google 内置的 api 接口对 proto 文件中定义的消息类型进行访问。