Protocol Buffer - proto3 指南
in Protocol Buffer with 0 comment

Protocol Buffer - proto3 指南

in Protocol Buffer with 0 comment

此前对 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;
}

字段类型(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)

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

在 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 类型提示
doubledouble
floatfloat
int32int使用变长编码。对负数的编码效率较低。如果定义的字段的值含有负数,那么建议用 sint32 来代替
int64long使用变长编码。对负数的编码效率较低,如果定义的字段的值含有负数,那么建议用 sint64 来代替
uint32int使用变长编码
uint64long使用变长编码
sint32int使用变长编码。相对于 int32,处理负数更高效
sint64long使用变长编码。相对于 int64,处理负数更高效
fixed32int四字节编码。相对于 uint32,如果值大于 2^28 会更高效
fixed64long八字节编码。相对于 uint64,如果值大于 2^56 会更高效
sfixed32int四字节编码
sfixed64long八字节编码
boolboolean
stringString包含 UTF-8 编码或者 7 比特的 ASCII 编码,并且长度不能大于 2^32
bytesByteString包含任意字节序列,长度不超过 2^32

这些标量类型在序列化的时候是如何编码的,后续将会详细介绍。

默认值(Default Value)

当一个已编码消息被解析的时候,如果该消息中不包含任何特定的单个元素,那么转换对象的对应字段将会被设置默认值。不同的类型对应不同的默认值:

注意:一旦消息被解析,我们是无法知晓字段是否被显示设置为默认值还是根本没有设置。我们在定义消息类型的时候,应该注意这个问题。例如,若你不希望默认情况下也发生某些事情,建议不要使用布尔值 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 对应的静态值,作为第一个元素,主要是因为:

我们也可以对相同的值设置不同的静态值,不过需要提前设置 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)

已经定义好的消息很有可能不满足我们的需求,例如,需要在消息中想要添加一个额外的字段,但是又想使用旧格式创建的代码。为防止在更新消息类型时破坏已经存在的代码,建议遵循一下几点规则:

未知字段(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 类型的运行库还处于开发阶段。

Oneof

一个消息体内有许多字段,但是同时只有一个字段会被设置,这时我们就可以使用 oneof 这种特性来完成这种场景,并节省内存。

oneof 字段和普通字段的区别在于 oneof 内的字段共享内存的,且最多只允许同时设置一个字段值。设置 oneof 内的任何一个字段,都会自动清楚其他所有的成员。我们可以使用 case() 或者 WhichOneof() 方法检查哪一个 oneof 内的值被设置,当然使用的方法由你选择的语言决定。

使用 Oneof

我们需要用 oneof 关键字跟随一个 oneof 名称来定义,例如:

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

我们可以在 oneof 定义中添加除 repeated 字段之外的其他任何类型字段。

在生成的代码中,oneof 字段有相同的 getterssetters,我们可以使用特殊的方法来检查哪一个 oneof 值被设置了。这些方法可以根据编程语言去官方的 API 文档查看。

Oneof 特性

向后兼容问题

应非常谨慎地添加和删除 oneof 字段。如果检查 oneof 的值时返回 None/NOT_SET,也就意味着 oneof 字段并未设置,或者已经在不同版本的 oneof 中设置过字段。我们没有办法知道区别以上两种情况,因为我们无法知道一个未知字段是否为 oneof 的成员。

重用注意事项

Maps

Map 数据结构我们已经非常熟悉了,Protocol Buffer 提供了非常短小精悍的语法来定义 Map:

map<key_type, value_type> map_field = N;

这里的 key_type 可以是任何整数或者字符串类型(即除浮点和字节类型外的标量类型)。注意枚举类型不是有效的 key_type。而 value_type 可以是除 Map 类型外的任何类型。

例如:

map<string, Project> projects = 3;

Map 相关的 API 详见官方文档

向后兼容

Map 语法和下面的定义在网线上传输的内容是等同的,所以即是不支持 MapProtocol Buffer 实现也能够正确处理我们的数据:

message MapFieldEntry {
    key_type key = 1;
    value_type value = 2;
}

repeated MapFieldEntry map_field = N;

Packages

我们可以在 .proto 文件中选择性得添加 package 指示符,防止协议消息类型间的命名冲突。

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

我们可以在定义消息类型时通过 package 指示符指示想要使用的类型:

message Foo {
    ...
    foo.bar.Open open = 1;
    ...
}

不同的编程语言使用 package 指示符所生成的代码是不相同的:

Packages and Name Resolution

命名解析方式类似 C++:首先,最内层范围会被搜寻,接下来就是次内层,以此类推,每一个 package 都可以被认为是其内部的父包。以 . 开头意味着从最外层范围开始(例如:.foo.bar.Baz)。

编译器通过解析已导入的 .proto 文件解决了所有类型命名。每种语言的代码生成器都直到如何引用本种语言的每一种类型,即是它们有不同的作用域规则。

Defining Services

若希望通过 RPC (Remote Procedure Call,远程调用)系统使用消息类型,我们可以在 .proto 文件中定义一个 RPC 服务接口,编译器会为我们根据我们选择的编程语言生成服务接口代码和存根(stubs)。例如,我们想要定义一个 RPC 服务:SearchRequest 作为入参,并返回 SearchResponse

service SearchService {
    rpc Search (SearchRequest) returns (SearchResponse);
}

gRPC 是一个最直接使用 Protocol Buffer 协议的 RPC 系统:由 Google 开发的语言和平台中立的开源 RPC 系统。gRPC 配合 Protocol Buffer 得尤其得好,并且使用特殊的 Protocol Buffer 编译器插件从 .proto 文件直接生成相关的 RPC 代码。

如果不想要使用 gRPC,同样可以自己实现 RPC 来使用 Protocol Buffer

这里也有很多第三方为 Protocol Buffer 实现了 RPC 的项目,可参考 Wiki Page

JSON Mapping

Proto3 支持 JSON 规范编码,这使得更加方便系统间分享数据。

如果在 JSON 编码的数据中的某个值缺失了或者该值为 null,反序列化时它将会被解释为合适的默认值。如果一个字段的值为默认值,则在 JSON 编码数据中它会被省略掉,以节省宝贵的空间。一些 Protocol Buffer 实现可能提供了一个选项,决定是否将默认值也输出到 JSON 编码数据中。

有关编码中 type-to-type 的基准表详见官方文档

proto3JSONJSON example
messageobject{"fooBar": v, "g": null, ...}
enumstring"FOO_BAR"
map<K, V>object{"k", v, ...}
repeated Varray[v, ...]
booltrue, falsetrue, false
stringstring"Protocol Buffer"
bytesbase64, string"YWJjMTIzIT8kKiYoKSctPUB+
int32, fixed32, uint32number1, -10, 0
int64, fixed64, uint64string"1", "-1"
float, doublenumber1.1, -10.1, 0, "NaN", "Infinity"
Anyobject{"@type": "url", "f": v, ...}
Timestampstring"1972-01-01T10:00:20.021Z"
Durationstring"1.00034s", "1s"
Structobject
Wrapper typesvarious types2, "2", "foo", true, "true", null, 0, ...
FieldMaskstring"f.fooBar,h"
ListValuearray[foo, var, ...]
Valuevalue
NullValuenull
Emptyobject{}

JSON options

proto3 JSON 实现可提供以下的选择: