Protobuf-C++

本文最后更新于:February 27, 2022 pm

本文介绍如何在C++工程中使用Google Protocol Buffer

Protocol Buffer简称Protobuf,是Google的一款针对序列化结构化数据而设计的,语言中立的,平台中立的可拓展机制。可以将之类比理解为xml,但是protobuf更小更快,也更简单。

使用时,需要用protobuf自己的一套语法定义一次希望序列化的数据类型,用protoc编译器编译之后便可以使用生成的代码进行字符流和对象的双向转换。目前支持很多语言,如C++C#JavaPythonGO等。特别地,为C++提供了与类型无关的反射机制

Protocol Buffer: 官网|文档

本文译自:Protocol Buffer Basics: C++ | Protocol Buffers | Google Developers,内容包含个人理解成分

最后放个Google自己的介绍

Protocol buffers are Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.

简介

这篇教程为C++使用者提供一个基本的protobuf介绍,内容包括

  • 如何在.proto文件中定义消息结构
  • 使用protocol buffer 的编译器protoc
  • 使用C++protobuf API来进行消息的读写

此外,这篇教程定位是基本使用介绍,而非综合的或者详尽的说明。如果有更高阶的需求,请关注:

Protocol Buffer Language Guide (proto2)

Protocol Buffer Language Guide (proto3)

C++ API Reference

C++ Generated Code Guide

Encoding Reference

为何使用Protocol Buffer?

本文所使用的例子是一个很简单的“地址簿”应用,此应用可以从和文件交互,读取和写入联系人的具体信息。每个地址簿中的人有以下属性:姓名,ID,邮箱地址和电话号码(0至多个)。

如果是你,你会如何来序列化类似这种联系人信息的结构化数据?通常,有以下几种解决方案:

  • 内存中的数据可以通过裸的二进制的方式直接进行发送和保存。但这种方式很脆弱,因为读取消息的代码必须在与发送信息的代码相同的内存布局下、系统大小端等硬件环境下编译。并且,随着文件中数据的累积和此地址簿应用软件的多处使用,鉴于此软件和文件中数据的组织形式高度耦合,想要拓展文件的内容格式几乎是天方夜谭。
  • 可以发明一种点对点(ad hoc)的数据编码方式来编码数据项,使之成为一个简单的字符串。比如,可以将4个int值编码成12:3:-23:67。这是一种简单且灵活的处理方式,但是它要求程序员编写一次性的编码解码的代码。并且解析会不可避免地导致运行时代价。这种解决方案在编码很简单的数据的场景下工作得非常好。
  • 将数据序列化成XML。这种方案非常吸引人,因为XML在某种程度上是可以被人理解的,并且许多语言都有相应的库。如果希望同其它的应用/工程共享数据,这会是一种好的选择。然而,XML在存储空间开销上早已经臭名远扬,并且使用它会使应用承受巨大的运行时开销。并且,在XMLDOM树中穿梭,比起在类的成员变量域中穿梭显然要更复杂得多。这两点尤其不C++

对于这个问题,Protocol Buffer就是一种灵活的,高效的且自动化的解决方案。 使用protobuf时,我们需要写一个.proto的描述性文件,用来描述我们希望序列化/存储的数据的结构(可以类比理解为在C++中声明一个类),当然也可以在一个.proto文件中声明多个这种结构。从.proto文件的内容出发,protobuf的编译器protoc会为每一种声明的结构定义并且实现一个类,这个类提供并且实现了消息对应的实例对象和二进制格式数据之间的编码和解码接口。此外,生成的类中每一个成员变量都有且不仅有对应的settergetter,并且整个类也有一个整体层次上的正反序列化接口。重要的是,protobuf的格式支持随着时间推移对格式内容进行拓展,并且仍然保证新版本的代码可以从旧的序列化格式中读取数据,旧版本的代码也可以解析新版本的二进制数据。

使用实例

The example code is included in the source code package, under the “examples” directory. Download it here.

定义Protocol 格式

.proto文件中,为每一种我们想要序列化的数据类型定义一个message(对应C++中的一个类),然后为这个message添加名字,成员。在本文的场景中,地址簿适用的addressbook.proto文件可以像这样书写,其语法和C++Java比较相近:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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;
}

.proto文件以package声明开头,用以防止不同工程之间的命名冲突。在C++中,产生的类都会被放在package的命名空间下。

一条消息是一个聚合体,包含了一堆带有类别的域(一堆成员变量)。许多标准的简单数据类型可以用做类型声明,包括bool, int32, float, double, string。也可以在定义某些消息类型之后,用已经定义过的消息类型来定义一个新的消息体里的域。比如上述代码中,Person类型消息中有PhoneNumber类型的域,并且PhoneNumber类型还是嵌套定义在Person类型之中的。使用enum类型来表明并且规范某个域的取值是有限且明确的。

定义中,在每一个域后面都有形如= 1, = 2等的记号,这些记号标记了用于二进制编码时其唯一的标签。编号在1-15的标签,比起更大的标签在存储上少占用一个Byte,建议将这些编号给使用最为频繁的域。每个repeated标识的域,在序列化每一个元素时都需要将标签重新编码一次,因此也很适合将1-15中的编号分配给这种域。

每个域都需要有以下三种标识符之一与之搭配:optional, repeated, 和required

  • optional

    表明这个域可能会被设置,也可能不会。如果没有被设置,会使用默认值。对于简单的类别而言,可以使用自己定义的默认值,比如在代码中给phone PhoneNumbertype设置了默认值[default = HOME]

    否则,系统对于数值类型的默认值为0,字符串则为空串,布尔类型为false。

    对于嵌套类型的消息,默认值是这种类型的默认实例,也就是没有任何一个域被设置。

    对一个还没有被显式设置过的域(可能是optional的,也可能是required的)调用getter,都会返回域的默认值。

  • repeated

    这个域可能出现任何自然数次。protobuf会自己维护出现的顺序,简单地将这种域看成动态数组即可。

  • required

    这个域的值必须被显式提供,否则这条消息会被认为是未被初始化的。如果libprotobuf是在debug模式下编译的,当试图序列化一个未被初始化的消息时会触发断言失败。在最优化的构建中,则会跳过检测,无论怎样都会将消息写出。然而,对没有初始化的消息执行解析操作会失败(parse方法返回false)。除此之外,required域和optional域表现得一致。

    注意:在声明某个域为required时,应该十分小心。因为当系统已经运行一段时间之后,再想要将某个域从required变回optional会有大问题。老的代码在解析时会认为消息不完整,因此将之抛弃。这需要程序员编写应用层的代码来定制验证逻辑。 在Google内部,required是极其不被推荐的。在Proto3中,已经不支持required域了。

Protocol Buffer Language Guide中可以找到完整的导引来编写.proto文件。不要花时间去找关于类的继承的内容,protobuf并不支持。

编译Protocol Buffer

接下来应该将.proto文件转换为类。首先下载编译器,按照README文件中操作。

编译器为protoc,编译时声明源文件路径(应用代码的源文件路径,不写则为curdir),目的路径,以及.proto文件的路径。

1
protoc -I=$SRC_DIR   --cpp_out=$DST_DIR   $SRC_DIR/addressbook.proto

因为要编译成C++的类,所以会有--cpp_out。其它语言略作转换即可。

这一步会生成两个文件到指定的目的地文件夹中:

  • addressbook.pb.h:头文件,声明生成的类
  • addressbook.pb.cc:类的实现

Protocol Buffer API

仔细查看addressbook.pb.h会发现,对于在.proto文件中所声明的每一种消息message类型,都被编译器proto转换成了一个C++的类,并且每个类的成员都有settergetter。对于Person类来说,可以看到以下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
 // name
inline bool has_name() const;
//对于每个required/optional类型的域,有has_方法,判断这个域是否被设置过。
inline void clear_name();
//unset方法,将域恢复到为空的状态
inline const ::std::string& name() const;
//getter方法的名字和域的名字一样,取全小写
inline void set_name(const ::std::string& value);
//setter则在getter名字前面加个set_
inline void set_name(const char* value);
//string类型提供两种setter
inline ::std::string* mutable_name();
//mutable_开头的getter直接返回指向string的非const指针,用于进一步修改
//在未设置该域的状态下调用此函数,会自动将字符串初始化为空串

// id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);

// email
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();


// phones
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;
//使用index get元素
inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
//用于修改某个index 上的元素
inline ::tutorial::Person_PhoneNumber* add_phones();

更多详细信息参考C++ generated code reference

枚举类型和嵌套类型

对于枚举类型,在生成的代码中有一个PhoneTypeenum,同.proto文件相关联。通过Person::PhoneType可以访问之,它的值取自$$\set{Person::MOBILE, Person::HOME,Person::WORK}$$但其实现有一些复杂,使用时并不需要理解。

编译器也会为嵌套的消息类型产生嵌套的类,在上述例子中为Person::PhoneNumber。实际上代码中类被命名为Person_PhoneNumber,但是在Person类中有typedef

唯一有区别的时候是,如果要在另一个文件中提前声明这个类——在C++中不能提前声明嵌套类型,但是可以提前声明Person_PhoneNumber

以下说明的方法在所有C++Protobuf产生的类中都是通用的,更多信息请参阅complete API documentation for message

标准消息方法

每种消息的类也包含一些方法,可以让外部对这个整体进行检查或者操作

  • bool IsInitialized() const;:检查是否所有的required的域都被设置了
  • string DebugString() const;:返回一个代表消息实例的可读的字符串,debug时有奇效。
  • void Clear();:将所有的元素清理干净,重置为空状态。

解析和序列化

每个protobuf产生的类都包含类和二进制格式之间的相互转换函数:

  • bool SerializeToString(string* output)const;:序列化对象,将二进制内容存到传入的string对象中去。string对象在此处仅仅是个方便使用的容器。
  • bool ParseFromString(const string& data);:从给定的字符串中解析一个对象。
  • bool SerializeToOstream(ostream* output)const;:将对象序列化到给定的ostream中去。
  • bool ParseFromIstream(istream* input);:从给定的istream中解析一个对象。

更多信息请参阅complete API documentation for message

注意:关于面向对象的设计和Protobuf

Protobuf产生的类,本质上来说只是纯纯的数据装载工具。对于面向对象的设计而言,它们不是首选的类。如果想在生成的类上加入更丰富的行为,最好的方法是在生成的类上额外包装一个和应用相关的类。另外,如果`.proto`文件不是你设计的,包裹protobuf也是一个很好的思路。 这样一来,就可以使用包裹类来创建一个更适合应用环境的接口,同时隐去具体细节,暴露简单方便的函数接口。

最最最重要的一点,永远不要通过继承protobuf生成的类来为之添加新的行为。这会破坏内部机制,并且无论如何也不是一种很好的面向对象的实践。

写一条消息

下面示范从一个文件中读取整个地址簿AddressBook,添加一条新的Person,并且将之写回文件的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
cout << "Enter person ID number: ";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');

cout << "Enter name: ";
getline(cin, *person->mutable_name());

cout << "Enter email address (blank for none): ";
string email;
getline(cin, email);
if (!email.empty()) {
person->set_email(email);
}

while (true) {
cout << "Enter a phone number (or leave blank to finish): ";
string number;
getline(cin, number);
if (number.empty()) {
break;
}

tutorial::Person::PhoneNumber* phone_number = person->add_phones();
phone_number->set_number(number);

cout << "Is this a mobile, home, or work phone? ";
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 << "Unknown phone type. Using default." << endl;
}
}
}

// Main function: Reads the entire address book from a file,
// adds one person based on user input, then writes it back out to the same
// file.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;

if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}

tutorial::AddressBook address_book;

{
// Read the existing address book.
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ": File not found. Creating a new file." << endl;
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}

// Add an address.
PromptForAddress(address_book.add_people());

{
// Write the new address book back to disk.
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed to write address book." << endl;
return -1;
}
}

// Optional: Delete all global objects allocated by libprotobuf.
google::protobuf::ShutdownProtobufLibrary();

return 0;
}

注意代码中的GOOGLE_PROTOBUF_VERIFY_VERSION宏。虽然不是严格意义上的必须,在使用C++protobuf 库之前执行之是一种很好的实践原则。它检查我们是否意外地链接了一个特定版本的库,并且这个库的版本和编译.proto文件所使用的库版本是不相同的。

如果两者的版本不相同,那么程序会直接终止。注意,每个xxx.pb.cc文件在开头都会自动使用这个宏。

注意在程序结尾对于ShutdownProtobufLibrary()的调用。这个调用会将所有被protobuf库所创建申请的全局对象delete掉。对于大多数程序而言,这是不必要的,因为程序都要结束了,OS会负责回收程序内存。然而,如果使用一个要求所有对象都被释放的内存泄漏检查器,或者你在写一个可能被一个进程多次载入和卸载的库时,必须强制让protobuf做清理工作。

读取一条消息

下面的程序将示范从上一段代码创建的文件中读取消息并且打印出来的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

// Iterates though all people in the AddressBook and prints info about them.
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 << "Person ID: " << person.id() << endl;
cout << " Name: " << person.name() << endl;
if (person.has_email()) {
cout << " E-mail address: " << 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 function: Reads the entire address book from a file and prints all
// the information inside.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;

if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}

tutorial::AddressBook address_book;

{
// Read the existing 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);

// Optional: Delete all global objects allocated by libprotobuf.
google::protobuf::ShutdownProtobufLibrary();

return 0;
}

对Protocol Buffer进行拓展

程序运行一段时间之后,我们可能会想要对这个地址簿进行升级。

我们尤其会希望新的版本和老的版本之间互相兼容。为了达到这一点,需要遵循以下的规则:

  • 禁止改变任意已经存在的域的标签,也就是原来的.proto文件中那些类似= 1的东西。
  • 禁止增加或删除任何required的域
  • 可以删除optional或者repeated的域
  • 可以增添optional或者repeated类型的域,但是新增的域的标签必须是老的message类中完全没有出现过的(即使是某个被删除的域的标签也不能被重用)。

当然也有一些例外,但是很少出现,可以参看some exceptions

如果遵守了这些规范,老的代码可以流畅地读入新版本的二进制消息,但是任何新增的域都会被忽略。对于老版本而言,被删除的optional域会有默认值,被删除的repeated域会为空。 新版本的代码也可以读取老版本对应的二进制内容。

牢记新增的optional域不会在老版本的消息中出现。因此在新版本的编程环境中,要么应该显式地检查has_xxx方法的返回值,或者在.proto文件中加入合适的默认值[defualt = soem_value],如果此处没有任何默认值,将会安排系统默认值

优化建议

鉴于是给C++用的,protobuf-C++已经被深度优化过了。以下的使用建议还能进一步提高性能:

  • 尽可能地重复使用message对象

    message尽量保持它们分配的内存的重用性,即使message对象可能已经被清理了。因此,如果编程中出现连续性地处理多个相同类型的message时,可以使用同一个message对象来免去内存分配的开销。然而,随着时间推移,对象可能会变得十分肿胀,特别是当你的消息在形状上时有变化(比如有repeated域),或者偶尔构造一个比往常大很多的消息对象时。应该手动地通过调用SpaceUsed监控message对象的大小,并且在对象过大时删除之。

  • 你所使用的系统的对于从多线程中分配小型对象的算法可能优化不够,尝试使用Google’s tcmalloc(自家广告,不寒碜

高级使用

protobuf能做的远不仅是序列化,更多的使用场景请参看C++ API reference

protobuf能为C++提供的一个关键特性是反射(Message::Reflection interface)编程时可以类型无关地迭代访问message的所有成员变量。因此一个特别有用的方向是将消息在protobufJSONXML等其它格式之间转换。

更高阶的使用还包括,使用反射机制来查找两个相同类型的message对象之间的不同点,甚至更可以开发一种适用于message类型的正则表达式。

protobuf能提供的远比大部分人想象的要多。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!