应用层协议设计 ProtoBuf

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
全局流量管理 GTM,标准版 1个月
简介: 应用层协议设计 ProtoBuf

通信协议设计核心

  • 解析效率
  • 可扩展可升级

协议设计细节

  • 帧完整性判断
  • 序列化和反序列化
  • 协议升级
  • 协议安全
  • 数据压缩

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 文件中定义的消息类型进行访问。

相关文章
|
7月前
|
存储 XML JSON
应用层协议设计ProtoBuf/Thrift
应用层协议设计ProtoBuf/Thrift
65 0
|
3月前
|
安全 网络协议 网络安全
应用层常见的协议有哪些?
应用层常见的协议有哪些?
363 1
|
6月前
|
网络协议 IDE 开发工具
TCP通信协议及代码细节
TCP通信协议及代码细节
37 0
|
7月前
|
存储 负载均衡 API
跨语言的GRPC协议
【2月更文挑战第11天】
|
7月前
|
存储 XML JSON
Protobuf应用层协议设计
Protobuf应用层协议设计
107 0
|
存储 XML JSON
高效的网络传输协议设计protobuf
高效的网络传输协议设计protobuf
143 1
|
存储 XML JSON
应用层协议设计及ProtoBuf
应用层协议设计及ProtoBuf
113 0
|
网络协议 Python
【从零学习python 】75. TCP协议:可靠的面向连接的传输层通信协议
【从零学习python 】75. TCP协议:可靠的面向连接的传输层通信协议
115 0
|
存储 网络协议 Dubbo
如何设计可向后兼容的RPC协议
HTTP协议(本文HTTP默认1.X)跟RPC协议又有什么关系呢?都属于应用层协议。
129 0