• 序列化协议:Protobuf入门


    偶然在网上清华大学电子系科协软件部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>101id>
    <name>helloname>
    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.git
    cd protobuf
    git submodule update --init --recursive

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

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

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

    接下来是进行测试:

    ctest --verbose

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

    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.proto
    import "google/protobuf/any.proto";
    message Order {
    string order_id = 1;
    google.protobuf.Any details = 2;
    }

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

    // product.proto
    message ProductOrder {
    string product_id = 1;
    int32 quantity = 2;
    // 其他与产品订单相关的字段
    }
    // service.proto
    message 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 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 字段,生成的成员函数如下:

      // 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;
      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 将要链接的东西都链接进来了。(注意这个不是引号,而是 " ` " 号)

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

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


    [三] 本篇结语

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

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

    参考资料:

    (全文完)

  • 相关阅读:
    Java-day17(反射)
    Temporal介绍
    ChatGPT发不出消息?GPT发不出消息怎么办?
    具有通信时延的多自主体系统时变参考输入的平均一致性跟踪
    【LeetCode】18、四数之和
    【大语言模型】ACL2024论文-03 MAGE: 现实环境下机器生成文本检测
    网络安全(黑客)自学
    无用的小知识
    【计算机毕业设计】331基于微信小程序的家庭财务管理系统
    Apache PLC4X 副总裁宣布个人停止对项目提供免费支持;Linux 5.17 增加对中国 Soc 的支持;IPython 8.0 发布 | 开源日报
  • 原文地址:https://www.cnblogs.com/grapefruit-cat/p/18020257