4

序列化协议:Protobuf入门 - GrapefruitCat

 7 months ago
source link: https://www.cnblogs.com/grapefruit-cat/p/18020257
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

偶然在网上清华大学电子系科协软件部2023暑期培训的内容中发现了这个东西,后面随着了解发现以后学习有关项目时会用到,便写个随笔记录一下这次学习的经历。作为一种序列化协议,与使用文本方式存储的xml、json不同,protobuf使用的是二进制格式进行存储,有利于在类似分布式LInux性能分析监控的项目中构建出整个项目的数据结构。

[零] 序列化与Protobuf

实际传输中,我们会面临各种问题,例如:

  • 要传输的数据量很大,但其实有效的数据却不多 例如,传输下面这样一个数组:

    // 传递一个长整型数组long long arr[5] = {1, 2, 3, 4, 1000000000000}
  • 要传输的的数据类型非常复杂,难以传递:

    // 传递一个结构体 struct Bar { int integer; std::string str; float flt[100]; };

那么我们如何正确而高效地进行这种传递呢?在发送端,我们需要使用一定的规则,将对象转换为一串字节数组,这就是序列化;在接收端,我们再以同样的规则将字节数组还原,这就是反序列化。

我们平时常见的文本序列化协议有 XML和JSON,这两种序列化协议在进行AI语料人工标注时很常见,可读性很好。但我们这里讲的protobuf却是一种可读性为零的协议——它使用二进制格式来进行数据的转储。

Google Protocol Buffer(简称 Protobuf)是一种轻便高效的结构化数据存储格式,平台无关、语言无关、可扩展,可用于通讯协议数据存储等领域。

下面来看一个表格,来对比这三种序列化协议的差异。这里就不对XML和JSON做详细介绍了,建议先去学习一下。

XML JSON Protobuf
数据存储 文本 文本 二进制
序列化存储消耗 较大 小(XML的1/3~1/10
序列化/反序列化速度 快(XML的20-100倍)
数据类型 支持广泛的数据类型 支持基本的数据类型 需要通过message定义来指定数据类型
跨平台支持 支持 支持 支持

再来看一个小例子。我们需要传输一个结构体类型的数据,结构体如下:

struct Student { int id; std::string name;}

使用XML序列化:

<student> <id>101</id> <name>hello</name> </student>

使用json序列化:

{ "id": 101, "name": "hello" }

使用Protobuf二进制序列化:

08 65 12 06 48 65 6C 6C 6F 77

为什么要用 protobuf ? Generated by GPT4.0.

1. 效率和性能: Protobuf是一种高效的二进制序列化格式,相比于其他文本格式(如JSON和XML),它具有更小的数据体积和更快的序列化/反序列化速度。这使得Protobuf在网络通信和数据存储方面表现出色,特别适合传输大量数据或需要高性能的场景。

2. 跨语言支持: Protobuf支持多种编程语言,包括C++、Java、Python等。通过定义通用的消息格式和服务接口,不同编程语言的应用程序可以相互通信和交换数据,实现跨平台和跨语言的互操作性。

3. 数据版本控制: Protobuf支持在数据结构发生变化时进行向前和向后兼容的数据版本控制。通过定义消息的字段编号而非字段名称,可以避免在数据结构演化时出现命名冲突或解析错误。这使得在应用程序升级和数据迁移时更加灵活和可靠。

4. 紧凑的数据格式: Protobuf使用二进制编码,将数据紧凑地表示为字节序列。相比于文本格式,二进制编码占用更少的存储空间,减少了网络传输的带宽消耗,并提高了数据传输的效率。

5. 自动生成代码: Protobuf使用定义数据结构的.proto文件,可以自动生成与编程语言相关的代码,包括消息类、序列化和反序列化方法等。这简化了开发过程,减少了手动编写和维护序列化代码的工作量。

6. 可扩展性: Protobuf支持向已定义的消息结构中添加新字段,而不会破坏已有的解析逻辑。这种可扩展性使得应用程序可以逐步演化和升级,而无需对整个数据结构进行全面修改。

GPT给我们介绍的优点会在后面我们对“如何使用 protobuf ”进行详细学习体现。


[一] Protobuf 安装

官方 C++ && CMake 版本安装文档—— C++ && CMake Protobuf Installation

进行学习时用的是C++,跟着上手搓一搓。注意Protobuf需要使用CMake进行编译安装,所以需要对CMake有一定的了解。

本机使用环境如下:

  • Ubuntu 20.04.6 LTS
  • cmake version 3.16.3
  • git version 2.25.1
  • 内核版本信息:Linux version 5.15.0-94-generic (buildd@lcy02-amd64-118)
  • GNU编译工具:gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34

进行安装前,需要检查是否具备:CMake, Git, 以及 Abseil 库。(在这里我进行了Abseil的拉取源码自行安装,按照官方文档傻瓜式操作就行,比较简单。)

首先进行 protobuf 源码的获取:,要注意通过GitHub拉取源码时,要使用第三行的 git 命令进行子模块和 configure 文件生成检查。

git clone https://github.com/protocolbuffers/protobuf.gitcd protobufgit submodule update --init --recursive

然后使用cmake进行构建。我这里没有完全按照官方文档那样直接在源码的根目录进行构建,而是采用了比较常见的“out of source”构建方法,即在源码根目录新建一个build目录用来存放构建文件。注意,protobuf使用了C++14及以上的语言标准,使用CMake编译时可能需要进行手动设定:

mkdir build && cd buildcmake .. -DCMAKE_CXX_STANDARD=14# 注意线程数量与自己的机器线程数适配,不然编译时会爆内存cmake --build . --parallel 4

(过程中碰到了virtual box虚拟机硬盘扩容的问题,搞了好久。。最后直接用GParted的GUI来搞定了)

接下来是进行测试:

ctest --verbose

2565949-20240219013723744-1975580973.png

测试完成后就可以进行安装了:

sudo cmake --install .

大功告成!!以上操作会将protoc可执行文件以及与 protobuf 相关的头文件、库安装至本机,在终端输入protoc,若输出提示信息则表示安装成功。


[二] 如何使用 Protobuf

官方英文学习文档:Protocol Buffers Documentation

我们接下来将围绕一个“地址簿”的应用例子。每个在地址簿上的人物都有名字、ID、电子邮箱和手机号码四个属性。

那么我们怎样去将这些结构化的数据进行序列化和反序列化呢?直接采用原始的raw二进制数据传输?太过fragile且扩展性太低;采用点对点定制的编码string传输?这种一次性的方法往往只在简单的数据传输中有效;采用大名鼎鼎的 XML ?空间消耗太大且 XML DOM树太过复杂了……

所以我们使用 protobuf

protobuf是为传输数据服务的,它为我们提供了用来定义消息格式的语言工具,我们可以使用protobuf语言的语法来编写一个 .proto文件,并围绕这个文件展开代码的编写和数据的传输。在这里我们学习C++方面的使用,分为三个步骤逐步介绍。

2.1 编写.proto文件

我们需要先从一个.proto文件开始。我们为每一个想要进行序列化的结构化数据都创建一个message(其实message也是一种类似struct的结构体语法格式),并在message里面声明每一个field的名字和类型。

我们从例子入手去学习如何编写一个这样的文件。下面是地址簿应用的.proto文件示例:

// [START declaration]syntax = "proto3";package tutorial;import "google/protobuf/timestamp.proto";// [END declaration] // [START messages]message Person { string name = 1; int32 id = 2; // Unique ID number for this person. string email = 3; enum PhoneType { MOBILE = 0; HOME = 1; WORK = 2; } message PhoneNumber { string number = 1; PhoneType type = 2; } repeated PhoneNumber phones = 4; // 引入在另一个.proto定义的消息类型 google.protobuf.Timestamp last_updated = 5; } // Our address book file is just one of these.message AddressBook { repeated Person people = 1; // repeated类型字段(数组)}// [END messages]

2.1.1 语法

protobuf 有两个主要版本,分别为 proto2proto3,两套语法不完全兼容。我们可以使用 syntax关键字指 定 protobuf 遵循的语法标准,如例子中使用的就是 proto3.

我们在这只记录一些简单但必要的proto3语法,详细还得查官方文档,这里只是做一个简单的备忘录的作用。proto2 的例子可以看这位博主的博文:Protobuf学习 - 入门,但我也会在下面列出的东东简略提到一下两个版本的差异。

  • syntax 关键字必须为第一行非空非注释的行,用于指定protobuf版本,如果不指定则后面编译时会默认你为 proto2 。
  • package 关键字为消息类型提供了命名空间的分隔,避免命名冲突。在这个例子中,所有的消息类型都属于名为 tutorial 的命名空间。
  • import 关键字用来引入外部的 .proto文件。(只能import当前目录及子目录?)
  • message是一个类似 struct的关键字,用来定义程序要传递的结构化消息类型,每一个字段都有自己的数据类型和字段名。
  • 定义字段时,必须对字段赋值标识号(即每个数据字段后的 = 1, = 2 ……),并且有以下限制:
    • 标识号范围为 1 到 536,870,911 (0x1至0x3FFFFFFF);
    • 每个标识号必须独一无二;
    • 19000 到 19999 的标识号是预留值,一旦使用编译时就会报warning;
    • 一旦定义好的消息类型开始使用,标识号就不能再更改,因为标识号 “it identifies the field in the message wire format.”
    • 为频繁访问的字段使用 1 - 15 的标识号,以节省编码空间消耗。
  • enum 关键字定义枚举类型。每个枚举定义都必须包含一个映射到 0 的常量作为枚举的默认值,但后续值不再自动递增,每个值需要显式指定。如例子中从 MOBILE 开始。
  • 简单数据类型bool, int32, float, double, 和 string. 除此之外,proto语法支持嵌套,即用自己定义的message来作为数据类型。如上面例子中,Person消息类型中嵌套了PhoneNumber消息类型,而 AddressBook消息类型中又嵌套了Person消息类型。
    • 数据类型与各个语言中的类型对应见文档:Scalar Value Types
    • 字段的默认值在proto3中不能手动指定,只能由系统根据字段类型决定(通常为零值或空值),同样见上面给出的文档链接。
  • 前缀标签(字段规则):proto3取消了proto2的required规则,只剩两种:singular(单数,相当于proto2的optional)和 repeated(重复)。
    • optional:有点像正则表达式中的 ?,表明该字段可以有0个或1个值,若不设置则为默认值,且编码时不会被编进去。
    • repeated:表明该字段可以重复任意多次(包括0次),即数组,顺序有序。如例子中的phones数组。
  • 注释:采用 C/C++ 注释格式。

2.1.2 默认命名规则

  • proto2中,默认情况下,字段、消息和枚举值的命名采用驼峰命名法(如myFieldMyMessageMY_ENUM)。
  • proto3中,默认情况下,字段、消息和枚举值的命名采用下划线命名法(如my_fieldMy_MessageMY_ENUM)。

2.1.3 高级语法

Any

Any 类型是一种特殊的消息类型,它允许在没有.proto定义的情况下,可以将任意类型的数据包装成 Any 消息,并将其嵌入到其他消息类型中,这样可以将不同类型的数据存储在同一个字段中

Any 消息类型的定义如下:

message Any { string type_url = 1; bytes value = 2;}
  • type_url:用于存储被包装数据的类型信息,唯一地标识了被封装的消息的类型。它是一个表示数据类型的URL字符串,通常遵循 "type.googleapis.com/_packagename_._messagename_ " 的格式,例如 "com.example.myapp.MyMessage"(即消息类型的全限定名,前面加上一个包名或域名的前缀)。通过类型URL,接收方可以识别出如何解析和处理被包装的数据。
  • value:用于存储被包装的数据。它是一个字节数组,可以存储任意类型的数据,例如序列化的消息或其他二进制数据。

我们来看一个 Any 消息类型使用的例子。假设我们现在有一个电子商务平台,需要存储用户的订单信息,但每个订单的详细信息结构可能因不同商家自定义而不同。这时候我们可以使用 Any 消息类型来存储订单的详细信息。

首先定义一个通用的订单信息类型:

syntax = "proto3";// 要使用 Any 消息类型,需要先import对应的any.protoimport "google/protobuf/any.proto"; message Order { string order_id = 1; google.protobuf.Any details = 2;}

接下来,我们定义两个具体的订单详细信息类型:ProductOrderServiceOrder 。它们使用不同的消息类型来表示不同的订单信息:

// product.protomessage ProductOrder { string product_id = 1; int32 quantity = 2; // 其他与产品订单相关的字段} // service.protomessage ServiceOrder { string service_id = 1; // 其他与服务订单相关的字段}

后面经过一系列的程序运行,我们可以得到一条这样的 Order 订单信息:

Order { order_id: "000001" details: Any { type_url: "type.googleapis.com/Product.ProductOrder" value: <可解析为ProductOrder类型的二进制数据> }}

在这个例子中,我们将产品订单的详细信息序列化为字节数组,并将其赋值给 Any 消息类型的 value 字段。同时,我们指定了类型URL为 "type.googleapis.com/Product.ProductOrder" ,以便接收方能够正确解析和处理这个订单的详细信息。

Oneof

oneof 类型就像 C/C++ 中的 Union, 它包含的多个字段共享一段内存。protobuf 提供了 case()WhichOneof() 两个API,用以检查 oneof 类型中哪个字段被赋值了。

message SampleMessage { oneof test_oneof { string name = 4; SubMessage sub_message = 9; }}

对于两个proto版本之间的差异,proto2 支持 oneof 语法,用于指定一组互斥的字段,只能设置其中一个字段的值;而 proto3 仍然支持 oneof 语法,但是在proto3中,oneof 字段可以为空,也就是可以没有任何字段被设置。

有一些需要注意的特点:

  • oneof 类型里面可以嵌套除了 maprepeated 的所有数据类型。如果你有着在 oneof 中加入 repeated 类型的需求,则可以用一个包含 repeated 类型的 message 来代替。

  • 注意最后一次赋值会像 Union 那样覆盖之前的赋值(清空 oneof 中其它的字段)。

  • oneof 类型不能通过 repeated 修饰。

  • 在使用 C++ 进行编码时,特别注意内存管理问题:在给 oneof中的字段赋值时,可能会导致旧值被覆盖,并且如果没有适当地释放内存,可能会导致内存泄漏或非法内存访问。

Map

map 字段可以定义关联映射类型,即键值对类型。其定义语法如下:

map<key_type, value_type> map_field = N;

其中 key_type 可以为任意整型或string类型(注意枚举类型并不归属在内),value_type 可以为任何除了 map 的数据类型。

如果你想定义一个以string类型为键,value为 Project 消息类型的 map映射,则如下:

map<string, Project> projects = 3;

很简单吧!map 也有一些特点:

  • map 类型不能通过 repeated 修饰。
  • 如果为 map 字段提供键但没有值,在序列化该字段时,其行为取决于编程语言。在C++、Java、Kotlin和Python中,将序列化该类型的默认值,而在其他语言中,则不会序列化任何内容。

不支持 map 类型的 protobuf 实现版本 可以这样手动实现对 map 的支持:

message MapFieldEntry { key_type key = 1; value_type value = 2;}repeated MapFieldEntry map_field = N;

除此之外,protobuf 中的高级语法还有很多,在这不做展开,可以去翻阅官方文档。

2.2 编译protobuf定义

在上一个步骤中,我们已经写好了一个 .proto 文件,接下来要做的就是根据这个 .proto 去生成一系列用于读写地址簿数据的类。

在这里要使用 protobuf 的编译器: protoc

如果本机环境中没有该编译器,在这里下载,按照 README 操作。

protoc 运行时,若无指定路径,则当前工作路径即为其默认路径;最简单的格式如下:

protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/xxx.proto

这条命令运行后,protoc 会编译生成两个文件:xxx.pb.hxxx.pb.cc

2.3 使用 C++ protobuf API 读写消息

经过 protoc 编译后,我们就可以使用生成的类以及protobuf提供的API来进行愉快的程序编写了。

2.3.1 生成的类与 API

我们先来看生成的类要怎么用。protoc 采用了面向对象的思想,把转化的 C++ 类的声明和实现放到生成的两个文件中,这两个文件是很大的,硬读的话肯定不太行。下面是一些 protoc 在编译过程中的行为要点,简要分析了这些类和成员函数是个怎样的情况。

  • 每个 message 都对应生成了一个类,每个字段都是类的成员变量;

  • 每个字段都有自己的 accessors,如对于 .proto 例子中 Person 消息类型的 id, email, 和 phones 字段,生成的成员函数如下:

    // idinline bool has_id() const;inline void clear_id();inline int32_t id() const;inline void set_id(int32_t value); // emailinline 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(); // phonesinline 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();

    可以看到有 has_fieldset_fieldclear_field 这些成员函数,并且对于不同数据类型的字段,成员函数也会有增加/减少。如 string 类型的字段会有一个 mutable_field 的方法,用于直接获取指向字段存储字符串的指针。

  • repeated 类型的字段没有 set_field 方法。它可以利用 field_size 方法来检查当前元素个数;利用元素下标获取/修改特定元素;利用 add_field 方法添加新的元素,该方法会创建一个未经设值的类型成员,并返回它的指针

  • .proto 中定义的枚举类型前加上外层的 message 名作为命名空间,如例子中的枚举类型生成为 Person::PhoneType,值为 Person::MOBILEPerson::HOMEPerson::WORK

  • 对于嵌套在 message 里面的 子 message,如例子中的 PhoneNumber,实际在代码文件中它是与类 Person 分开定义的,类名为 Person_PhoneNumber(C++没有嵌套类定义,这里也没有用继承什么的),只不过 Person 定义域里面使用了它的别名:

    using PhoneNumber = Person_PhoneNumber; // 使得看起来就像一个 nested class

对于整个 message 的数据,也有相应的成员函数来对其进行检查/操作。这些函数与 I/O 函数 共同构建起了 父类 Message 的接口。如 Person 类中:

  • bool IsInitialized() const;: 检查是否所有字段都已经赋值;

  • string DebugString() const;: 字面意义,返回可读性高的 message 字符串,用于debug;

  • void CopyFrom(const Person& from);: 就是复制赋值函数,覆盖现有的数据。

  • void Clear();: 全部字段值归零。

    更多信息见文档:complete API documentation for Message

最后当然是类中使用 protobuf binary 格式进行 message 读写的成员函数:

  • bool SerializeToString(string* output) const;: 将 message 序列化到一个string中,注意string存储的是序列化后的二进制数据,而不是文本。

  • bool ParseFromString(const string& data);: 解析函数,功能与上面函数相反。

  • bool SerializeToOstream(ostream* output) const;: 序列化 message 数据后直接输出到指定的 ostream。

  • bool ParseFromIstream(istream* input);: 以指定的 istream 作为二进制数据输入,进行反序列化解析。

    除此提供的更多序列化/反序列化函数,如与字节流配对的 SerializeToArrayParseFromArray,详细见文档

2.3.2 写入 message

我们现在的第一个需求是能够将个人信息写入到地址簿中,这个过程包括信息输入、序列化、写入地址簿数据存储文件。

这里是官方的代码:add_person.cc

基本数据操作上面API讲得也差不多了,看一下代码里怎样运用即可。这里还有几点值得注意的:

  • 善用 宏 GOOGLE_PROTOBUF_VERIFY_VERSION,来检查兼容性问题;

    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;
  • 打开 fstream 时可以见到打开的方式为 ios::in | ios::binary,反序列化解析时是通过 ParseFromIstream() 直接将文件数据解析到 Address 类中;如下:

    // 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; }

    Address 类写回文件中同理,不过输出的 fstream 打开方式为 ios::out | ios::trunc | ios::binary

  • 最后使用 ShutdownProtobufLibrary() 来结束程序,不是很必要但是一个良好的习惯(特别对于C++):

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

2.3.3 读取 message

我们的第二个需求就是将地址簿中的所有人信息列举出来。

这里是官方的代码:list_people.cc

代码中可以看到对 repeated 类型数据的访问,确实是用下标来确认具体位置:

void ListPeople(const tutorial::AddressBook& address_book) { // select the person by index for (int i = 0; i < address_book.people_size(); i++) { const tutorial::Person& person = address_book.people(i); // ... // select the phone number by index for (int j = 0; j < person.phones_size(); j++) { const tutorial::Person::PhoneNumber& phone_number = person.phones(j); switch (phone_number.type()) { // ... } cout << phone_number.number() << endl; } if (person.has_last_updated()) { cout << " Updated: " << TimeUtil::ToString(person.last_updated()) << endl; } }}

其余注意地方基本和写入 message 时一样。

2.3.4 编译生成整个程序

现在我们有了 .proto 生成的 .h.cc 类文件,还有了两个源程序代码文件,接下来要做的就是将它们编译链接了。

如果我们直接进行 g++ 编译:

g++ add_person.cc address.pb.cc

报大错!正确编译命令应该要加上包含的头文件路径以及需要链接的库:

g++ --std=c++14 main.cc xxx.pb.cc -I $INCLUDE_PATH -L $LIB_PATH

这里有很重要的点:

  1. C++ 版本必须在 cpp14 及以上,这一点在安装 protobuf 也很明确了;
  2. 对于需要包含的头文件位置和需要链接的库文件,一个个去尝试属实麻烦。用 pkg-config 帮忙查找!!

不妨看看官方给出的 Makefile 文件中是怎么做的:

c++ -std=c++14 add_person.cc addressbook.pb.cc -o add_person_cpp `pkg-config --cflags --libs protobuf`

它使用 pkg-config 将要链接的东西都链接进来了。(注意这个不是引号,而是 " ` " 号)

写入程序运行与存储的文件内容展示:

2565949-20240219013724304-356724903.png

输出程序运行与结果展示:

2565949-20240219013724756-802369627.png

[三] 本篇结语

OK!洋洋洒洒写了很多,但都是一些自己入门学习 protobuf 的心得,学习这些知识时看官方文档真的很必要! :)

下一篇再打算学习一下为什么 protobuf 这么好,它里面到底有什么样的编码原理,不能成为只会调 API 的家伙哈哈……以及还有 gRPC 这种东西要学习呢……

参考资料:

(全文完)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK