protobuf —— 快速上手
- 创建 .proto 文件
- 添加注释
- 指定proto3语法
- package 声明符
- 定义消息(message)
- 定义消息字段
- 字段定义基本格式
- 字段名称命名规范
- 字段类型
- 字段唯一编号
- 示例
- 转换关系
- 示例:增加姓名和年龄字段
- 字段唯一编号
- 字段编号范围
- 编码效率
- 实践指导
- 编译 contacts.proto 文件,生成 C++ 文件
- 编译命令
- 编译 contacts.proto 文件后会生成什么
- 序列化注意事项
- 序列化和反序列化
我们今天来快速上手protobuf的用法,我们会用一个简单的通讯录来进行讲解:
如果还没有装好protobuf的小伙伴可以点击这里:
https://blog.csdn.net/qq_67693066/article/details/139215057
如果大家使用Linux的话,并且对版本没有太多要求的,可以直接用Linux的软件包来安装,Centos和Ubuntu都可以,我这里以Centos为例:
Ubuntu就换成apt,安装的版本是14的,也是21年发布的版本,也不错。
创建 .proto 文件
装好protobuf之后,我们用vscode连接,大家可以下载一个支持protobuf语法的插件,我下的是这一个:
然后创建一个后缀为.proto的文件:
创建 .proto 文件时,文件命名应该使用全小写字母命名,多个字母之间用 _ 连接。 例如:lower_snake_case.proto
添加注释
向文件添加注释,可使使用 // 或者 /* ... */
指定proto3语法
Protocol Buffers 语言版本3,简称 proto3,是 .proto
文件的最新语法版本。Proto3 简化了 Protocol Buffers 语言,既易于使用,又能在更多编程语言中运用自如。它支持使用 Java、C++、Python 等多种语言生成 Protocol Buffer 代码。
在 .proto
文件里,需使用 syntax = "proto3";
来指定文件遵循 proto3 语法,这条声明必须位于除去注释内容的首行。如果未作指定,编译器将默认采用 proto2 语法。
例如,在通讯录 1.0 的 contacts.proto
文件中,指定使用 proto3 语法的方式如下所示:
package 声明符
在 Protocol Buffers(protobuf)的 .proto
文件中,package
是一个可选但强烈推荐的声明语句。它扮演着命名空间的角色,用于组织和分隔不同的消息类型定义,确保即便在导入多个 .proto
文件到同一个项目时,消息类型之间也不会发生名称冲突。简而言之,通过指定独一无二的包名,可以有效避免定义的消息类型重名问题。
以“通讯录 1.0”项目的文件为例,为了明确该文件中消息类型的所属命名空间,可以在文件开头添加如下的 package 声明:
定义消息(message)
在分布式系统和网络通信中,消息(message
)扮演着至关重要的角色,它是定义数据交换格式的基础。为何需要定义消息? 主要原因有二:
- 协议定制:网络通信依赖于双方遵守的协议,这些协议定义了数据如何封装和解析。通过在
.proto
文件中定义消息,您可以精确地描述数据的结构,包括字段类型、名称及其顺序。这样,Protocol Buffers工具就能自动生成对应语言的源代码,实现序列化(将数据结构转换为字节流以便网络传输)和反序列化(将接收到的字节流还原为数据结构)功能。比如TCP/IP协议中的报文头和数据部分,就是一个典型的结构化数据示例。
- 数据持久化:当数据需要被存储到诸如数据库这类持久化存储介质时,清晰、统一的数据结构变得尤为重要。消息定义不仅帮助组织数据,还能确保数据的一致性和高效存储。通过将数据封装在消息中,可以更容易地映射到数据库的表结构或文档模型中,简化数据的存取逻辑。
以“通讯录1.0”为例,为“联系人”创建一个消息定义,就是预先规划好联系人信息的结构,比如姓名、电话号码、电子邮箱等,使得在实际编码时,可以直接利用Protocol Buffers生成的类来操作这些数据,无需手动处理序列化和解析的细节。这样,无论是网络间的数据交换,还是数据库的存储读取,都能保持高效和一致。
.proto 文件中定义⼀个消息类型的格式为:
message 消息类型名{
}
简单来说,message有点像C++中的class进行属性的封装。
定义消息字段
字段定义基本格式
字段定义遵循以下基本格式:
字段类型 字段名 = 字段唯一编号;
字段名称命名规范
- 全小写字母:字段名应全部使用小写字母,以保持一致性并符合protobuf的命名约定。
- 使用下划线
_
连接:如果字段名由多个单词组成,单词间应使用下划线分隔,提高可读性。例如,first_name
而非firstName
。
字段类型
字段类型分为标量数据类型和特殊类型两大类:
-
标量数据类型包括但不限于:
int32
,int64
: 整型float
,double
: 浮点数bool
: 布尔值string
: 字符串bytes
: 二进制数据
-
特殊类型:
- 枚举(enum): 自定义的枚举类型,用于限定某个字段的取值范围。
- 其他消息类型: 引用其他已定义的消息类型作为字段类型,实现复杂数据结构的嵌套。
字段唯一编号
- 编号要求:每个字段都应分配一个唯一的整数编号,用于在序列化和反序列化过程中识别字段。
- 编号范围:通常情况下,编号1到15的字段在编码时较为节省空间,因为它们可以用一个字节编码(如果字段没有被省略)。16及以上的编号需要更多的字节来编码。
- 不可变更性:一旦消息发布并被使用,字段编号就不应该被修改或重新分配,因为这将破坏与旧版本的兼容性。新增字段应分配新的编号,而避免更改现有字段编号。
示例
下面是一个简单的Person
消息定义示例:
syntax = "proto3";message Person {int32 id = 1; // 唯一IDstring first_name = 2; // 名string last_name = 3; // 姓int32 age = 4; // 年龄bool is_student = 5; // 是否为学生
}
在这个例子中,Person
消息包含了几个基本的标量数据类型的字段,并且每个字段都分配了一个唯一的编号。
转换关系
这个表格更清晰地展示.proto
文件中定义的消息字段类型及其与C++语言类型的对应关系:
.proto Type | C++ Type | Notes |
---|---|---|
double | double | - |
float | float | - |
int32 | int32_t | 使用变长编码[1]。负数编码效率较低,如果字段可能为负值,建议使用sint32 。 |
int64 | int64_t | 使用变长编码[1]。负数编码效率较低,如果字段可能为负值,建议使用sint64 。 |
uint32 | uint32_t | 使用变长编码[1]。 |
uint64 | uint64_t | 使用变长编码[1]。 |
sint32 | int32_t | 使用变长编码[1]。符号整型,负值编码效率高于常规int32 。 |
sint64 | int64_t | 使用变长编码[1]。符号整型,负值编码效率高于常规int64 。 |
fixed32 | uint32_t | 定长4字节。如果值通常大于2^28,则比uint32 更高效。 |
fixed64 | uint64_t | 定长8字节。如果值通常大于2^56,则比uint64 更高效。 |
sfixed32 | int32_t | 定长4字节。 |
sfixed64 | int64_t | 定长8字节。 |
bool | bool | - |
string | std::string | 包含UTF-8和ASCII编码的字符串,长度不超过2^32。 |
bytes | std::string | 可包含任意字节序列,长度不超过2^32。 |
[1] 变长编码指的是Protocol Buffers使用一种可变长度的编码方式(如VarInt编码),这种编码方式对于较小数值占用空间较少,但大数值会占用更多字节。对于fixed32
、fixed64
、sfixed32
和sfixed64
,它们使用固定长度编码,不论数值大小,始终占用指定的字节数。
示例:增加姓名和年龄字段
假设我们要在一个消息中增加name
(字符串类型)和age
(假设为非负整数,使用uint32
类型)字段,那么在.proto
文件中的定义可能是这样的:
message Person {string name = 1; // 姓名uint32 age = 2; // 年龄
}
这里name
字段使用了字符串类型(对应C++中的std::string
),age
字段使用了无符号32位整型(对应C++中的uint32_t
),并为每个字段分配了唯一的编号。
字段唯一编号
这里还要说明一下字段唯一编号:
在Protocol Buffers(protobuf)中,字段唯一编号的选取是一个关键设计决策,因为它直接影响到消息的序列化效率和兼容性。下面是关于字段编号范围及编码的一些关键点:
字段编号范围
字段编号的有效范围是从1到536,870,911(即2^29 - 1)。但是,需要注意的是,19000至19999这一区间内的编号是被protobuf协议实现预留的,不允许用户直接使用。尝试使用这些预留编号会导致编译时错误,提醒开发者这些编号不可用,因为它们在protobuf内部有特殊的用途。
编码效率
- 编号与编码大小:为了优化序列化后的消息体积,protobuf采用了变长编码。其中,编号小于等于15的字段仅需1个字节来编码编号和类型信息;编号在16至2047之间的字段需要2个字节;更大的编号则需要更多的字节。这意味着,编号较小的字段在序列化时更为高效。
- 优化建议:鉴于此,建议将频繁出现且长度较短的字段分配编号在1至15之间,以减少消息的整体大小。同时,应保留一部分低编号以备未来可能添加的常用字段。
实践指导
- 避免预留区间:确保在定义
.proto
文件时,字段编号避开19000至19999这个预留区间。- 编号策略:合理规划字段编号,考虑当前及未来的扩展性。频繁使用的字段应给予更低的编号以优化效率,同时留出一定编号空间供后续扩展使用。
通过遵循上述原则,可以确保你的protobuf消息定义既高效又具有良好的向前兼容性,便于维护和升级。
编译 contacts.proto 文件,生成 C++ 文件
编译命令
protoc [--proto_path=IMPORT_PATH] --cpp_out=DST_DIR path/to/file.proto
protoc
是 Protocol Buffers(protobuf)的编译器,用于将 .proto
文件转换为目标编程语言的源代码文件。您给命令行参数示例中,各部分的含义如下:
--proto_path=IMPORT_PATH
或-I=IMPORT_PATH
:
- 作用:指定
.proto
文件的搜索路径。当你在.proto
文件中使用import
语句引入其他.proto
文件时,protoc
会根据这个路径去查找这些被引用的文件。- 参数说明:
IMPORT_PATH
是一个或多个目录路径,可以多次使用该选项指定多个路径。如果不指定,默认会在当前目录下查找。
--cpp_out=DST_DIR
:
- 作用:指定编译生成的C++源代码输出目录。当您希望将
.proto
文件编译为C++代码时,使用这个选项。- 参数说明:
DST_DIR
是您希望输出C++源代码的目录路径。protoc
会在这个目录下生成对应的.pb.cc
(源文件)和.pb.h
(头文件),这些文件包含了序列化和反序列化消息所需的函数和其他必要的代码。
path/to/file.proto
:
- 作用:指定需要编译的
.proto
文件路径。- 说明:这是您要编译的protobuf定义文件的完整或相对路径。
protoc
会读取这个文件并根据指定的输出选项(如--cpp_out
)生成目标语言的代码。
综上,整个命令的含义是:使用 protoc
编译器,从指定的导入路径中查找任何被导入的 .proto
文件,然后将 path/to/file.proto
文件编译为C++源代码,并将生成的文件输出到 DST_DIR
目录下。这样做的目的是为了让开发者能够方便地将protobuf消息类型集成到他们的C++项目中。
举个例子
当然,让我们通过一个具体的例子来进一步说明如何使用这些参数。假设你有一个项目结构如下:
/my_project/
|-- protos/
| |-- message.proto
|-- src/
|-- CMakeLists.txt
|-- main.cpp
在这个场景中,你有一个名为 message.proto
的 Protocol Buffers 定义文件,位于 protos/
目录下,你想将它编译成C++代码,并将生成的文件放在 src/
目录中以便在你的C++项目中使用。
你可以打开终端(命令行界面),并导航到 /my_project/
目录,然后执行以下命令:
protoc --proto_path=./protos --cpp_out=./src ./protos/message.proto//或者
protoc -I=./protos --cpp_out=./src ./protos/message.proto
这里发生了什么:
--proto_path=./protos
指定了.proto
文件的搜索路径为当前目录下的protos/
目录。如果有message.proto
中import
了其他.proto
文件,编译器会在这个目录下查找它们。--cpp_out=./src
指定编译生成的C++源代码放置在src/
目录中。./protos/message.proto
是你要编译的具体.proto
文件路径。
执行完这个命令后, message.proto
定义了一个消息类型,protoc
编译器将在 src/
目录下生成两个文件:message.pb.cc
和 message.pb.h
,这些就是你可以包含在C++项目中使用的源代码和头文件,用于处理该消息类型的序列化与反序列化等操作。
编译 contacts.proto 文件后会生成什么
编译 contacts.proto 文件后,会生成所选择语言的代码,我们选择的是C++,所以编译后生成了两个文件: contacts.pb.h contacts.pb.cc 。
对于编译生成的 C++ 代码,包含了以下内容 :
- 对于每个 message ,都会生成⼀个对应的消息类。
- 在消息类中,编译器为每个字段提供了获取和设置⽅法,以及⼀下其他能够操作字段的方法。
- 编辑器会针对于每个 .proto 文件生成 .h 和 .cc 文件,分别用来存放类的声明与类的实现。
contacts.pb.h 部分代码展示:
// string name = 1;void clear_name();const std::string& name() const;template <typename ArgT0 = const std::string&, typename... ArgT>void set_name(ArgT0&& arg0, ArgT... args);std::string* mutable_name();PROTOBUF_NODISCARD std::string* release_name();void set_allocated_name(std::string* name);private:const std::string& _internal_name() const;inline PROTOBUF_ALWAYS_INLINE void _internal_set_name(const std::string& value);std::string* _internal_mutable_name();public:// int32 age = 2;void clear_age();int32_t age() const;void set_age(int32_t value);private:int32_t _internal_age() const;void _internal_set_age(int32_t value);public:
上述的例子中:
- 每个字段都有设置和获取的方法, getter 的名称与小写字段完全相同,setter 方法以 set_ 开头。
- 每个字段都有⼀个 clear_ 方法,可以将字段重新设置回 empty 状态。
contacts.pb.cc 中的代码就是对类声明方法的⼀些实现,在这里就不展开了。
序列化和反序列化方法在哪里呢?在消息类的父类MessageLite 中,提供了读写消息实例的方法,包括序列化方法和反序列化方法:
bool MergeFromCodedStream(io::CodedInputStream* input);bool ParseFromCodedStream(io::CodedInputStream* input);bool ParseFromZeroCopyStream(io::ZeroCopyInputStream* input);bool ParseFromArray(const void* data, int size);inline bool ParseFromString(const std::string& data) {return ParseFromArray(data.data(), static_cast<int>(data.size()));}// Merges this message's unknown field data (if any). This works whether// the message is a lite or full proto (for legacy reasons, lite and full// return different types for MessageType::unknown_fields()).template <typename MessageType>bool MergeFromMessage(const MessageType& message);// Serialization.bool SerializeToString(std::string* output) const;bool SerializeToCodedStream(io::CodedOutputStream* output) const;static const UnknownFieldSet& default_instance();
理解您的要求,这里是对于序列化概念及Protocol Buffers(protobuf)中消息序列化方法的解释,稍作调整以供参考:
序列化注意事项
- 二进制输出:序列化过程将结构化数据转换成紧凑的二进制形式,而非易于阅读的文本格式。这有助于减小程序体积,提高网络传输效率和存储效率。
- 多样化的序列化方法:Protocol Buffers提供了多种序列化API,包括但不限于
SerializeToString()
、SerializeToOstream()
和SerializeToArray()
等。这些方法虽然输出形式各异——字符串、输出流或字节数组,但核心目的相同:将消息对象转化为二进制数据,适应不同应用场景的需要。- 不变性保证:序列化操作通过const成员函数实现,意味着调用序列化函数不会修改消息对象本身的内在状态。数据的转换发生在序列化过程中,并将结果输出到指定的目标(如内存、字符串或流),而不影响原始对象。
- 深入探索API:欲了解更多关于protobuf消息对象的序列化及其他功能,可查阅protobuf官方文档中的消息API完整列表,那里详尽地介绍了每种方法的使用方法和适用场景,帮助开发者高效利用protobuf的强大功能。
序列化和反序列化
创建⼀个测试文件 main.cc,方法中我们实现:
- 对⼀个联系人的信息使用 PB 进行序列化,并将结果打印出来。
- 对序列化后的内容使用 PB 进行反序列,解析出联系人信息并打印出来。
#include <iostream>
#include "contacts.pb.h" // 引⼊编译⽣成的头⽂件
using namespace std; int main()
{ string people_str; {// .proto⽂件声明的package,通过protoc编译后,会为编译⽣成的C++代码声明同名的// 其范围是在.proto ⽂件中定义的内容contacts::PeopleInfo people; people.set_age(20); people.set_name("张珊"); // 调⽤序列化⽅法,将序列化后的⼆进制序列存⼊string中if (!people.SerializeToString(&people_str)) { cout << "序列化联系⼈失败." << endl; }// 打印序列化结果cout << "序列化后的 people_str: " << people_str << endl; }{contacts::PeopleInfo people; // 调⽤反序列化⽅法,读取string中存放的⼆进制序列,并反序列化出对象if (!people.ParseFromString(people_str)) { cout << "反序列化出联系⼈失败." << endl; } // 打印结果cout << "Parse age: " << people.age() << endl; cout << "Parse name: " << people.name() << endl; }
}
代码书写完成后,编译 main.cc,生成可执行程序 TestProtoBuf :
g++ main.cc contacts.pb.cc -o TestProtoBuf -std=c++11 -lprotobuf
其中-lprotobuf必加,否则会有连接错误,-std=c++11可以为更高级。
执行TestProtoBuf ,可以看见 people 经过序列化和反序列化后的结果:
由于 ProtoBuf 是把联系⼈对象序列化成了⼆进制序列,这里用 string 来作为接收二进制序列的容器。所以在终端打印的时候会有换行等⼀些乱码显示。
所以相对于 xml 和 JSON 来说,因为被编码成⼆进制,破解成本增本,ProtoBuf 编码是相对安全的。
最后,总结一下:
- 创建
.proto
文件:通过定义message
类型,我们详细规划了数据对象的结构和组成部分,包括字段名称、类型及其编码规则,为后续的通信协议奠定基础。- 通过
protoc
工具将.proto
文件转换为编程语言绑定代码:protoc
编译器读取.proto
文件并生成目标语言(如C++、Java、Python等)的接口和实现代码。这些代码被组织在头文件(.h
)中声明接口,源文件(.cc
或其他后缀)中实现细节,方便开发者在项目中直接调用。- 整合生成的接口到项目中,实现数据操作和消息的编解码:将编译得到的头文件包含到项目源代码中,即可利用预生成的类和方法来实例化消息对象,设置和检索字段值。同时,利用内置的序列化方法(如
SerializeToString
、ParseFromString
等)轻松地在二进制格式与消息对象之间转换,支撑了数据在网络间的高效传输与存储需求。这样,开发者便能集中精力于业务逻辑,而不必关注底层的数据序列化和协议细节。