本文介绍如何使用Protocol Buffers语言来构造协议缓冲区数据,包括.proto文件语法以及如何从 .proto文件生成数据访问类。它涵盖了proto2版本的协议缓冲区语言。

本文只是一个参考指南,后续会出Java语言的教程。
如何使用本指南?在工作中遇到时,通过查询关键字来查找需要的知识点。

定义一个Message类型

首先我们开始为一个简单的例子,假如我们要构建一个搜索的请求消息格式。
每个搜索的消息包括三个参数:

  1. 要查询的字符串
  2. 指定的页码
  3. 以及结果数

下面则就是这个.proto对应的message格式

message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;
  optional int32 result_per_page = 3;
}

SearchRequest消息定义指定了三个字段(也可称为名称或者键值对),每个字段都有一个名称和一个类型。

指定的字段类型

上面的例子中,可以看到有两个类型:两个整数(页码和每页结果数量)、字符串(查询条件)。
当然,我们也可以定义字段为复合类型,包括枚举和其他的消息类型。

分配字段编号

上面的例子中,可以看到每个字段都有一个独一无二的编号,这些编号用于在二进制消息中标识我们的字段,也就是相当于字段的别名。
1 ~ 15 范围内的字段使用的是一个字节来表示。
16 ~ 2047 范围内的字段使用两个字节来表示。
所以,为了提升性能,我们尽可能的将出现非常频繁的字段保留到1~15的范围中。

最小的编号是1,最大的编号是2^29 - 1,也就是536,870,911。
注意,不能使用数字 19000 到 19999,因为它们是为协议缓冲区实现保留的 - 如果在 .proto 中使用这些保留数字之一,协议缓冲区编译器会报错。
同一个消息内的编号不能重复,这个也是需要注意的。

指定字段规则
  • required:简单理解为必选字段,数量为1。
  • optional:可选择的,数量不超过1。
  • repeated:所修饰的字段可以任意次数重复,包括0次。重复值的顺序也会被记录。

由于历史原因,标量数字类型的重复字段(例如,int32、int64、enum)的编码效率没有达到应有的水平。新代码应该使用特殊选项[packed=true]来获得更高效的编码。例如:

repeated int32 samples = 4 [packed = true];
repeated ProtoEnum results = 5 [packed = true];
packed为压缩字段,这个在后续的文章会讲解。

required也就意味了forever,因此我们在设置字段时,需要尽可能的多去考虑一下字段的作用范围。

添加更多的消息类型

我们可以在一个.proto文件中,设置多个消息类型。


message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;
  optional int32 result_per_page = 3;
}

message SearchResponse {
 ...
}
虽然可以在单个 .proto 文件中定义多种消息类型(例如消息、枚举和服务),但在单个文件中定义大量具有不同依赖关系的消息时,也会导致依赖关系膨胀。官方建议在每个 .proto 文件中包含尽可能少的消息类型,但是这个数值并为给出具体范围,需要根据实际情况评定。
保留字段

当我们在更新消息结构时,删除了某个字段或者注释掉了某个字段,将来的某一个用户可以使用我们删除了的这个字段对应的编号,这是没有问题的。但是一旦在后期加载到了.proto修改前的旧版本,就会因为编号冲突而发生问题,导致数据错乱的情况,因此可以引入保留字段。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

保留字段编号范围包括:2、15、9、10、11。也可使用 9 to max,以全部保留后续的编号。
请注意,不能在同一个保留语句中混合使用字段名和字段号。

根据.proto文件自动生成了什么?

对于java来说,编译器生成一个 .java 文件,其中包含每个消息类型的类,以及用于创建消息类实例的特殊 Builder 类。

类型对照表

.proto类型描述java类型
double double
float float
int32使用可变长度编码。对负数进行编码效率低下——如果您的字段可能有负值,请改用 sint32。int
int64使用可变长度编码。对负数进行编码效率低下——如果您的字段可能有负值,请改用 sint64。long
uint32使用可变长度编码。int
uint64使用可变长度编码。long
sint32使用可变长度编码。带符号的 int 值。这些比常规 int32 更有效地编码负数。int
sint64使用可变长度编码。带符号的 int 值。这些比常规 int64 更有效地编码负数。long
fixed32总是4个字节。如果值通常大于 2^28,则比 uint32 更有效。int
fixed64总是8个字节。如果值通常大于 2^56,则比 uint64 更有效。long
sfixed32总是4个字节。int
sfixed64总是8个字节。long
bool boolean
string字符串必须始终包含 UTF-8 编码的文本。String
bytes可以包含任意字节序列。ByteString

可选字段和默认值

当字段设置为可选的时候,我们的消息可包含也可以不包含该字段。
当不包含时,我们可以为可选字段设置默认值。

optional int32 result_per_page = 3 [default = 10];

如果没有指定默认值,则为系统默认值。
字符串为空串,bool为false,整型为0,枚举则为枚举类型的第一个值。
因此在设置枚举类型时,要格外注意。

枚举

我们可以在message内部定义枚举,并为其设置编号,比如我们设置一个Corpus枚举。

message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;
  optional int32 result_per_page = 3 [default = 10];
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  optional Corpus corpus = 4 [default = UNIVERSAL];
}

在同一个enum中。如果我们想要多个枚举值对应一个编号,可以使用别名的方式。
下面的实例中,上面的实例不会有问题,但是下面的实例会有错误提示。
注意:枚举名称不同,但是枚举值相同,也会报错,但如果在不同的message中,则不会报错。

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}
enum EnumNotAllowingAlias {
  UNKNOWN = 0;
  STARTED = 1;
  // RUNNING = 1;  // 取消注释此行将导致 Google 内部出现编译错误,并在外部出现警告消息。
}

使用其他message类型

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

message SearchResponse {
  repeated Result result = 1;
}

message Result {
  required string url = 1;
  optional string title = 2;
  repeated string snippets = 3;
}
import 定义

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

import "myproject/other_protos.proto";

默认情况下,我们只能使用直接import的 .proto 文件中的定义。但是,有时我们可能需要将 .proto 文件移动到新位置。
我们可以在旧位置放置一个占位符 .proto 文件,以使用import公共文件的概念将所有导入转发到新位置,而不是直接移动 .proto 文件并在一次更改中更新所有调用站点。

// new.proto
// All definitions are moved here
// old.proto
// This is the proto that all clients are importing.
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// You use definitions from old.proto and new.proto, but not other.proto

任何导入包含 import public 语句的 proto 的代码都可以传递依赖 import public 依赖项。

嵌套类型

我们可以在其他消息类型中定义和使用消息类型,如下例所示 - 这里 Result 消息在 SearchResponse 消息中定义:

message SearchResponse {
  message Result {
    required string url = 1;
    optional string title = 2;
    repeated string snippets = 3;
  }
  repeated Result result = 1;
}

如果我们需要在别的message,引用result,我们需要指定号result外围类(父类)。

message SomeOtherMessage {
  optional SearchResponse.Result result = 1;
}

您可以随意嵌套消息。在下面的示例中,请注意名为 Inner 的两个嵌套类型是完全独立的,因为它们是在不同的消息中定义的:

message Outer {       // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      optional int64 ival = 1;
      optional bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      optional string name = 1;
      optional bool   flag = 2;
    }
  }
}

更新 message

如果现有的消息类型不再满足我们的所有需求 - 例如,我们希望消息格式有一个额外的字段 - 但我们希望使用之前旧格式创建的代码,请不要担心!在不破坏任何现有代码的情况下更新消息类型非常简单。只需记住以下规则:

  • 不要更改任何现有字段的字段编号。
  • 您添加的任何新字段都应该是可选的或重复的。这意味着使用“旧”消息格式的代码序列化的任何消息都可以由新生成的代码解析,因为它们不会丢失任何必需的元素。您应该为这些元素设置合理的默认值,以便新代码可以与旧代码生成的消息正确交互。

    • 类似地,新代码创建的消息可以由旧代码解析:旧二进制文件在解析时会忽略新字段。
    • 但是,未知字段不会被丢弃,如果消息稍后被序列化,未知字段也会随之序列化——因此,如果将消息传递给新代码,新字段仍然可用。
  • 如果在更新的消息类型中不再使用某些字段编号,就可以删除非必填字段。您可能想要重命名该字段,可能添加前缀“OBSOLETE_”,或保留字段编号,以便您的 .proto 的未来用户不会意外重用该编号。
  • 只要类型和编号保持不变,非必填字段可以转换为扩展名(extension 后面会讲),反之亦然。
  • int32、uint32、int64、uint64 和 bool 都是兼容的——这意味着您可以将字段从其中一种类型更改为另一种类型,而不会破坏前向或后向兼容性。
  • sint32 和 sint64 相互兼容,但与其他整数类型不兼容。
  • 只要字节是有效的UTF-8编码格式,字符串和字节就兼容。
  • fixed32 与 sfixed32 兼容,fixed64 与 sfixed64 兼容。
  • 对于字符串、字节和消息字段,optional与repeated兼容。给定一个repeated字段的序列化数据作为输入,如果它是一个基本类型字段,那么期望这个字段是optional的客户端将采用最后一个的输入值,或者如果它是一个message类型字段,则合并所有输入元素。

    • 请注意,这对于数字类型(包括布尔值和枚举)通常不安全。数字类型的重复字段可以以打包(packed 后续会讲)格式序列化,当需要可选字段时,将无法正确解析。
  • 更改默认值通常是可以的,但要记住默认值永远不会通过网络发送。因此,如果程序接收到未设置特定字段的消息,则程序将看到在该程序的协议版本中定义的默认值。它不会看到发送方代码中定义的默认值。
  • 尽量不要修改枚举值,否则会出现一些奇怪的问题。
  • 在 map<K, V> 和相应的重复消息字段之间更改字段是二进制兼容的(有关消息布局和其他限制,请参见下面的 Maps)。但是,更改的安全性取决于应用程序:在反序列化和重新序列化消息时,使用重复字段定义的客户端将产生语义相同的结果;但是,使用映射字段定义的客户端可能会重新排序条目并删除具有重复键的条目,因为map的key不能重复。

扩展 Extensions

扩展允许我们在message中的声明一系列字段编号,用于第三方扩展。
扩展是原始 .proto 文件中未定义类型的字段的占位符。这允许其他 .proto 文件通过使用这些字段编号来定义字段的类型,最终添加到我们的message定义中。让我们看一个例子:

message Foo {
  // ...
  extensions 100 to 199;
}

这表示 Foo 中的字段编号范围 [100, 199] 是为扩展保留的。其他用户现在可以在他们自己的 .proto 文件中向 Foo 添加新字段,这些文件导入我们的 .proto,使用我们指定范围内的字段编号 - 例如:

extend Foo {
  optional int32 bar = 126;
}

这会在 Foo 的原始定义中添加一个名为 bar 且字段编号为 126 的字段。
具体的操作请查看后续的Java开发指南。
请注意,扩展可以是任何字段类型,包括消息类型,但不能是 oneofs 或 Maps。

嵌套扩展

我们在message内声明扩展

message Baz {
  extend Foo {
    optional int32 bar = 126;
  }
  ...
}

唯一的区别是 bar 被定义在 Baz 的范围内。
这是一个常见的混淆来源:声明一个在message中的嵌套扩展块并不意味着外部类型和扩展类型之间有任何关系。特别是,上面的例子并不意味着 Baz 是 Foo 的任何子类。这意味着符号 bar 是在 Baz 范围内声明的;它只是一个静态成员。

一种常见的模式是在扩展的字段类型范围内定义扩展——例如,这是对 Baz 类型的 Foo 的扩展,其中扩展被定义为 Baz 的一部分:

message Baz {
  extend Foo {
    optional Baz foo_ext = 127;
  }
  ...
}

但是,并没有要求必须在该类型内定义具有该message类型的扩展。你也可以这样做:

message Baz {
  ...
}

// This can even be in a different file.
extend Foo {
  optional Baz foo_baz_ext = 127;
}

事实上,为了避免混淆,最好使用这种语法。如上所述,嵌套语法经常被不熟悉扩展的用户误认为是子类化。

Oneof

当我们的message包含多个可选的字段,并且最多只赋值一个,我们可以使用oneOf。

除共享内存中的所有字段外,其中 oneof 字段与可选字段类似,最多可以同时设置一个字段。设置 oneof 其中的一个成员会自动清除所有其他成员。
可以使用特殊的 case() 或 WhichOneof() 方法检查 oneof 中设置的值(如果有),具体取决于我们选择的语言。
要在 .proto 中定义 oneof,请使用 oneof 关键字,后跟 oneof 名称,在本例中为 test_oneof:

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

注意不能使用required、optional、repeated修饰oneof字段。如果需要向 oneof 添加重复字段,可以使用包含重复字段的消息
生成的代码中,oneof 字段具有与常规可选方法相同的 getter 和 setter。
我们还可以获得一种特殊的方法来检查 oneof 中设置了哪个值(如果有)。

oneof 特性
  • 设置 oneof 字段将自动清除 oneof 的所有其他成员。因此,如果您设置了多个 oneof 字段,则只有您设置的最后一个字段仍有值。

    SampleMessage message;
    message.set_name("name");
    CHECK(message.has_name());
    message.mutable_sub_message();   // Will clear name field.
    CHECK(!message.has_name());
  • 在解析oneof时,则只会使用最后有值的成员。
  • oneof 不支持 Extensions。
  • oneof 不能重复。
  • 反射 API 适用于 oneof 字段。
  • 如果设置了默认值,则会在序列化时进行解析。
  • 添加或者删除oneof字段时需要注意,如果检查oneof字段返回none或者not_set,则意味着我们没有设置oneof字段,或者已经设置不同版本的oneof字段,此时无法区分这个字段是存在被清除还是说不存在。
    oneof在特定场景时,比如这几个条件满足一个不为空时,会很方便。因此考虑场景,避免修改,并控制好版本。

Maps

提供了一种可以实现键值对映射的快捷预发

map<key_type, value_type> map_field = N;

其中 key_type 可以是任何整数或字符串类型(因此,除了浮点类型和字节之外的任何标量类型)。请注意,枚举不是有效的 key_type。 value_type 可以是除另一个映射之外的任何类型。
因此,例如,如果您想创建一个项目映射,其中每个项目消息都与一个字符串键相关联,您可以这样定义它:

map<string, Project> projects = 3;
Map 特性
  • 不支持扩展
  • 不能被repeated, optional, required修饰。
  • 大部分情况下是乱序的,不能以其作为排序标准。
  • 当生成文本格式的.proto文件时,则会按照key进行排序。
  • 重复的key,则使用最后看到的key。

常用的功能几乎涵盖了,如果遇到未提及的用法,可以参与评论,我会更新~ 感谢大家支持~


MurasakiSeiFu
51 声望4 粉丝

德以配位,命以己修。