应用层协议设计 ProtoBuf

本文详细介绍了通信协议设计的关键要素,包括消息的完整性判断、序列化方法、协议安全、数据压缩及协议升级等内容,并通过nginx、redis及即时通信协议实例进行了具体说明。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

通信协议设计核心

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

协议设计细节

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

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 协议

redis 协议设计,

  • 消息边界:字符流头部 + 分隔符
  • 版本区分:?
  • 消息类型:字符串的第一个字符。

redis 采用 RESP 序列化协议,协议的不同部分使用以CRLF(\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>
    

RESP 在 redis 请求-响应协议中的作用方式

  • 客户端发送字符串数组 ( Array + Bulk Strings) 到 redis 服务器

    *<参数数量>\r\n$<参数1的长度>\r\n<参数1的数据>\r\n...$<参数n的长度>\r\n<参数n的数据>\r\n
    
  • redis 服务器根据命令实现回复一种 RESP 数据类型到客户端。

来看下面例子

在 redis-cli,发送一条命令 set key value,对应的报文为:

*3\r\n$3\r\nset\r\n$3\r\nkey\r\n$5\r\nvalue

执行成功 OK,回应的报文为:

+OK\r\n

若执行失败,回应的报文为

-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、工作流程

在这里插入图片描述

接口描述语言 IDL,Interface description language

可以看到,对于序列化协议来说,使用方只需要关注业务对象本身,即 idl 定义 (.proto),序列化和反序列化的代码只需要通过工具生成即可。

使用 protobuf 的方法

  • 编写 proto 文件

  • 调用 proto 文件,将 proto 文件生成对应的 .pb.cc 和 .pb.h 文件,让程序去调用

    protoc -I=/.proto文件路径 --cpp_out=./.cc和.h生成路径 .proto文件路径
    
  • 编译:-lprotobuf

3.3、标量数值类型

在这里插入图片描述

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 >> 31) 
逆向:(n >> 1) ^ -(n & 1) 
3.4.3、数据组织

序列化后的 protobuf 不使用字段名,只使用字段编号来标识一个字段,因此改变 proto 字段名不会影响数据解析,字段编号会被编码进二进制的消息结构中,所以频繁出现的消息元素应尽可能使用小字段编号。

相较于完全自描述的 json, xml等协议格式,即拿到到消息体,就可以知道字段和字段值分别是什么。protobuf 不是⼀种完全自描述的协议格式,接收端需要有相应的解码器(proto 文件定义)才能解码 protobuf 消息体的。接收对于通信双方来说,约定好了消息格式,没有必要在每条消息中都携带字段名称,移除这些字段,可以降低消息的长度,提高通信效率。

目前 protobuf 在序列化之后的消息类型除去已经 deprecated 的,总共有 4 种,

TypeMeaningUsed For
0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bitfixed64, sfixed64, double
2Length-delimitedstring, bytes, embedded messages, packed repeated fields
532-bitfixed32, 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

可以看到本地生成了 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 文件中定义的消息类型进行访问。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值