此前对 Protocol Buffer 有了初步的了解,接下来我们需要学会 proto3 的消息是如何定义的。
定义消息类型
首先,我们从一个非常简单的例子入手。通常我们在设计查询接口参数的时候,会包含关键字(query)、页码(page_number)和每页的结果数(result_per_page)三个参数。这里我们用 .proto
文件来定义消息类型:
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
- 第一行指定了下面使用的是 proto3 语法。如果去掉第一行,则 Protocol Buffer 编译器默认你使用的 proto2 语法。与 Java 中的 package 声明类似,该文件的第一行非空且非注释行。
- SearchRequest 消息定义指定了三个字段(name/value),每个字段都对应于要包含在该消息中的数据,且都由名称和类型组成。
字段类型(Field Type)
上面的例子中,所有的字段都是标量类型(scalar type):两个整型(page_number 和 result_per_page)和一个字符串(query)。当然,你也可以组合类型,包括枚举(enumeration)和其他消息类型。
字段号(Field Number)
从上面的例子中,我们可以看到每个字段都有一个独立号码。在消息二进制格式中,字段号被用于标识字段,该字段号一旦定义就不应该再次修改。需要注意的是,字段号为 1 - 15 的字段在编码的时候仅仅占用一个字节,包括字段号和字段类型。字段号为 16 - 2047 的字段将占用两个字节。所以,官方建议我们保留 1 - 15 的号码给高频使用的消息元素(字段)。同时,也建议保留一些空间给未来可能需要添加的高频使用的消息元素。
最小的字段号是 1,最大是 2^29 - 1(即 536,870,911)。但我们不能使用 19000 - 19999(FiledDescriptor::kFirstReservedNumber 到 FiledDescriptor::kLastReservedNumber),因为这些号码被 Protocol Buffer 保留 --- 如果你在 .proto
文件中使用了这些保留号,Protocol Buffer 编译器会报错。同样地,我们也不能使用任何以前保留的字段号(通过 reversed 声明的字段)。
字段规则(Field Rule)
消息字段可以是一下两种之一:
- 单个(singular):不允许有两个及以上的字段相同。proto3 语法默认采用此规则。
- 重复(repeated):字段可以重复任何次数(包括零次)。并且将会保留重复字段对应值的顺序。
在 proto3 中,repeated
声明的数字型字段默认采用 packed
编码。
多消息类型(More Message Type)
我们可以在单个 .proto
文件中定义多个消息类型。有助于我们定义多个相关的消息 --- 比如,如果你想要定义一个响应消息格式:SearchResponse 消息类型,我们可以在同一个 .proto
文件中新增:
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
注释
我们可以使用 C/C++ 风格的注释://
和 /* ... */
在 .proto
文件中添加注释。
/* SearchRequest 代表一个搜索查询,
* 其中分页参数指示了在响应中应该返回哪些数据。 */
message SearchRequest {
string query = 1;
int32 page_number = 2; // 页码数
int32 result_per_page = 3; // 每页返回结果的数量
}
保留字段
如果完全地删除或者注释一个字段,未来的开发者可以重复使用该字段号。如果后来加载了旧版本且相同的 .proto
文件, 将会导致非常严重的问题,包括数据崩溃、隐私等问题。为了防止重复使用已删除的字段号(字段名也会导致 Json 序列化问题),我们可以使用 reserved
关键字进行修饰已删除的字段号或者字段名。任何尝试想要使用这些字段号或者字段名,Protocol Buffer 都不会同意。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foot", "bar";
}
我们不能将字段号和字段名混合在一个 reserved
声明中。
标量值类型(Scalar Value Type)
一个标量消息字段可以是以下类型之一(这里主要展示和 Java 的对应关系,若需要查看所有支持语言的对应关系可点击这里查看):
.proto 类型 | Java 类型 | 提示 |
---|---|---|
double | double | |
float | float | |
int32 | int | 使用变长编码。对负数的编码效率较低。如果定义的字段的值含有负数,那么建议用 sint32 来代替 |
int64 | long | 使用变长编码。对负数的编码效率较低,如果定义的字段的值含有负数,那么建议用 sint64 来代替 |
uint32 | int | 使用变长编码 |
uint64 | long | 使用变长编码 |
sint32 | int | 使用变长编码。相对于 int32,处理负数更高效 |
sint64 | long | 使用变长编码。相对于 int64,处理负数更高效 |
fixed32 | int | 四字节编码。相对于 uint32,如果值大于 2^28 会更高效 |
fixed64 | long | 八字节编码。相对于 uint64,如果值大于 2^56 会更高效 |
sfixed32 | int | 四字节编码 |
sfixed64 | long | 八字节编码 |
bool | boolean | |
string | String | 包含 UTF-8 编码或者 7 比特的 ASCII 编码,并且长度不能大于 2^32 |
bytes | ByteString | 包含任意字节序列,长度不超过 2^32 |
这些标量类型在序列化的时候是如何编码的,后续将会详细介绍。
- 在 Java 中,无符号 32 位 和 64 位整数使用它们对应的有符号对应项表示,最高位存储在符号位中。
- 在任何情况下,给一个字段设置值的时候会执行类型检查,确保该值是有效的。
默认值(Default Value)
当一个已编码消息被解析的时候,如果该消息中不包含任何特定的单个元素,那么转换对象的对应字段将会被设置默认值。不同的类型对应不同的默认值:
- 字符串(string),默认值为空字符串(empty string)
- 字节(bytes),默认值为空字节(empty bytes)
- 布尔(bool),默认值为 false
- 数值(numeric types),默认值为 0
- 枚举(enums),默认值为定义的第一个枚举值,且为 0
- 消息字段(message fields),默认值则依赖所使用的编程语言。
- 对于 repeated 字段,默认值则为空(通常是适当语言的空列表,Empty List)
注意:一旦消息被解析,我们是无法知晓字段是否被显示设置为默认值还是根本没有设置。我们在定义消息类型的时候,应该注意这个问题。例如,若你不希望默认情况下也发生某些事情,建议不要使用布尔值 false 来打开那些行为。值得一提的是,如果一个标量消息类型的字段被设置为了默认值,该值是不会被序列化的(出于减少网络传输的考虑)。
枚举(Enumeration)
在我们定义消息的时候,某个字段的值只可能是已经定义好的多个值中的一个。例如,我们向 SearchRequest 添加一个 corpus 字段,其中 corpus 字段的值可以是 UNIVERSAL、WEB、IMAGES、LOCAL、NEWS、PRODUCTS、VIDEO。我们可以通过添加 enum
来定义以上静态值。
下面的例子就是通过添加一个包含所有可能值的 Corpus 枚举以及 Corpus 类型的字段:
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 枚举的第一个静态值(Constant)和 0 对应。每一个枚举的定义中必须包含一个和 0 对应的静态值,作为第一个元素,主要是因为:
- Protocol Buffer 使用了数字 0 作为了默认值
- 与 0 对应的值必须是第一个元素,主要是为了和 proto2 语义兼容(默认值总是选择第一个元素)
我们也可以对相同的值设置不同的静态值,不过需要提前设置 allow_alias
选项为 true
,否则编译器会报错。
enum EnumAllowingAlias {
option allow_alias = true;
UNKOWN = 0;
STARTED = 1;
RUNNING = 1;
}
如果以上代码中没有设置 allow_alias = true,那么编译器会发现 1 对应了两个静态值,然后抛出警告信息(在 Google 内会导致编译错误)。
枚举值是一个 32 为的整数,因该值在序列化时采用变长编码,负数效率较低,所以不建议使用负数作为枚举值。枚举消息是可以定义在外部的,这样在同一个 .proto
文件中的消息均可重复引用它。当然,我们也可以通过 MessageType.EnumType 来使用来自不同的消息内的枚举类型。
在编译使用过枚举的 .proto
文件时,生成的代码中枚举部分的对应关系:Java 和 C++ 对应 enum
,Python 对应一个特殊的 EnumDescriptor
类。
在反序列化过程中,会保留未识别的枚举值,但该值在反序列化的时候如何显示则取决于编程语言。一些编程语言是支持值超出定义范围的开放枚举类型(如 C++ 和 Go),未知的枚举值简单地存储为整数(基本表现形式)。在具有封闭枚举类型的语言(如 Java)中,枚举中的原始值则需要通过特殊访问符(Spacial Accessor)来访问。不论是哪一种枚举类型,未识别的值都会被序列化。
使用其他消息类型(Other Message Type)
我们可以使用其他消息类型作为另一个消息的字段类型。比如,在 SearchResponse
消息中使用 Result
消息。首先在同一个 .proto
文件中定义 Result
消息,然后在 SearchResponse
中指定一个 Result
类型的字段:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
导入定义(Importing Definition)
在上面的例子中,Result 消息类型被定义在同一个文件中。我们需要在其他 .proto
文件中也需要使用该类型该怎么办呢?
通过简单的 import
语句使用其他 .proto
文件中的定义,且该语句必须在文件的顶部:
import "myproject/other_protos.proto";
默认情况下,我们可以通过直接导入 .proto
文件来使用定义。然而,有时候我们可能需要移动 .proto
文件到其他地方,需要一个一个更新所有已经引用过该文件的文件。其实完全不必如此,我们只需要在老地方放置一个相同文件名的文件,并通过 import public
来导入移动过后的文件。我们可以传递使用被 import public
声明文件的所有定义:
// new.proto
// 所有的定义都移动到这里
// current.proto
// 本文件正被其他文件引用
import public "new.proto";
import "other.proto";
// client.proto
import "current.proto"
// 在这里,我们只能使用 current.proto 和 new.proto 文件中的定义,不能访问到 other.proto
嵌套类型(Nested Type)
我们可以在消息定义中继续定义一个新的消息,也就是所谓的消息嵌套。下面例子中就是将 Result
消息定义在 SearchResponse
消息中:
message SearchResponse {
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
repeated results = 1;
}
如果我们需要在其他消息中使用消息内的消息类型(即使用 SearchResponse
消息中的 Result
消息类型),可通过 Parent.Type
语法来引用:
message SomeOtherMessage {
SearchResponse.Result result = 1;
}
甚至可以多层嵌套:
message Outer { // Level 0
message MiddleA { // Level 1
message Inner { // Level 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleB { // Level 1
message Inner { // Level 2
int32 ival = 1;
bool booly = 2;
}
}
}
更新消息类型(Updating A Message Type)
已经定义好的消息很有可能不满足我们的需求,例如,需要在消息中想要添加一个额外的字段,但是又想使用旧格式创建的代码。为防止在更新消息类型时破坏已经存在的代码,建议遵循一下几点规则:
- 不要尝试修改已经存在的字段号
- 若添加新字段,任何通过
旧
消息格式生成的代码都可以新生成的代码解析。但应当注意新生成的代码能够正确理解新增字段的默认值。同样地,新生成的代码也可以转换为旧消息格式生成的代码,因为在转换过程中会自动忽略掉新添加的字段 - 可以移除字段,但不能够再次使用该字段号。有时也需要重命名字段,比如添加一个前缀:
PREFIX_
,在重命名之前应该用reserved
关键字修饰来保留该字段号,防止未来的使用者偶然地重用了该字段 int32
、uint32
、int64
、uint64
和bool
都是互相兼容的,也就意味着我们可以将其中的一种类型修改为其他类型,不用担心向前和向后兼容。如果一个数字超过了对应类型的表示范围,最终转换过程中将会被截取,比如 64 位的数字将会被截取后 32 位作为最终的结果sint32
和sint64
是互相兼容的,但是不兼容其他整数类型string
和bytes
也是互相兼容的,前提是 bytes 必须是有效的 UTF-8 字符编码的- 内置消息都和
bytes
兼容,前提是 bytes 得包含该消息的编码版本信息 fixed32
与sfix32
、fixed64
和sfixed64
兼容enum
与int32
、uint32
、int64
和uint64
兼容(注意:如果数字溢出了也会被截取)。然而需要注意客户端代码在反序列化时可能会有差别,在 proto3 中未识别的 enum 类型会被保留,但是具体如何显示则和编程语言有关- 将单个值更改为
oneof
成员中是安全且二进制兼容的。如果确定没有在代码中一次设置多个值,那么将多个字段移动至oneof
也是安全的。将任何字段移动到现有的oneof
都是不安全的。
未知字段(Unkown Field)
未知字段是解析器无法识别的字段,但是序列化数据的格式是正确的。例如,当旧格式代码解析带有新字段的新二进制数据时,这些新字段将会被作为未知字段。
Any 类型
Any 消息类型允许我们使用未在 .proto
文件中定义的内置类型。Any 将任意序列化的消息都当作 bytes
,并且需要一个全局标识符来表示并解析它。我们需要添加 import "google/protobuf/any.proto"
来使用 Any:
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}
消息类型 的 默认 URL 格式为:type.googleapis.com/packagename.messagename
。
不同的语言将会实现支持运行时库助手以类型安全的方式打包和解包 Any 值。例如,Java 有特定的 pack()
和 unpack()
访问符来应对 Any 值,而 C++ 则用 PackFrom()
和 UnpackTo()
方法:
// 保存任意消息为 Any 值
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details() -> PackFrom(details);
// 从 Any 值解析为任意消息
ErrorStatus status = ...;
for(const Any& detail : status.details()) {
if(detail.Is<NetworkErrorDetails>()) {
NetworkErrorDetails network_error;
detail.UnpackTo(&network_error);
... 处理 network_error ...
}
}
处理 Any 类型的运行库还处于开发阶段。