序列化协议: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>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.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

序列化协议:Protobuf入门

测试完成后就能够进行装置了:

 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<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 字段,生成的成员函数如下:

    // 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 文件中是怎样做的:

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

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

写入程序运转与存储的文件内容展现:

序列化协议:Protobuf入门

输出程序运转与结果展现:

序列化协议:Protobuf入门


[三] 本篇结语

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

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

参考资料:

(全文完)