文章

protobuf指南

protobuf指南

protobufGoogle 开发了供内部使用的一种免费的开源跨平台数据格式,用于序列化结构化数据,并在开源许可下为多种语言提供了代码生成器。

protobuf

1. 网址

2. 介绍

protobuf 支持几种不同的编程语言。对于每种编程语言,您可以在Github开源网址上相应的源目录中找到有关如何为该特定语言安装 protobuf 运行时的说明:

LanguageSource
C++ (include C++ runtime and protoc)src
Javajava
Pythonpython
Objective-Cobjectivec
C#csharp
Rubyruby
Goprotocolbuffers/protobuf-go
PHPphp
Dartdart-lang/protobuf

本文都是根据官方文件 https://developers.google.com/protocol-buffers/docs/proto3 翻译并只保留基本介绍和接口说明,版本适用于 proto3

2.1. 定义消息类型

2.1.1. 示例

首先让我们看一个非常简单的例子。假设您要定义一个搜索请求消息格式,其中每个搜索请求都有一个查询字符串、您感兴趣的特定结果页面以及每页的多个结果。这是.proto您用来定义消息类型的文件。

1
2
3
4
5
6
7
syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}
  • 该文件的第一行指定您使用的是proto3语法:如果您不这样做,协议缓冲区编译器将假定您使用的是 proto2。这必须是文件的第一个非空、非注释行。
  • SearchRequest 消息定义指定了三个字段(名称/值对),每个字段用于您希望包含在此类消息中的每条数据。每个字段都有一个名称和一个类型。

2.1.2. 指定字段类型

  • 标量类型
  • 复合类型:枚举、嵌套消息类型

2.1.3. 分配字段编号

消息定义中的每个字段都有一个唯一的编号。这些字段编号用于在消息二进制格式中标识您的字段,并且在使用您的消息类型后不应更改。

您可以指定的最小字段编号是 1,最大的是 $2^{29}-1$,即 536,870,911。您也不能使用数字 19000 到 19999 ( FieldDescriptor::kFirstReservedNumberFieldDescriptor::kLastReservedNumber),因为它们是为 Protocol Buffers 实现保留的。如果您在.proto 自定义,您不能使用任何以前保留的字段编号。

2.1.4. 指定字段规则

消息字段可以是以下之一:

  • 单数:格式良好的消息可以有零个或一个此字段(但不能超过一个)。这是 proto3 语法的默认字段规则。
  • repeated:该字段可以在格式良好的消息中重复任意次数(包括零次)。重复值的顺序将被保留。

proto3 中,repeated标量数值类型的字段packed默认使用编码。

2.1.5. 添加更多消息类型

可以在单个.proto文件中定义多种消息类型。如果您要定义多个相关消息,这很有用——例如,如果您想定义与您的SearchResponse消息类型相对应的回复消息格式,您可以将其添加到相同的.proto:

1
2
3
4
5
6
7
8
9
message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

message SearchResponse {
 ...
}

2.1.6. 注释

要向.proto文件添加注释,请使用 C/C++ 样式///* ... */语法。

1
2
3
4
5
6
7
/* SearchRequest 表示一个搜索查询,其分页选项为显示结果包含在响应中. */

message SearchRequest {
  string query = 1;
  int32 page_number = 2;  // 我们想要哪个页码?
  int32 result_per_page = 3;  // 每页返回的结果数.
}

2.1.7. 保留字段

2.1.8. proto 生成文件

当你运行 protocol buffer compiler(编译器)编译 .proto 文件,编译器会以您选择的语言生成代码,您需要使用文件中描述的消息类型,包括获取和设置字段值,将消息序列化到输出流,并从输入流中解析您的消息。

  • 对于C++,编译器会将每个 .proto 生成一个.hand.cc文件,并为文件中描述的每种消息类型提供一个类。

2.2. 标量类型

标量消息字段可以具有以下类型之一,该表显示.proto文件中指定的类型,以及自动生成的类中的相应类型:

.proto TypeNotesC++ TypeJava/Kotlin Type[1]Python Type[3]C# Type
double doubledoublefloatdouble
float floatfloatfloatfloat
int32使用可变长度编码。对负数进行编码效率低下——如果您的字段可能有负值,请改用 sint32。int32intintint
int64使用可变长度编码。对负数进行编码效率低下——如果您的字段可能有负值,请改用 sint64。int64longint/long[4]long
uint32使用可变长度编码。uint32int[2]int/long[4]uint
uint64使用可变长度编码。uint64long[2]int/long[4]ulong
sint32使用可变长度编码。带符号的 int 值。这些比常规 int32 更有效地编码负数。int32intintint
sint64使用可变长度编码。带符号的 int 值。这些比常规 int64 更有效地编码负数。int64longint/long[4]long
fixed32总是四个字节。如果值通常大于 $2^{28}$,则比 uint32 更有效。uint32int[2]int/long[4]uint
fixed64总是八个字节。如果值通常大于 $2^{56}$,则比 uint64 更有效。uint64long[2]int/long[4]ulong
sfixed32总是四个字节。int32intintint
sfixed64总是八个字节。int64longint/long[4]long
bool boolbooleanboolbool
string字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,并且不能超过 $2^{32}$。stringStringstr/unicode[5]string
bytes可能包含不超过 $2^{32}$的任意字节序列。stringByteStringstr (Python 2) bytes (Python 3)ByteString

[1] Kotlin 使用 Java 中的相应类型,甚至是无符号类型,以确保在混合 Java/Kotlin 代码库中的兼容性。

[2] 在 Java 中,无符号 32 位和 64 位整数使用它们的有符号对应物表示,最高位简单地存储在符号位中。

[3] 在所有情况下,为字段设置值将执行类型检查以确保其有效。

[4] 64 位或无符号 32 位整数在解码时始终表示为 long,但如果在设置字段时给出 int,则可以是 int。在所有情况下,该值必须适合设置时表示的类型。见[2]。

[5] Python 字符串在解码时表示为 unicode,但如果给出 ASCII 字符串,则可以是 str(这可能会发生变化)。

[6] 整数用于 64 位机器,字符串用于 32 位机器。

2.3. 默认值

解析消息时,如果编码的消息不包含特定的奇异元素,则解析对象中的相应字段将设置为该字段的默认值。这些默认值是特定于类型的:

  • 对于字符串,默认值为空字符串。
  • 对于字节,默认值为空字节。
  • 对于布尔值,默认值为 false。
  • 对于数字类型,默认值为零。
  • 对于enums,默认值是第一个定义的 enum value,它必须是 0。
  • 对于消息字段,未设置该字段。它的确切值取决于语言。

重复字段的默认值为空(通常是相应语言的空列表)。

2.4. 枚举

在定义消息类型时,您可能希望其字段之一仅具有预定义的值列表之一。例如,假设您要corpus为每个添加一个字段,SearchRequest其中语料库可以是UNIVERSALWEBIMAGESLOCALNEWS或。您可以通过在消息定义中添加一个非常简单的方法来做到这一点,并为每个可能的值添加一个常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

如您所见,Corpus枚举的第一个常量映射到零:每个枚举定义都必须包含一个映射到零的常量作为其第一个元素。这是因为:

  • 必须有一个零值,以便我们可以使用 0 作为数字默认值。
  • 零值必须是第一个元素,以便与第一个枚举值始终为默认值的 proto2 语义兼容。

您可以通过将相同的值分配给不同的枚举常量来定义别名。为此,您需要将allow_alias选项设置为true,否则协议编译器将在找到别名时生成错误消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
message MyMessage1 {
  enum EnumAllowingAlias {
    option allow_alias = true;
    UNKNOWN = 0;
    STARTED = 1;
    RUNNING = 1;
  }
}
message MyMessage2 {
  enum EnumNotAllowingAlias {
    UNKNOWN = 0;
    STARTED = 1;
    // RUNNING = 1;  // Uncommenting this line will cause a compile error inside Google and a warning message outside.
  }
}

2.4.1. 保留值

2.5. 使用其他消息类型

您可以使用其他消息类型作为字段类型。例如,假设您想Result在每条SearchResponse消息中包含消息——为此,您可以Result在同一条消息中定义一个消息类型,.proto然后指定一个类型为Result的字段SearchResponse

1
2
3
4
5
6
7
8
9
message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

2.5.1. 导入定义

在上面的例子中,Result消息类型定义在同一个文件中SearchResponse——如果你想用作字段类型的消息类型已经在另一个.proto文件中定义了怎么办?

您可以通过导入.proto来自其他文件的定义来使用它们。要导入另一个的定义,请在文件顶部添加一个 import 语句:

1
import "myproject/other_protos.proto";

请注意,公共导入功能在 Java 中不可用。

import public任何导入包含该语句的原型的代码都可以传递依赖依赖项。例如:

1
2
3
4
// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
1
2
3
// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto

2.5.2. 使用 proto2 消息类型

可以导入proto2 消息类型并在您的 proto3 消息中使用它们,反之亦然。但是,proto2 枚举不能直接在 proto3 语法中使用

2.6. 嵌套类型

您可以在其他消息类型中定义和使用消息类型,如下例所示,这里Result消息是在消息内部定义的SearchResponse

1
2
3
4
5
6
7
8
message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

如果您想在其父消息类型之外重用此消息类型,请将其称为_Parent_._Type_

1
2
3
message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

2.7. 包管理

您可以将可选package说明符添加到.proto文件中,以防止协议消息类型之间的名称冲突。

1
2
package foo.bar;
message Open { ... }

然后,您可以在定义消息类型的字段时使用包说明符:

1
2
3
4
5
message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

2.8. JSON 映射

2.9. 生成类

使用 protoc 工具编译生成自己需要的编程语言。

1
protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR --go_out=DST_DIR --ruby_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto
  • IMPORT_PATH指定.proto解析import指令时在其中查找文件的目录。如果省略,则使用当前目录。--proto_path多次传递该选项可以指定多个导入目录;他们将被按顺序搜索。-I=_IMPORT_PATH_可以用作 的简写形式--proto_path

  • 您可以提供一个或多个输出指令:

    • --cpp_out生成 C++ 代码DST_DIR
    • --java_out生成 Java 代码DST_DIR
    • --kotlin_out生成 Kotlin 代码DST_DIR
    • --python_out生成 Python 代码DST_DIR
    • --go_out生成 Go 代码DST_DIR
    • --objc_out生成 C# 代码DST_DIR
    • --php_out生成 PHP 代码DST_DIR

    请注意,如果输出存档已经存在,它将被覆盖

  • 您必须提供一个或多个.proto文件作为输入。.proto可以一次指定多个文件。尽管文件是相对于当前目录命名的,但每个文件必须位于其中一个IMPORT_PATH中,以便编译器可以确定其规范名称。

3. 使用

3.1. proto 文件

我习惯进行文件分类,所以 proto 文件放入 proto_file 文件中。

3.1.1. enum.proto

1
2
3
4
5
6
7
8
9
10
syntax = "proto3";

package ENUM;

enum PhoneType
{
    MOBILE = 0;
    HOME   = 1;
    WORK   = 2;
}

3.1.2. address_book.proto

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 = "proto3";

package TEST;

import "enum.proto";

message Phone
{
    string number  = 1;
    ENUM.PhoneType type = 2;
}

message Person
{
    string name = 1;
    int32  id = 2;     
    string email = 3;
    int32 phone_num = 4;
    repeated Phone phone = 5;
}

message AddressBook
{
    int32 people_num = 1;
    repeated Person people = 2;
}

3.1.3. 编译

可以写入 .bat.sh 文件中

1
2
protoc ./proto_file/enum.proto --proto_path=./proto_file/ --cpp_out=.
protoc ./proto_file/address_book.proto --proto_path=./proto_file/ --cpp_out=.

会生成对应的 .pb.h.pb.cc 文件。

3.2. 测试

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
#include <iostream>

#include "PbConvertor.h"
#include "address_book.pb.h"

void serializate(void*& data, size_t& size)
{
    TEST::AddressBook address_book;

    address_book.set_people_num(1);
    TEST::Person* person = address_book.add_people();
    person->set_name("august");
    person->set_id(1);
    person->set_email("123456789@qq.com");

    person->set_phone_num(1);
    TEST::Phone* phone = person->add_phone();
    phone->set_number("12345678910");
    phone->set_type(ENUM::PhoneType::MOBILE);

    size = address_book.ByteSizeLong();
    data = malloc(size);
    address_book.SerializeToArray(data, size);
}

void deserializate(void* data, size_t size)
{
    TEST::AddressBook address_book;
    address_book.ParseFromArray(data, size);
    for (int i = 0; i < address_book.people_size(); i++)
    {
        TEST::Person person = address_book.people(i);
        std::cout << "The " << i + 1 << " Information" << std::endl;
        std::cout << "name = " << person.name() << std::endl;
        std::cout << "id = " << person.id() << std::endl;
        std::cout << "email = " << person.email() << std::endl;
        for (int j = 0; j < person.phone_size(); j++)
        {
            TEST::Phone phone = person.phone(j);
            switch (phone.type())
            {
            case ENUM::PhoneType::MOBILE: std::cout << "  Mobile phone = "; break;
            case ENUM::PhoneType::HOME: std::cout << "  Home phone = "; break;
            case ENUM::PhoneType::WORK: std::cout << "  Work phone = "; break;
            }
            std::cout << phone.number() << std::endl;
        }
    }
}

void demo_hand()
{
    void*  data = nullptr;
    size_t size = 0;

    serializate(data, size);
    deserializate(data, size);

    free(data);
}

// g++ -o main main.cpp address_book.pb.cc enum.pb.cc -lprotobuf
int main()
{
    demo_hand();

    return 0;
}

image-20220613235038655

参考

[1] Google参考文档:https://developers.google.com/protocol-buffers/

本文由作者按照 CC BY 4.0 进行授权