1. 数据存储
数据采用TagLengthValue方式存储, 一条数据在protobuf二进制中的存储分为三段: Tag(标签), Length(长度), Value(数值).
每段最短都占用一个byte, 也能够每段占用多个byte, 在protobuf中每个byte最高位是一个控制位, 用来表示后续byte是否和自己属于同一个字段.
1.1. Tag(标签)字段
其中Tag(标签)存储了数据类型以及数据编号, 其编码格式为控制位 | (编号 << 3) | 类型
.
例如 0 | 0001 | 010
表示:
- 0: 后续byte和自己不属于同一个字段
- 1(0001): 数据编号为1
- 3(010): 数据类型为3
或者也可能为 1 | 0010 | 010 00000001
表示:
- 1: 后续byte和自己属于同一个字段 (后续byte就是指的
00000001
) - 18(00000001 0010): 数据编号为18 (因为pb是小端法, 要换成大端法表示)
- 3(010): 数据类型为3
pb定义的数据类型种类如下:
1.2. Length(长度)字段以及Value(数值)字段
然后Length(长度)字段以及Value(数值)字段一样的, 开头是控制位, 置1就表示后续byte和自己是一个字段, 这样就能用多个byte表示一个字段.
1.3. 优化措施: Varint, ZigZag
其中, pb为了优化编码后的二进制长度, 还引入了Varint以及ZigZag方式进行压缩.
对于Varint编码, 其能够有效压缩小整数, 但是无法处理大整数以及负数.
方案先利用ZigZag将所有整数映射成无符号整数, 然后再采用Varint编码方式编码.
1.3.1. ZigZag
ZigZag的基本思想是负数和正数进行交错编码, 使得负数映射为奇数.
处理为 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3
.
这样的好处是: 无论正负, 数值的绝对大小都能较为紧凑地表示.
具体的映射方式如下:
(n << 1) ^ (n >> 31) // 32 bit
(n << 1) ^ (n >> 63) // 64 bit
启用方式要在proto文件定义中采用sintN类型, 比如sint32, sint64.
当负数比较多的情况下就应该采用sinN类型进行定义声明.
1.3.2. Varint
和pb的开头控制位类似, Varint的最高位也是控制位, 表示后续byte是否属于这个数值的编码.
每个字节剩余的七位则用于表示实际的数字(对于小整数能够压缩, 但是大整数反而会多消耗).
作用是省略掉编码中纯0的byte, 比如int32存储数值1, 前三个byte就全是0, 就能够省略.
举个例子:
10010110 00000001 // pb的原始二进制字节流
// 10010110 开头的 1 说明后面字节 00000001 也是编码的一部分
0010110 0000001 // 丢弃每个byte开头第一位的信息位
0000001 0010110 // 由于 varint 编码时采用小端法,我们需要将其调换顺序转换为大端法
00000010010110 // 链接有效载荷
128 + 16 + 4 + 2 = 150 // 解释为无符号 64 位整数
2. 简介
protobuf是Google提出的一种数据交换的格式,是一套类似JSON或者XML的数据传输格式和规范,用于不同应用或进程之间进行通信。
特点如下:
- 语言无关,平台无关;Protobuf支持Java、 C++,、Python、JavaScript等多种语言,支持跨多个平台。
- 高效;比XML更小(3~10倍),更快(20 ~ 100倍),更为简单。
- 扩展性,兼容性好;可以更新数据结构,而不影响和破坏原有的旧程序。
Protobuf数据包是一种二进制的格式,相对于文本格式的数据交换(JSON、XML)来说,速度要快很多。
Protobuf的编码过程为:使用预先定义的Message数据结构将实际的传输数据进行打包,然后编码成二进制的码流进行传输或者存储。
Protobuf的解码过程则刚好与编码过程相反:将二进制码流解码成Protobuf自己定义的Message结构。
protobuf的功能是序列化&反序列化, 属于应用层(表示层).
3. 安装protoc
3.1. Protocol Buffers v21.12([protobuf-3.21.12)版本
protobuf编译、安装和简单使用C++ (Windows+VS平台) - WindSun - 博客园
win10系统下使用mingw编译protobuf,并且在vscode中使用cmake配置应用_cmake编译protobuf时 vscode选什么-CSDN博客
下载: https://github.com/protocolbuffers/protobuf/releases/download/v21.12/protobuf-cpp-3.21.12.tar.gz
./autogen.sh
./configure
make && make install
sudo ldconfig /usr/local/lib /usr/local/lib64
protoc --version
3.2. 最新版(c++编译报错)
github发布页Release Protocol Buffers v25.3 · protocolbuffers/protobuf (github.com)
其他语言可以直接安装二进制文件到PATH中, 但是c++需要编译安装: protobuf/src/README.md 位于 main · protocolbuffers/protobuf --- protobuf/src/README.md at main · protocolbuffers/protobuf (github.com)
要从源代码构建 protobuf,需要以下工具:
- bazel
- git
- g++
centos下需要升级g++,使用较新的g++编译器:
yum install devtoolset-8-gcc devtoolset-8-gcc-c++
echo "source /opt/rh/devtoolset-8/enable" >> /etc/bashrc
source /etc/bashrc
bazel安装可以参考官方给的脚本:Release 7.0.2 · bazelbuild/bazel (github.com)
获取源代码,请在发布页面下载发布.tar.gz或.zip包:
https://github.com/protocolbuffers/protobuf/releases/latest
构建 C++ Protocol Buffer 运行时和 Protocol Buffer 编译器 (protoc),请执行以下命令:
bazel build :protoc :protobuf
然后可以安装编译器,例如在 Linux 上:
cp bazel-bin/protoc /usr/local/bin
可以成功安装, 但是c++编译报错:
Person.pb.h:13:10: fatal error: google/protobuf/port_def.inc: No such file or directory
#include "google/protobuf/port_def.inc"
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.
4. 编译Protocol Buffers
-
确定数据格式,比如:
struct Person { int id; string name; string sex; int age; };
-
根据protobuf的语法, 编辑.proto文件
// Person.proto syntax = "proto3"; // 在该文件中对要序列化的结构体进行描述 message Person { int32 id = 1; repeated bytes name = 2; bytes sex = 3; int32 age = 4; }
-
使用 protoc 命令将 .proto 文件转化为相应的 C++ 文件
$ protoc -I /path/Person.proto --cpp_out=输出路径(存储生成的c++文件)
- 源文件: xxx.pb.cc –> xxx对应的名字和 .proto文件名相同
- 头文件: xxx.pb.h –> xxx对应的名字和 .proto文件名相同
- 在 protoc 命令中,
-I
参数后面可以跟随一个或多个路径,用于告诉编译器在哪些路径下查找导入的文件或依赖的文件,使用绝对路径或相对路径都是没问题的。protoc -I path1 -I path2
或protoc -I path1:path2
都表示告诉编译器在 path1 和 path2 路径下查找导入的文件或依赖的文件。
-
需要将生成的c++文件添加到项目中, 通过文件中提供的类 API 实现数据的序列化/反序列化
-
编译cpp文件:
g++ MyTest.cpp Person.pb.cc -o MyTest -lprotobuf -std=c++11 -lpthread
5. proto文件
Protobuf使用proto文件来预先定义的消息格式。数据包是按照proto文件所定义的消息格式完成二进制码流的编码和解码。proto文件简单地说,就是一个消息的协议文件,这个协议文件的后缀文件名为“.proto”。
// [开始头部声明]
syntax = "proto3";
package tutorial;
// [结束头部声明]
// [开始 java 选项配置] (c++不需要定义包)
option java_package = "tutorial"; // 生成类的包名
option java_outer_classname = "MsgProtos"; // 生成类的类名
// [结束 java 选项配置]
// [开始消息定义]
message Msg {
optional uint32 id = 1; //消息 ID
optional string content = 2 [default = "hello world"];//消息内容
}
// [结束消息定义]
optional
:该字段可以设置也可以不设置。如果未设置可选字段值,则使用默认值。对于简单类型,您可以指定自己的默认值,就像我们在示例中为电话号码type
所做的那样。否则,使用系统默认值:数字类型为零,字符串为空字符串,布尔值为 false。对于嵌入消息,默认值始终是消息的“默认实例”或“原型”,其未设置任何字段。调用访问器来获取尚未显式设置的可选(或必填)字段的值始终返回该字段的默认值。repeated
:该字段可以重复任意次数(包括零次)。重复值的顺序将保留在协议缓冲区中。将重复字段视为动态大小的数组。required
:必须提供该字段的值,否则消息将被视为“未初始化”。如果 libprotobuf 在调试模式下编译,序列化未初始化的消息将导致断言失败。在优化版本中,会跳过检查,并且无论如何都会写入消息。但是,解析未初始化的消息总是会失败(通过从解析方法返回 false )。除此之外,必填字段的行为与可选字段完全相同。required
字段受到强烈反对;proto2 语法中定义的大多数消息仅使用optional
和repeated
(Proto3 根本不支持required
字段。)
5.1. 枚举类型
// 要序列化的数据
// 枚举
enum Color
{
Red = 5, // 可以不给初始值, 默认为0
Green,
Yellow,
Blue
};
// 要序列化的数据
struct Person
{
int id;
string name[10];
string sex;
int age;
// 枚举类型
Color color;
};
—>.proto:
// 定义枚举类型
enum Color
{
Red = 0;
Green = 3; // 第一个元素以外的元素值可以随意指定
Yellow = 6;
Blue = 9;
}
// 在该文件中对要序列化的结构体进行描述
message Person
{
int32 id = 1;
repeated bytes name = 2;
bytes sex = 3;
int32 age = 4;
// 枚举类型
Color color = 5;
}
proto3 中的第一个枚举值必须为 0,第一个元素以外的元素值可以随意指定。
5.2. proto的引用导入
在 Protocol Buffers 中,可以使用import语句在当前.ptoto中导入其它的.proto文件。这样就可以在一个.proto文件中引用并使用其它文件中定义的消息类型和枚举类型。
syntax = "proto3";
// 使用另外一个proto文件中的数类型, 需要导入这个文件
import "Address.proto";
// 在该文件中对要序列化的结构体进行描述
// 定义枚举类型
enum Color
{
Red = 0;
Green = 3; // 第一个元素以外的元素值可以随意指定
Yellow = 6;
Blue = 9;
}
// 在该文件中对要序列化的结构体进行描述
message Person
{
int32 id = 1;
repeated bytes name = 2;
bytes sex = 3;
int32 age = 4;
// 枚举类型
Color color = 5;
// 添加地址信息, 使用的是外部proto文件中定义的数据类型
Address addr = 6;
}
-
import语句中指定的文件路径可以是相对路径或绝对路径。如果文件在相同的目录中,只需指定文件名即可。
-
导入的文件需要会在编译时与当前文件一起被编译
protoc A.proto B.proto --cpp_out=.
。 -
导入的文件也可以继续导入其他文件,形成一个文件依赖的层次结构。
-
c++的api调用引用文件时格式为
mutable_addr()->set_addr("北京")
, 即mutable_消息名()->同理
5.3. 包(package)
在 Protobuf 中,可以使用package关键字来定义一个消息所属的包(package)。包是用于组织和命名消息类型的一种机制,类似于命名空间的概念。
使用包可以避免不同.proto文件中的消息类型名称冲突,同时也可以更好地组织和管理大型项目中的消息定义。可以将消息类型的名称定义在特定的包中,并使用限定名来引用这些类型。
下面有两个proto文件,分别给他们添加一个package:
-
proto文件 - Address.proto:
syntax = "proto3"; // 添加命名空间 Dabing package Dabing; // 地址信息, 这个Address类属于命名空间: Dabing message Address { bytes addr = 1; bytes number = 2; }
-
proto文件 - Person.proto:
syntax = "proto3"; // 使用另外一个proto文件中的数类型, 需要导入这个文件 import "Address.proto"; // 指定命名空间 ErBing package ErBing; // 以下的类 Person 属于命名空间 ErBing, 在该文件中对要序列化的结构体进行描述 message Person { int32 id = 1; repeated bytes name = 2; bytes sex = 3; int32 age = 4; // 添加地址信息, 使用的是外部proto文件中定义的数据类型 // 如果这个外边类型属于某个命名空间, 语法格式: Dabing.Address addr = 6; }
6. 序列化和反序列化
6.1. xxx.pb.h 头文件
通过protoc 命令对.proto文件的转换,得到的头文件中有一个类,这个类的名字和 .proto文件中message关键字后边指定的名字相同,.proto文件中message消息体的成员就是生成的类的私有成员。
调用生成的类提供的公共成员函数访问生成的类的私有成员,这些函数格式如下:
- 清空(初始化) 私有成员的值:
clear_变量名()
- 获取类私有成员的值:
变量名()
- 给私有成员进行值的设置:
set_变量名(参数)
- 得到类私有成员的地址, 通过这块地址读/写当前私有成员变量的值:
mutable_变量名()
- 如果这个变量是数组类型:
- 数组中元素的个数:
变量名_size()
- 添加一块内存, 存储新的元素数据:
add_变量名()
、add_变量名(参数)
- 数组中元素的个数:
6.2. 序列化
序列化是指将数据结构或对象转换为可以在储存或传输中使用的二进制格式的过程。在计算机科学中,序列化通常用于将内存中的对象持久化存储到磁盘上,或者在分布式系统中进行数据传输和通信。
Protobuf 中为我们提供了相关的用于数据序列化的 API,如下所示:
// 头文件目录: google\protobuf\message_lite.h
// --- 将序列化的数据 数据保存到内存中
// 将类对象中的数据序列化为字符串, c++ 风格的字符串, 参数是一个传出参数
bool SerializeToString(std::string* output) const;
// 将类对象中的数据序列化为字符串, c 风格的字符串, 参数 data 是一个传出参数
bool SerializeToArray(void* data, int size) const;
// ------ 写磁盘文件, 只需要调用这个函数, 数据自动被写入到磁盘文件中
// -- 需要提供流对象/文件描述符关联一个磁盘文件
// 将数据序列化写入到磁盘文件中, c++ 风格
// ostream 子类 ofstream -> 写文件
bool SerializeToOstream(std::ostream* output) const;
// 将数据序列化写入到磁盘文件中, c 风格
bool SerializeToFileDescriptor(int file_descriptor) const;
6.3. 反序列化
反序列化是指将序列化后的二进制数据重新转换为原始的数据结构或对象的过程。通过反序列化,我们可以将之前序列化的数据重新还原为其原始的形式,以便进行数据的读取、操作和处理。
Protobuf 中为我们提供了相关的用于数据序列化的 API,如下所示:
// 头文件目录: google\protobuf\message_lite.h
bool ParseFromString(const std::string& data) ;
bool ParseFromArray(const void* data, int size);
// istream -> 子类 ifstream -> 读操作
// wo ri
// w->写 o: ofstream , r->读 i: ifstream
bool ParseFromIstream(std::istream* input);
bool ParseFromFileDescriptor(int file_descriptor);
7. 反射机制
通过.proto文件实现.
.proto文件里存储了元信息, 也就是数据名称, 类型, 以及嵌套关系之类的信息.
xxx.pb.cc里存储了序列化处理后的.proto内容, 以硬编码的方式保存.
硬编码的.proto元信息内容以懒加载的方式被DescriptorDatabase加载, 解析, 并缓存到DescriptorPool中.
通过反射机制, 可以自动遍历.proto中定义的数据. 这样代码编写更容易, 同时灵活性更高, 更新.proto内容后, 代码也不需要修改.