上一小节,简单熟悉了一下 gRPC,接下来继续学习 Protocol Buffers 的相关知识:

  • 什么是 Protocol Buffers ?
  • .proto 文件怎么写? -> 语言规范
  • .proto 文件写好了怎么用?

本指南描述了怎样使用 protocol buffer 语言来构造你的 protocol buffer 数据,包括 .proto 文件语法以及怎样生成.proto 文件的数据访问类。

1. Protocol Buffers 简介

Protocol Buffers 官网介绍:

Protocol Buffers 是一种与语言中立,平台中立,可扩展的序列化结构化数据的方法,可用于通信协议,数据存储等。使用Protocol Buffers 可以一次定义结构化的数据,然后可以使用特殊生成的源代码轻松地在各种数据流中使用各种语言编写和读取结构化数据。

现在有许多框架等在使用Protocol Buffers。流行的RPC框架gRPC也是基于Protocol Buffers。 Protocol Buffers 目前有2和3两个版本号。

优点

  • 性能较xml,json, thirft等好,效率高
  • 代码生成机制,数据解析类自动生成
  • 支持向后兼容和向前兼容
  • 支持多种编程语言(java,c++,python)

缺点

  • 二进制格式导致可读性差
  • 缺乏自描述

2. 语言指南(proto3)

该部分内容只针对 proto3 语法

2.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;
}
  • .proto 文件的第一个非空,非注释的行用来声明版本: 即 syntax = "proto3",不填写的话默认为 proto2

  • 所述SearchRequest消息定义指定了三个字段(名称/值对),一个查询字符串,结果页面数以及每页的结果个数。每个字段都有一个名称和一种类型。

2.1.1. 指定字段类型

在上述例子中,所有的字段都是 scalar types: 标量类型:两个整型(page_number和result_per_page),一个string类型(query)。当然,你也可以为字段指定其他的合成类型,包括枚举 enumerations:枚举 或其他消息类型。

2.1.2. 分配标识号

正如上述文件格式,在消息定义中,每个字段都有唯一的一个数字标识符(unique number)。这些标识符是用来在消息的 二进制格式 中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。

最小的标识号可以从1开始,最大到2^29 - 1, or 536,870,911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。同样,您不能使用任何以前保留的字段编号。

2.1.3. 指定字段规则

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

  • singular:格式良好的消息可以包含该字段中的零个或一个(但不超过一个)
  • repeated: 此字段可以在格式良好的消息中重复任意次数(包括零)。将保留重复值的顺序。

在proto3中,标量数字类型的重复字段默认使用压缩编码。

您可以在 Protocol Buffer Encoding 中找到有关编码的更多信息。

2.1.4. 添加更多消息类型

在一个.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.5. 添加注释

向.proto文件添加注释,可以使用C/C++/java风格的双斜杠(//) 语法格式,如:

1
2
3
4
5
6
7
8
/* SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response. */

message SearchRequest {
  string query = 1;
  int32 page_number = 2;  // Which page number do we want?
  int32 result_per_page = 3;  // Number of results to return per page.
}

2.1.6. 保留字段

如果你通过移除一个字段或注释掉一个字段来更新一条消息, 将来的用户可以重用这个标签数字. 如果它们延迟加载旧版本的 .proto 的话, 这可能导致一些问题. 一种防止这不会出现的方式是, 为这些字段标识为 reserved . 如果将来任意有人试图使用这些标识的话,编译器会报错。

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

请注意:在 reserved 语句中, 不能混合数字和名字.

2.1.7. 从 .proto 文件中生成了什么?

当用protocolbuffer编译器来运行.proto文件时,编译器将生成所选择语言的代码,这些代码可以操作在.proto文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中,以及从一个输入流中解析消息。

  • For Go: 编译器会生成一个 .pb.go 的文件,且在 .proto 文件中定义的每一个消息类型在 .pb.go 中也有一个类型与之对应。

2.2. 标量数值类型

一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:

view on doc

2.3. 默认值

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

1
2
3
4
5
6
7
string :    空字符串 “”
bytes :     空字节
bool :      false
数值类型 :  0
enum :      首个枚举值, 必须为 0
message     字段 : 这是依赖于具体的语言的
repeated    字段: 通常是一个空列表

注意:一旦消息被解析后,就没有任何方式获知这个字段是否被明确的设置为默认值了(例如:布尔值是否设置为false) 或者说根本就没有设置:在定义消息类型时,你应该牢记这一点。例如:如果你不希望默认情况下也发生这种行为的话,就不要使用 boolean 值,当其被设置为 false 时,会在一些行为中切换。另请注意,如果标量消息字段设置为其默认值,则该值不会在传输过程中序列化。

2.4. 枚举

当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值。例如,假设要为每一个SearchRequest消息添加一个 corpus字段,而corpus的值可能是UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,PRODUCTS或VIDEO中的一个。 其实可以很容易地实现这一点:通过向消息定义中添加一个枚举(enum)就可以了。一个enum类型的字段只能用指定的常量集中的一个值作为其值(如果尝 试指定不同的值,解析器就会把它当作一个未知的字段来对待)。

在下面的例子中,在消息格式中添加了一个叫做Corpus的枚举类型——它含有所有可能的值 ——以及一个类型为Corpus的字段:

 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作为数字的默认值。
  • 枚举的第一个必须是0. (也出于兼容 proto2 的考虑), 并且必须是 32 位的 integer 范围

你可以为枚举常量定义别名。 需要设置allow_alias option 为 true, 否则 protocol编译器会产生错误信息。

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

枚举常量必须在32位整型值的范围内。因为enum值是使用可变编码方式的,对负数不够高效,因此不推荐在enum中使用负数。如上例所示,可以在 一个消息定义的内部或外部定义枚举——这些枚举可以在.proto文件中的任何消息定义里重用。当然也可以在一个消息中声明一个枚举类型,而在另一个不同 的消息中使用它——采用MessageType.EnumType的语法格式。

当对一个使用了枚举的.proto文件运行protocol buffer编译器的时候,生成的代码中将有一个对应的enum(对Java或C++来说),或者一个特殊的EnumDescriptor类(对 Python来说),它被用来在运行时生成的类中创建一系列的整型值符号常量(symbolic constants)。

2.4.1. 保留值

Trun to: 2.1.6. 保留字段

2.5. 使用其他消息类型

你可以将其他消息类型用作字段类型。例如,假设在每一个SearchResponse消息中包含Result消息,此时可以在相同的.proto文件中定义一个Result消息类型,然后在SearchResponse消息中指定一个Result类型的字段,如

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文件中的定义来使用它们。要导入其他.proto文件的定义,你需要在你的文件中添加一个导入声明,如:

import "myproject/other_protos.proto";

默认情况下,您只能使用直接从 .proto 文件中导入的定义,然而,有时候你需要移动一个.proto文件到一个新的位置。现在,你可以放置一个dummy .proto 文件老位置,然后使用 import public 将所有导入转发到新位置。

通过编译器参数 -I 或 –proto_path 来定义一系列的目录来搜索 .proto 文件

2.5.2. 使用 proto2 消息类型

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

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;
}

当然,你也可以将消息嵌套任意多层,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
message Outer {       // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

2.7. 更新一个消息类型

如果一个已有的消息格式已无法满足新的需求 —— 如,要在消息中添加一个额外的字段——但是同时旧版本写的代码仍然可用。不用担心!更新消息而不破坏已有代码是非常简单的。在更新时只要记住以下的规则即可。

  • 不要改变当前已存在的字段的 数字标签 (numeric tag)
  • 如果添加了新的字段, 任意的旧的 message 序列化格式都可以被新的代码解析.
  • 可以删除字段, 只要标签的数字不会被重用即可.
  • int32, uint32, int64, uint64 以及 bool 是相互兼容的. 这意味着你可以从一个字段的类型修改为其他类型.
  • sint32, sint64 是相互兼容的, 但与其他的 integer 类型是不兼容的.
  • string 和 bytes 是相互兼容的, 只要 bytes 都是有效的 UTF-8
  • 内嵌的 message 是兼容 bytes 的, 如果 bytes 包含一个 message 的编码版本.
  • fixed32 是兼容于 sfixed32, fixed64 是兼容于 sfixed64 的
  • enum 是兼容于 int32, uint32, int64 以及 uint64 (注意, 如果值不匹配则会直接截断)

2.8. 未知字段

本来,在 proto3 的消息中,未知字段在解析时总是会被丢弃掉,但在 3.5 版本中,重新启用了对未知字段的保全已匹配 proto2。在 3.5 及以后的版本中,位置字段在解析时被保留并且会包含在序列化输出中。

2.9. Any

Any message 类型允许你使用它来作为内嵌的类型而不必需要在 .proto 文件中定义它们. Any 用 bytes 保存一个任意序列化后的消息, 以及一个 URL,该 URL 充当该消息类型的全局唯一标识符并对其进行解析.

使用 Any type 类型,需要导入 google/protobuf/any.proto

1
2
3
4
5
6
import "google/protobuf/any.proto"

message ErrorStatus {
    string message = 1;
    repeated google.protobuf.Any deatils = 2;
}

2.10. Oneof

如果你的消息中有很多可选字段, 但同一时间, 只允许设置一个字段值., 你可以加强这个行为,使用oneof特性节省内存.

Oneof 字段就像可选字段, 除了它们会共享内存, 至多一个字段会被设置。 设置其中一个字段会清除其它oneof 字段。 你可以使用 case( )或者 WhichOneof() 方法检查哪个oneof字段被设置, 检查方法依语言而定。

2.10.1. 使用 Oneof

为了在.proto定义Oneof字段, 你需要在名字前面加上oneof关键字, 比如下面例子的test_oneof:

1
2
3
4
5
6
message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

然后你可以增加oneof字段到 oneof 定义中. 你可以增加任意类型的字段, 但是不能使用 repeated 关键字.

在产生的代码中, oneof字段拥有同样的 getters 和setters, 就像正常的可选字段一样. 也有一个特殊的方法来检查到底那个字段被设置. 你可以在相应的语言API中找到oneof API介绍.

2.10.2. Oneof 特性 (??)

  • 设置oneof会自动清楚其它oneof字段的值. 所以设置多次后,只有最后一次设置的字段有值.
  • 如果解析时, 只有最后一次的解析是可见的. ??
  • oneof 字段不能是 repeated 的
  • 反射 api 可以用于 oneof
  • 如果你正使用C++, 请确保你的代码不会导致内存崩溃.

2.10.3. 向后兼容性问题

要注意添加或删除 oneof 字段. 如果检查 oneof 的值返回的是 None 或 NOT_SET , 它可能表示 oneof 还没有设置或它已经设置到了不同版本的 oneof 字段.

2.10.4. 标签重用

  • Move fields into or out of a oneof: You may lose some of your information (some fields will be cleared) after the message is serialized and parsed. However, you can safely move a single field into a new oneof and may be able to move multiple fields if it is known that only one is ever set.
  • Delete a oneof field and add it back: This may clear your currently set oneof field after the message is serialized and parsed.
  • Split or merge oneof: This has similar issues to moving regular fields.

2.11. Maps

语法:

1
map<key_type, value_type> map_field = N;

…where the key_type can be any integral or string type (so, any scalar type except for floating point types and bytes). Note that enum is not a valid key_type. The value_type can be any type except another map.

例子:

1
map<string, Project> projects = 3;
  • map 字段不能是 repeated 的
  • 不能依赖于 map 的值的顺序.
  • 当为 .proto 生成文本格式时, maps 是根据 key 来排序的. 所有 numeric 的 key 是根据数字来排序的.
  • 当合并 maps 时, 重复的 key 仅最后一个可见的 key 会被使用. 当解析为 text 格式时, 如果有重复的 keys 则会失败

2.11.1. 向后兼容

2.12. 包 Package

You can add an optional package specifier to a .proto file to prevent name clashes(命名冲突) between protocol message types.

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

You can then use the package specifier when defining fields of your message type

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

2.12.1. 包及名称的解析

工作原理类似 C++.

首先, 它会在最内层范围搜索, 然后到下一范围再搜索, 以此类推.

2.13. 定义服务

If you want to use your message types with an RPC (Remote Procedure Call) system, you can define an RPC service interface in a .proto file and the protocol buffer compiler will generate service interface code and stubs in your chosen language. So, for example, if you want to define an RPC service with a method that takes your SearchRequest and returns a SearchResponse, you can define it in your .proto file as follows:

如果你想在 RPC 中使用你的 messages 类型. 你可以在 .proto 文件中定义一个 RPC 服务接口, 这样子编译器就会为你的语言生成服务接口了.例如

1
2
3
service SearchService {
    rpc Search (SearcRequest) returns (SearchResponse)
}

2.14. JSON 映射

If a value is missing in the JSON-encoded data or if its value is null, it will be interpreted(解读) as the appropriate(适当的) default value when parsed into a protocol buffer. If a field has the default value in the protocol buffer, it will be omitted(缺省) in the JSON-encoded data by default to save space. An implementation may provide options to emit fields(导出字段) with default values in the JSON-encoded output.

更详细内容请查看 原文档

2.14.1. JSON 选项

2.15. 选项

在定义.proto文件时能够标注一系列的options。Options并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。完整的可用选项可以在google/protobuf/descriptor.proto找到。

选项级别:

  • 文件级别:意味着它可以作用于最外范围,不包含在任何消息内部、enum或服务定义中。
  • 消息级别: 意味着它可以用在消息定 义的内部。
  • 字段级别:即有些选项可以作用在域、enum类型、enum值、服务类型及服务方法中。

2.15.1. 自定义选项

2.16. 生成访问类

可以通过定义好的.proto文件来生成Java、Python、C++代码,需要基于.proto文件运行protocol buffer编译器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文件所在的具体目录。如果忽略该值,则使用当前目录。如果有多个目录则可以 对–proto_path 写多次,它们将会顺序的被访问并执行导入。-I=IMPORT_PATH是它的简化形式。

  • 当然也可以提供一个或多个输出路径:

    • –go_out generates Go code in DST_DIR. See the Go generated code reference for more.
  • 你必须提供一个或多个.proto文件作为输入。多个.proto文件能够一次全部声明。虽然这些文件是相对于当前目录来命名的,每个文件必须在一个IMPORT_PATH中,只有如此编译器才可以决定它的标准名称。

3. 小小总结

使用 gRPC, Protobol Buffers 时的开发流程:

  1. 写好 .proto 文件:消息类型,服务接口等
  2. 生成包含服务接口和存根的源代码文件
  3. 编码调用服务接口完成通信

至此,完。

转载时加入了新增的内容并按自己学习过程中的理解略作修改

4. See Also

Thanks to the authors 🙂