本教程提供protocol buffer在C++程序中的基础用法。通过创建一个简单的示例程序,向你展示如何:

  • .proto中定义消息格式
  • 使用protocol buffer编译器
  • 使用C++ protocol buffer API读写消息

这并不是protocol buffer在C++中使用的完整指南。更多细节,详见Protocol Buffer Language GuideC++ API ReferenceC++ Generated Code GuideEncoding Reference

为什么使用Protocol Buffer

我们要使用的例子是一个非常简单的“通讯录”应用程序,它可以从文件中读写联系人的信息。通讯录中每个人都有一个姓名、ID、邮箱和练习电话。

你如何序列化并取回这样结构化的数据呢?下面有几条建议:

  • 原始内存中数据结构可以发送/保存为二进制。这是一种随时间推移而变得脆弱的方法,因为接收/读写的代码必须编译成相同的内存布局,endianness等。另外,文件已原始格式积累数据和在网络中到处传输副本,因此扩展这种格式十分困难。
  • 你可以编写已临时的方法来讲数据元素编码到单个字符串中 — 例如用“12:3:-23:67”来编码4个int。这是一种简单而灵活的方法,尽管它确实需要编写一次性的编码和解析代码,并且解析会增加少量的运行时成本。这对于编码非常简单的数据最有效。
  • 序列化为XML。这种方法非常有吸引力,因为XML(某种程度上)是人类可读的,而且有许多语言的绑定库。如果你希望与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML是出了名的空间密集型,对它进行编码/解码会给应用程序带来巨大的性能损失。而且,在XML DOM树中导航要比在类中导航简单字段复杂得多。

Protocol buffer是解决上述问题的一个灵活、高效、高度自动化的解决方案。使用Protocol buffer,你只需在.proto文件中描述你想要存储的数据结构。从文件中,protocol buffer编译器会创建一个类 — 实现了可以自动编解码的、高效的二进制protocol buffer数据。生成的类为组成protocol buffer的字段提供getter和setter方法,并负责将protocol buffer作为一个整体进行读写的细节。重要的是,protocol buffer协议支持扩展格式,以便新的代码仍可读取旧格式的编码。

从哪能找到示例代码呢?

你可以从这里下载。

定义你的Protocol格式

要创建通讯录程序,始于.proto文件。.proto文件中的定义很简单:为你想要序列化的每一个数据结构添加一个消息,然后声明消息中每个字段的名称和类型。示例使用的.proto文件为addressbook.proto,其中定义如下:

syntax = "proto3";

package tutorial;

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 PhoneNumber phones = 4;
}

如你所见,语法与C++/Java类似。接下来介绍文件中的每一部分以及它们如何工作。

.proto开头声明使用proto3语法,若不明确指出,编译器默认使用proto2语法。之后是包声明,用来解决不同项目的命名冲突。在C++中,你生成的代码会被放在与包名对应的命名空间。

接着,定义你的消息。消息只是一系列字段类型的集合体。很多标准的、简单的数据类型可以作为字段类型,包括boolint32floatdoublestring。你也可以使用其它消息类型作为字段类型来添加复杂结构到你的消息中 — 就像上面例子中,Person消息包含PhoneNumber消息,同时Person消息包含在AddressBook消息中。你甚至可以定义消息类型嵌套在其它消息中 — 就像上面PhoneNumber定义在Person中。你也可以定义enum类型,如果你想让你的字段只是用预定义列表中的一个值 — 这里你想声明的电话类型可以是MOBILEHOMEWORK其中之一。

“= 1”,“= 2”标记每个字段在二进制编码中的唯一的“tag”。序号1-15编码的字节数比较高的数字少一个,因此,作为一种优化,你可以决定对常用或重复的元素使用这些标记,而对不常用的可选元素使用标记16或更高。重复字段中的每个元素都需要重新编码标记号,因此重复字段是此优化的特别好的候选项。

每个字段都必须遵循下列规则之一:

  • singular:符合语法规则的消息可以拥有0个或1个该字段(但不能超过1个)。这是proto3默认的字段规则。
  • repeated:在符合语法规则的消息中,该字段可以重复任意次数(包括0次)。重复变量的顺序将被保留。

完整的编写.proto文件指南,详见Language Guide(proto3)

编译Protocol Buffers

现在你已经有.proto文件了,接下来你需要生成读写AddressBook(包括PersonPhoneNumber)消息的类。现在,你需要运行protocol buffer编译器protoc

  • 如果你还没安装编译器,可从这里下载并根据README编译安装。
  • 现在运行编译器,指明源目录(应用程序源文件目录,不指定的话默认使用当前目录),目标路径(你要存放生成的代码的目录,通常与$SRC_DIR一样),.proto文件路径。这样,你可以:

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,你会发现你在addressbook.proto中声明的每个消息类型都有一个对应的类。在Person类中,你会看到编译器已经为每个字段生成了访问器。例如,对于nameidemailphones字段,有如下方法:

// name
void clear_name();
const std::string& name() const;
void set_name(const std::string& value);
void set_name(std::string&& value);
void set_name(const char* value);
void set_name(const char* value, size_t size);
std::string* mutable_name();

// email
void clear_email();
const std::string& email() const;
void set_email(const std::string& value);
void set_email(std::string&& value);
void set_email(const char* value);
void set_email(const char* value, size_t size);
std::string* mutable_email();  

// id
void clear_id();
::PROTOBUF_NAMESPACE_ID::int32 id() const;
void set_id(::PROTOBUF_NAMESPACE_ID::int32 value);

// phones
int phones_size() const;
void clear_phones();
::tutorial::Person_PhoneNumber* mutable_phones(int index);
::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
const ::tutorial::Person_PhoneNumber& phones(int index) const;
::tutorial::Person_PhoneNumber* add_phones();

如你所见,getters方法实际是字段名的小写,setters方法以set_开头。每个字段都有一个clear_方法来清空重置该字段。尽管数字的id字段只有上面描述的基本访问器,但由于nameemail是字符串,所以它们还有一对额外的方法 — mutable_可以让你获取直指字符串的指针,以及额外的setter方法。如果在例子中有一个单一消息字段,那它也会有一个mutable_方法,但没有set_方法。

重复字段也有一些特有的方法 — 如何你查看重复字段phones的话,你会看到:

  • _size检查重复字段的数量(换句话说,Person有多少个电话号码)。
  • 使用索引来获取指定的电话号码。
  • 使用索引更新指定的电话
  • 添加新的号码到消息中,之后再编辑(重复标量字段类型都有个add_方法,仅可以通过它来访问新的变量)。

有关编译器为其它字段定义生成的成员的详情,参见C++ Generated Code Guide

枚举和内嵌类

生成的代码中包含一个PhoneType的枚举来匹配.proto中的枚举。你可以通过Person::PhoneType来访问该类型,其值可以通过Person::MOBILEPerson::HOMEPerson::WORK访问(实现细节有点复杂,但使用枚举时并不需要关心实现细节)。

编译器也为你调用Person::PhoneNumber生成了内嵌类。如果你看了生成的代码,你会发现“真的”有个类叫做Person_PhoneNumber,但是Person中的typedef定义允许你像内嵌类一样使用它。唯一有区别的情况是,如果你想在另一个文件中forward-declare这个类——在c++中你不能forward-declare嵌套类型,但你可以forward-declare Person_PhoneNumber

标准消息方法

每个消息类也包含很多你可以用来检查/操作整个消息的其它方法,包括:

  • bool IsInitialized() const:检查所有字段是否都已初始化。
  • string DebugString() const:返回人类可读的消息描述,debug时非常有用。
  • void CopyFrom(const Person& from);:使用给定的消息变量重写消息。
  • void Clear();:重置所有元素为空状态。

这些方法和接下来描述的I/O方法实现了所有c++ protocol buffer类共享的消息接口。详见complete API documentation for Message

解析和序列化

最后,每个类都提供了使用你所选方式来读写protocol buffer格式的二进制消息。包括:

  • bool SerializeToString(string* output) const;:将消息序列化并存储到给定的字符串中。注意,是二进制而不是文本字节;我们只是使用string作为便携的容器。
  • bool ParseFromString(const string& data);从给定的字符串中解析消息。
  • bool SerializeToOstream(ostream* output) const;将消息写入给定的C++ostream
  • bool ParseFromIstream(istream* input);从给定的C++istream中解析消息。

这些只是所提供用于解析和序列化选项的一部分,完整列表,详见complete API documentation for Message

写入消息

现在来试试protocol buffer类。你的通讯录程序首先要做的是可以将信息写入通讯录里。为此,你需要创建并实例化你的protocol buffer类,然后将它们写入输出流。

下面是一个可以从一个文件中读取通讯录,并根据用户输入向其中添加一个新Person,然后再次将新的通讯录写回文件。

#include <iostream>
#include <fstream>
#include <string>

#include "addressbook.pb.h"

using namespace std;

//从用户输入解析通讯录
void PromptFromAddress(tutorial::Person *person)
{
    cout << "Enter person ID number: ";
    int id;
    cin >> id;
    person->set_id(id);
    cin.ignore(256, '\n');

    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 << "Unknow phone type, Use default: home. " << endl;
            phone_number->set_type(tutorial::Person::HOME);
        }
    }
}

int main(int argc, char const *argv[])
{
    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] << ": File not found. Create a new file." << endl;
    else if (!address_book.ParseFromIstream(&input))
    {
        cerr << "Failed to parse address book." << endl;
        return -2;
    }
    else
    {
        PromptFromAddress(address_book.add_people());
        fstream output(argv[1], ios::out | ios::binary);
        if (!address_book.SerializeToOstream(&output))
        {
            cerr << "Failed to write address book." << endl;
            return -3;
        }
    }

    //可选操作,用于清除libprotobuf申请的所有全局对象
    google::protobuf::ShutdownProtobufLibrary();

    return 0;
}

注意,在程序末尾调用了google::protobuf::ShutdownProtobufLibrary()。它所做的工作就是清除libprotobuf申请的所有全局对象。对大多数程序而言,这一步不是必须的,因为进程一旦结束,系统会自动回收程序开辟的所有内存。然而,如果你使用的是要求每个遗留对象都必须释放或者你在写一个会被单个进程多次导入导出的库,那么你可能会希望protocol buffer来帮你清理这些。

读取消息

当然,如果你无法从中读取任何消息的通讯录是没用的。下面的例子是从上面例子中创建的文件中读取并输出其中的所有消息。

#include <iostream>
#include <fstream>
#include <string>

#include "addressbook.pb.h"

using namespace std;

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 << "\t Name: " << person.name() << endl;
        if (!person.email().empty())
            cout << "\t Email: " << 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 << "\t\t Mobile phone: ";
                break;
            case tutorial::Person::HOME:
                cout << "\t\t Home phone: ";
                break;
            case tutorial::Person::WORK:
                cout << "\t\t Work phone: ";
                break;
            default:
                break;
            }
            cout << phone_number.number() << endl;
        }
    }
}

int main(int argc, char const *argv[])
{
    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 -2;
    }

    ListPeople(address_book);

    google::protobuf::ShutdownProtobufLibrary();

    return 0;
}

扩展

在发布protocol buffer生成的代码后不久,你肯定会想提升你的protocol buffer定义。如果你想新的buffer可以被后向兼容,并且旧的buffer可以被前向兼容,— 你确实想这样做 — 那你需要遵守下面的规则。在新版的protocol buffer中:

  • 必须不能改变已有字段的序号。
  • 可以删除repeated字段。
  • 可以新增repeated字段,但必须使用新的序号(序号在protocol buffer中没被用过,也没被删除)。

还有一些其它的扩展要遵守,但很少会用到它们。

如果你遵守这些规则,那么旧代码可以轻松读取新的消息,忽略新的字段。对旧代码而言,删除的重复字段是空的。新代码可以正常读取旧消息。

优化建议

C++ Protocol Buffer库是高度优化过的。但是,恰当的用法还是可以提高效率的。下面的一些技巧可以让你进一步压榨库的性能:

  • 尽可能重用消息对象。重用时,消息会保留它开辟的所有内存,即使被清理过。这样,如果你正在连续处理许多具有相同类型和相似结构的消息,那么每次最好重用相同的消息对象以减小内存分配的开销。但是,随着时间的推移,对象可能会变得非常庞大,特别是当你的消息在“形状”上发生变化,或者你偶尔构造一个比通常大得多的消息时。你应该通过调用SpaceUsed方法来监视消息对象的大小,并在它们变得太大时删除它们。
  • 在多线程调用时,针对大量小对象的创建,系统的内存分配可能优化的不够好。可以使用Google`s tcmalloc替代。

高级用法

Protocol Buffer的用途不仅限于简单的访问器和序列化。一定要研究C++ API Reference,看看还可以用它们做什么。

protocol 消息提供的一个最重要的功能是反射。你可以迭代消息的字段并操作它们的值,而无需针对任何特定的消息类型编写代码。使用反射的一个非常有用的方法是将协议消息与其他编码(如XML或JSON)进行转换。反射的一个更高级的用途可能是发现相同类型的两个消息之间的差异,或者开发一种“协议消息的正则表达式”,在这种表达式中可以编写与特定消息内容匹配的表达式。如果你发挥你的想象力,可能会将协议缓冲区应用到比你你初预期的范围更广的问题上!

关于反射,详见Message::Reflection interface


声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin92
Github: mengbin92
cnblogs: 恋水无意