原文地址:https://developers.google.com/protocol-buffers/docs/cpptutorial
本文通过一个示例,简单介绍了如何在 C++ 中使用 protocol buffer,您可以了解到:
- 如何在
.proto
文件中定义消息格式; - 如何使用 protocol buffer 编译器;
- 如何使用 C++ protocol buffer 接口来读写消息;
更详细的使用,请参阅 Protocol Buffer Language Guide (proto2)、Protocol Buffer Language Guide (proto3)、C++ API Reference、C++ Generated Code Guide、Encoding Reference。
1. 为什么使用 Protocol Buffers?
假设有一个可以读写人的联系方式的 Address Book 程序,每个人关联以下数据:
- ID
- 姓名
- 电话号码
如何序列化和检索这样的结构化数据? 有几种方法:
- 将原始的内存数据结构以二进制的形式发送/保存。这种方式随着项目的发展很不健壮,因为接收数据的代码必须使用完全的内存布局、字节序等进行编译。此外,如果为该数据结构增加一个字段的话,那些已经发行的软件是无法解析新的数据结构,因此扩展性也受到很大限制。
- 使用特殊方式将数据编码为单个字符串。例如,将 12、3、-23、67 这 4 个整数编码为“12:3:-23:67”。这种方式简单而灵活,非常适合简单的数据结构。缺点是:需要一次性将整段数据编解码,而不能分段分时去编解码。
- 将数据序列化为 XML。其优点是:人类可读的、有许多语言绑定库、可以与其他程序/项目共享数据。缺点是:XML 占用空间大,导致对数据的编解码造成性能损失。此外,导航 XML DOM 树比导航数据结构中的字段要复杂很多。
Protocol Buffers 提供了灵活、高效、自动化的解决方案。使用时,首先编写 .proto
文件来描述数据结构。然后,protocol buffer 编译器会创建了一个类,该类以高效的二进制格式实现 protocol buffer 数据的自动编解码。生成的类为字段提供 getter 和 setter 函数,并将读写 protocol buffer 的细节作为一个单元处理。重要的是,扩展数据结构后,旧的程序依然能够解析原来的字段。
2. 示例代码
在源码包中的 examples
目录中。
下载地址:https://developers.google.com/protocol-buffers/docs/downloads
3. 定义 Protocol 消息格式
例如,有这样一个数据结构:
- ID
- 姓名
- 电话号码
需要在 .proto
文件中为这个数据结构添加一条 message
,然后为 message
中的每个字段指定名称和类型。例如:
syntax = "proto2";
package tutorial;
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
如您所见,语法类似于 C++ 或 Java。让我们浏览文件的每个部分,看看它做了什么。
3.1 package 声明
package tutorial;
.proto
文件以 package 声明开头,类似命名空间,防止命名冲突。
3.2 message 定义
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message
是一组字段的集合,许多标准的简单数据类型可以作为字段类型,如 bool、int32、float、double 和 string。message
之间可以嵌套,如在 Person 中定义 PhoneNumber。
“= 1”、“= 2” 是该字段在二进制编码中的唯一标记。其中,1~15
占用空间更少,因此一般用于常用的字段或者 repeated 字段。
每个字段必须使用 修饰符
:
optional
:该字段可以赋值,也可以不赋值。未赋值时,编译器自动为该字段设置一个默认值:数字类型默认为 0,字符串默认为空,布尔值默认为 false。repeated
:该字段可以重复 n 次(包括 0)。可以看作是动态数组。required
:该字段必须赋值,否则被视为“未初始化”。
注意:required 必须谨慎使用!如果某个时刻将该字段改为 optional 可能会出现问题 - 旧程序可能会无意间拒绝或者丢弃这些消息。在 Google 内部,required 字段不受欢迎,proto2 语法中定义的大多数 message 仅使用 optional 或 repeated,proto3 语法取消了对 required 的支持。
关于 .proto
文件的完整指南,请参阅 Protocol Buffer Language Guide。不要试图寻找类似于类继承的工具 - protocol buffer 不会那样做。
4. 编译 .proto 文件
接下来需要用 protoc
工具来编译 .proto
文件生成所需的类:
- 如果没有安装编译器,需要到 download the package 下载并阅读 README;
- 运行
protoc
,指定源目录(默认当前目录)、生成目录(通常与 $SRC_DIR 相同),以及.proto
文件路径:
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto
编译后,生成两个文件:
- addressbook.pb.h:头文件
- addressbook.pb.cc:实现文件
5. Protocol Buffer 接口
生成的 addressbook.pb.h
中有着 addressbook.proto
中定义的每个 message
的类,不同类型的字段有着不同的函数:
// optional string name = 1;
inline bool has_name() const;
inline void clear_name();
inline const ::std::string& name() const;
inline void set_name(const ::std::string& value);
inline void set_name(const char* value);
inline ::std::string* mutable_name();
// optional int32 id = 2;
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);
// optional string email = 3;
inline bool has_email() const;
inline void clear_email();
inline const ::std::string& email() const;
inline void set_email(const ::std::string& value);
inline void set_email(const char* value);
inline ::std::string* mutable_email();
// repeated PhoneNumber phones = 4;
inline int phones_size() const;
inline void clear_phones();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
inline const ::tutorial::Person_PhoneNumber& phones(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
inline ::tutorial::Person_PhoneNumber* add_phones();
-
对于
required
、optional
字段-
获取数据的函数名与字段小写同名。例如,
name
字段的获取函数是name()
。除此以外,字符串类型的字段有着额外的mutable_
函数获得指向字符串的指针,即使未设置字段,调用该函数会自动初始未空字符串; -
使用
set_
函数设置值; -
使用
has_
函数判断是否设置了值; -
使用
clear_
函数将字段恢复为空状态;
-
-
对于
repeated
字段-
使用索引获取数组值;
-
使用索引更新数组值;
-
使用
_size
函数判断数组大小; -
使用
clear_
函数将字段恢复为空状态; -
使用
add_
函数为数组添加一个新数据;
-
更多有关不同字段生成的函数信息,请参阅 C++ generated code reference。
5.1 枚举、嵌套类
message Person {
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
}
生成的 PhoneType
枚举称为 Person::PhoneType
,其值有:
Person::MOBILE
Person::HOME
Person::WORK
message Person {
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
}
生成的嵌套类为 Person::PhoneNumber
。但其实际上为 Person_PhoneNumber
,在 Person 内部定义的 typedef 允许您将其视为嵌套类。其差别在于 ”在另一个文件中前向声明类“ 这种情况,你不能在 C++ 中前向声明嵌套类型,但你可以前向声明 Person_PhoneNumber。
5.2 标准 Message 方法
每个 Message 类还包含许多其他方法,包括:
bool IsInitialized() const
:检查所有required
字段是否都已设置。string DebugString() const
:返回 Message 的调试信息。void CopyFrom(const Person& from)
:拷贝 from 来覆盖当前 Message。void Clear()
:将所有元素清除回空状态。
更详细信息,请参阅 complete API documentation for Message。
5.3 序列化、解析
最后,每个类都有读写 Message 的方法:
bool SerializeToString(string* output) const
:序列化 Message 到字符串中。请注意,字节是二进制的,而不是文本;这里只是将字符串作为一个方便的容器。bool ParseFromString(const string& data)
:从给定的字符串解析消息。bool SerializeToOstream(ostream* output) const
:将 Message 写入给定的 C++ ostream。bool ParseFromIstream(istream* input)
:解析来自 C++ istream 的 Message。
更详细信息,请参阅 complete API documentation for Message。
Protocol Buffers 和面向对象设计
Protocol Buffer 生成的类基本上可以看作是 C 中的结构体。如果想扩展类的行为,推荐:用程序中的类封装该 Protocol Buffer 类。如果您无法控制 .proto 文件的设计,例如您正在重用来自另一个项目的文件,封装 Protocol Buffer 也是一个好主意。在这种情况下,您可以使用包装类来制作更适合程序独特环境的接口:隐藏一些数据和方法等。不推荐:继承生成的类。这将破坏内部机制并且这种方式不是好的面向对象的实践。
6. 写入 Message
要将数据写入 Message,需要创建并填充类的实例,然后将它们写入输出流。
再次回顾下 .proto
文件定义的数据结构:
syntax = "proto2";
package tutorial;
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
以下部分是将数据写入 Message 的代码,首先是头文件:
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
然后,PromptForAddress
函数会根据输入,将数据写入 person
中:
void PromptForAddress(tutorial::Person* person)
{
cout << "请输入ID:";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');
cout << "请输入name:";
getline(cin, *person->mutable_name());
cout << "请输入email:";
string email;
getline(cin, email);
if (!email.empty()) {
person->set_email(email);
}
while (true) {
cout << "请输入PhoneNumber(输入空白则完成):";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
tutorial::Person::PhoneNumber* phone_number = person->add_phones();
phone_number->set_number(number);
cout << "输入mobile, home, work确定该电话号码的类型:";
string type;
getline(cin, type);
if (type == "mobile") {
phone_number->set_type(tutorial::Person::MOBILE);
} else if (type == "home") {
phone_number->set_type(tutorial::Person::HOME);
} else if (type == "work") {
phone_number->set_type(tutorial::Person::WORK);
} else {
cout << "未知的类型,使用默认 home 类型." << endl;
}
}
}
最后是 main()
主函数:
int main(int argc, char* argv[])
{
// 验证我们链接的lib版本是否兼容之前编译的头文件
GOOGLE_PROTOBUF_VERIFY_VERSION;
//需要 filename 作为启动参数
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
// 读取文件
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ": 找不到该文件. 创建一个新文件." << endl;
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "解析失败!" << endl;
return -1;
}
// 添加 Person
PromptForAddress(address_book.add_people());
// 写入文件
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "写入文件失败!" << endl;
return -1;
}
// 可选:删除 libprotobuf 分配的全局对象
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
几点注意:
-
注意
GOOGLE_PROTOBUF_VERIFY_VERSION
宏。虽然该宏不是必须的,但使用前执行此宏是一种很好的做法。它验证您没有意外链接到与编译的头文件版本不兼容的库版本,如果不匹配,则程序中止。而每个.pb.cc
文件会自动调用该宏。 -
注意程序结束时对
ShutdownProtobufLibrary()
的调用,该函数会删除由 Protocol Buffer 库分配的所有全局对象。这对大多数程序来说是不必要的,因为进程无论如何都会退出,操作系统会负责回收它的所有内存。但是,如果您使用内存泄漏检查器或者程序中需要多次加载/卸载库,那么必须使用该函数。
7. 读取 Message
以下示例读取文件并打印信息。
首先是头文件:
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
然后,ListPeople()
函数列出 address_book
中的每一个 Person
信息:
void ListPeople(const tutorial::AddressBook& address_book)
{
for (int i = 0; i < address_book.people_size(); i++) {
const tutorial::Person& person = address_book.people(i);
cout << "ID:" << person.id() << endl;
cout << "Name:" << person.name() << endl;
if (person.has_email()) {
cout << "E-mail:" << person.email() << endl;
}
for (int j = 0; j < person.phones_size(); j++) {
const tutorial::Person::PhoneNumber& phone_number = person.phones(j);
switch (phone_number.type()) {
case tutorial::Person::MOBILE: cout << "Mobile phone:"; break;
case tutorial::Person::HOME: cout << "Home phone:"; break;
case tutorial::Person::WORK: cout << "Work phone:"; break;
}
cout << phone_number.number() << endl;
}
}
}
最后,是 main()
主函数:
int main(int argc, char* argv[])
{
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
ListPeople(address_book);
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
8. 扩展 Protocol Buffer
扩展旧的 .proto
文件中的定义,并且向下兼容,需要遵循一些规则:
- 禁止修改旧字段的标签号;
- 禁止新增、删除
required
字段; - 可以删除
optional
、repeated
字段; - 可以新增
optional
、repeated
字段,但必须使用新的标签号。不能使用已删除的字段的标签号。
以上规则有一些例外,但很少使用。
遵循这些规则,旧代码将可以读取新的 Message 并忽略新字段。对于旧代码,已删除的 optional
字段将仅具有其默认值,而删除的 repeated
字段将为空。新代码也可以读取旧的 Message。
请记住,旧的 Message 中不会出现新的 optional
字段,因此您需要调用 has_
函数来检查,或者在 .proto
文件中使用 [default = value]
提供合理的默认值。如果没有为 optional
元素指定默认值,则使用特定于类型的默认值:字符串默认值为空字符串;布尔值默认值为 false;数字类型默认值为 0。
还需请注意,如果新增 repeated
字段,您的新代码将无法判断它是留空(通过新代码)还是根本没有设置(通过旧代码),因为它没有 has_
标志。
9. 优化
C++ Protocol Buffers 库经过高度优化。但是,还有一些技巧可以进一步提高性能:
- 尽可能重用 Message 对象。清除 Meessage 时会保留分配的内存以供重用。因此,如果您要连续处理相同类型的 Message,最好每次重用相同的 Message 对象以减轻内存分配器的负载。但是,随着时间的推移,对象可能会变得臃肿,尤其是如果您的 Message “形状” 不同,或者您偶尔构建的 Message 比平时大得多。您应该通过调用
SpaceUsed
方法来监控 Message 对象的大小,并在它们变得太大时将其删除。 - 改用 Google 的 tcmalloc。因为您系统的内存分配器可能没有针对从多个线程分配大量小对象进行优化。
10. 高级用法
Protocol Buffer 的用途超越了一般的访问器和序列化。一定要探索 C++ API reference,看看你还能用它们做什么。
Protocol Message 类的一个关键特性是反射。您可以遍历 Message 的字段并操作它们的值,而无需针对任何特定的 Message 类型编写代码。
- 使用反射的一种非常有用的方法是:将 protocol Message 与其他编码(例如 XML 或 JSON)相互转换。
- 反射的更高级用途可能是:发现相同类型的两条 Message 之间的差异,或者开发一种“协议消息的正则表达式”,您可以在其中编写与某些 Message 内容匹配的表达式。如果您发挥自己的想象力,则可以将 Protocol Buffers 应用于比您最初预期的范围更广的问题!
反射由 Message::Reflection interface 提供。