简书Go语言专栏java基础

Proto3 语言指南

2017-07-12  本文已影响4018人  黄海佳

由于工程项目中拟采用一种简便高效的数据交换格式,百度了一下发现除了采用 xml、JSON 还有 ProtoBuf(Google 出品),赶紧去瞄了一下。花了一个周末的时间把它走马观花的学习了一下,顺便将官方的指南翻译了出来。

首先申明,哥们儿英语高中水平,借助了必应词典勉强将其意译了出来,如果你发现翻译中有纰漏,请一定不要告诉我~

怕有误人子弟之嫌,先贴上官方文档的地址,本译文仅供参考:
https://developers.google.com/protocol-buffers/docs/proto3

Proto3 语言指南

本指南描述如何使用 ProtoBuf 语言规范来组织你的.proto 文件,以及如何编译.proto 文件来生成相应的操作类。它涵盖了proto3 语法,如果你想查看老版本 proto2 的相关信息,请参考《Proto2 语言指南》

这是一个参考指南---通过一个例子一步一步地介绍本文档描述的 proto3 语言特性,请根据你选择的编程语言参考基础教程

定义消息类型

让我们先来看一个简单的例子。假设你想定义一个搜索请求的消息格式,它包含一个查询字符串、一个你感兴趣的特定页号、以及每页结果数。下面就是这个.proto 文件所定义的消息类型。

syntax = "proto3";


message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}
指定字段类型

在上面的例子中,所有的字段都是 标准 类型:二个整形(page_number 和 resulet_per_page)和一个字符串类型(query)。然而,你也可以用复杂类型来定义字段,包括 枚举 和其它消息类型。

指定标签

通过上面的例子你可以看到,这里每个字段都定义了一个唯一的数值标签。 这些唯一的数值标签用来标识 二进制消息 中你所定义的字段,一旦定义了编译后就无法修改。需要特别提醒的是标签 1–15 标识的字段编码仅占用 1 个字节(包括字段类型和标识标签),更多详细的信息请参考 ProtoBuf 编码 。 数值标签 16–2047 标识的字段编码占用 2 个字节。因此,你应该将标签 1–15 留给那些在你的消息类型中使用频率高的字段。记得预留一些空间(标签 1–15)给将来可能添加的高频率字段。

最小的数值标签是 1, 最大值是 2 29 - 1, 即 536,870,911。 你不能使用的标签范围还有:19000–19999
( FieldDescriptor::kFirstReservedNumber – FieldDescriptor::kLastReservedNumber ),这些是 ProtoBuf 系统预留的,如果你在你的.proto 文件中使用了其中的数值标签,protoc 编译器会报错。同样地,你不能使用保留字段中 reserved 关键字定义的标签。

定义字段的规则

消息的字段可以是一下情况之一:

你能够在 ProtoBuf 编码 中查阅更多的关于 packed 关键字的信息

添加更多的消息类型

同一个.proto 文件中可以定义多个消息类型。这在定义多个相关的消息时非常有用。例如,如果你想针对用于搜索查询的 SearchRequest 消息定义
一个保存查找结果的 SearchResponse 消息,你可以把它们放在同一个.proto 文件中:

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

message SearchResponse {
  ...
}

添加注释

在.proto 文件中,使用 C/C++格式的注释语法 // syntax

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

保留字段

如果你通过直接删除或注释一个字段的方式 更新 了一个消息结构,将来别人在更新这个消息的时候可能会重复使用标签。如果他们以后加载旧版
本的相同的.proto 文件,可能会导致严重的问题。包括数据冲突、 隐秘的 bug 等等。为了保证这种情况不会发生,当你想删除一个字段的时候,
可以使用 reserved 关键字来申明该字段的标签(和/或名字,这在 JSON 序列化的时候也会产生问题)。 将来如果有人使用了你使用 reserved
关键字定义的标签或名字,编译器就好报错。

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

注意:你不能同时在一条 reserved 语句中申明标签和名字。

.proto 文件编译生成了什么?

当你使用 protoc 编译器 编译一个.proto 文件的时候,编译器会根据你选择的语言和你在这个.proto 文件定义的消息类型生成代码,,这些代码的
功能包括:字段值的 getter,setter,消息序列化并写入到输出流,从输入流接反序列化读取消息等。
对于 C++语言,编译器会根据定义的.proto 文件编译生成一个.h 头文件和一个.cc 源码实现文件。
4

标准类型

.proto文件中消息结构里用于定义字段的标准数据类型如下表所示,后面几列是.proto文件中定义的标准类型编译转换后在编程语言中的类型对照。

如果你想了解这些数据类型在序列化的时候如何编码,请参考 ProtoBuf 编码
[1] 在 Java 中,无符号的 32 位整形和 64 位整形都是用的相应的有符号整数表示,最高位储存的是符号标志。
[2] 在任何情况下,给字段赋值都会执行类型检查,以确保所赋的值是有效的。
5
[3] 默认情况下 64 位整数或 32 位无符号整数通常在编码的时候都是用 long 类型表示,但是你可以在设定字段的时候指定位 int 类型。 在任何情况
下,这个值必须匹配所设定的数据类型。参考[2]
[4] Python 中字符串通常编码为 Unicode,但是如果给定的字符串是 ASCII 类型,可以设置位 str 类型(可能会有变化)

默认值

当一个消息被解析的时候,如果在编码后的消息结构中某字段没有初始值,相应的字段在被解析的对象中会被设置默认值。这些默认值都是类型相关的。

可重复类型字段的默认值为(相应编程语言中的)空列表。
需要提醒的是:对于标准数据类型的字段,当消息被解析的时候你是没有办法显示地设定默认值的(例如布尔类型是否默认设置为 false),记住当你定义自己的消息类型的时候不要设置它的默认值。例如,不要在你的消息类型中定义一个表示开关变量的布尔类型字段,如果你不希望它默认初始化为 false 的话。 还要注意的是,在序列化的时候,如果标准类型的字段的值等于它的默认值,这个值是不会存储到介质上的。

枚举

当你定义一个消息的时候,你可能会希望某个字段在预定的取值列表里面取值。 例如,假设你想为 SearchRequest 消息定义一个 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 枚举类型的第一个常量被设置为 0,每个枚举类型的定义中,它的第一个元素都应该是一个等于 0 的常量。 这是因为:

你可以通过给不同的枚举常量赋同样的值的方式来定义别名。 为了定义别名,你需要设置 allow_alias=true,否则编译器会报错。


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 位整数值的范围。 由于枚举的值采用 varient 编码 方式,负数编码效率低所以不推荐。枚举类型可以定义在消息结构体内部(上例所示),也可以定义在外部。如果定义在了外部,同一个.proto 文件中的所有消息都能重用这个枚举类型。当然,你也可以用
MessageType.EnumType 语法格式在一个消息结构内部引用其它消息结构体内部定义的枚举类型来定义字段。

当你用 protoc 编译器编译一个包含枚举类型的.proto 文件时,对于 Java 或 C++编译生成的代码中会包含相应的枚举类型,对于 Python 语言会生成
一个特殊的 EnumDescriptor 类,在 Python 运行时会生成一系列整形的符号常量供程序使用。

在消息反序列化的时候,无法识别的枚举类型会被保留在消息中,但是这种枚举类型如何复现依赖于所使用的编程语言。对于支持开放式枚举类型的编程语言,枚举类型取值超出特定的符号范围,例如 C++和 Go 语言,未知的枚举值在复现的时候简单地以基础型整数形式存储。对于支持封闭式枚举类型的编程语言,例如 Java,未知的枚举值可以通过特殊的访问函数读取。在任何情况下,只要消息被序列化,无法识别的枚举值也会跟着被序列化。

欲详细了解枚举类型如何在消息类型内工作,请根据你选择的编程语言,参考 生成代码参考

使用其它消息类型

你可以使用其它消息类型来定义字段。 假如你想在每一个 SearchResponse 消息里面定义一个 Result 消息类型的字段,你只需要同一个.proto文件中定义 Result 消息,并用它来定义 SearchResponse 中的一个字段即可。

message SearchResponse {
    repeated Result result = 1;
}

message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
}
导入定义

在上面的例子中,Result 消息类型和 SearchResponse 消息类型是定义在同一个文件中的,如果你想用另外一个.proto 文件中定义的消息类型来定义字段该怎么做呢?

你可以导入其它.proto 文件中已经定义的消息,来定义该消息类型的字段。为了导入其它.proto 文件中的定义,你需要在你的.proto 文件头部申明import 语句:

import "myproject/other_protos.proto";

默认情况下,你只能使用直接导入的.proto 文件中的定义。然而,有时候你可能需要移动一个.proto 文件到一个新的位置。如果直接移动这个.proto文件,你需要一次更新所有引用了这个.proto 文件的所有调用站点。你可以将文件移动之后(译注:下面例子中的 new.proto),在原来的位置创建一个虚拟文件(译注:下面例子中的 old.proto),在其中用 import public 申明指向新位置的.proto 文件(译注:这样就实现了跨文件引用,而不需要更新调用站点里面的代码了)。通过 import public 申明的导入依赖关系可以被任何调用了包含该 import public 语句的调用者间接继承。(译注:这段话绕来绕去就是说,默认情况下,a 中 import b,b 中 import c,a 只能引用 b 里面的定义,而不能引用 c 里面的定义;如果你想 a 跨文件导入引用 c 里面的定义,就要在 b 中申明 import public c,这样 a 既能引用 b 里面的定义,又能引用 c 里面的定义了) 例如:

// 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

protoc 编译器通过命令行参数 -I 或 --proto_path 指定导入文件的搜索查找目录 。如果没有指定该参数,默认在编译器所在目录查找。 通常,你需要设定 --proto_path 参数为你的项目根目录,使用全名称目录路径指定导入文件搜索路径。

使用 proto2 消息类型

你在 proto3 中可以引用 proto2 消息类型,反之亦可。 然而,proto2 语法格式的枚举类型,不可以在 proto3 中引用。

消息嵌套

你可以在一个消息结构内部定义另外一个消息类型,如下例所示---Result 消息类型定义在 SearchResponse 消息体内部。

message SearchResponse {

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

    repeated Result result = 1;
}

如果你想在它的父消息外部复用这个内部定义的消息类型,可以采用 Parent.Type 语法格式:

message SomeOtherMessage {
    SearchResponse.Result result = 1;
}

只要你愿意,消息可以嵌套任意层。

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

        }
}
更新一个消息类型

如果现有的消息类型无法满足你的需要---例如,你想为这个消息添加一个字段,但是你又想沿用原来的代码格式,不用担心!
你可以非常简单地就能在不破坏现有代码的基础上更新这个消息类型。只需要遵循以下原则:

Any

Any 类型允许你在没有某些消息类型的.proto 定义时,像使用内嵌的消息类型一样使用它来定义消息类型的字段。一个 Any 类型的消息是一个包含任意字节数的序列化消息,拥有一个 URL 地址作为全局唯一标识符来解决消息的类型。为了使用 Any 类型的消息,你需要import google/protobuf/any.proto

import "google/protobuf/any.proto";

message ErrorStatus {
        string message = 1;
        repeated Any details = 2;
}

给定 Any 消息类型的默认 URL 是: type.googleapis.com/packagename.messagename。
不同的语言实现都会支持运行库帮助通过类型安全的方式来封包或解包 Any 类型的消息。在 Java 语言中,Any 类型有专门的访问函数 pack()和unpack()。在 C++中对应的是 PackFrom()和 PackTo()方法。

// Storing an arbitrary message type in Any.
NetworkErrorDetails details = ...;
ErrorStatus status;
status.add_details()->PackFrom(details);
// Reading an arbitrary message from Any.
ErrorStatus status = ...;

for (const Any& detail : status.details()) {
    if (detail.IsType<NetworkErrorDetails>()) {
          NetworkErrorDetails network_error;
          detail.UnpackTo(&network_error);
          ... processing network_error ...
    }
}

当前,Any 类型的运行时库还在开发中。
如果你已经熟悉 proto2 语法 ,Any 类型就是替代了 proto2 中的 extensions

Oneof

如果你的消息中定义了很多字段,而且最多每次只能有一个字段被设置赋值,那么你可以利用 Oneof 特性来实现这种行为并能节省内存。

Oneof 字段除了拥有常规字段的特性之外,所有字段共享一片 oneof 内存,而且每次最多只能有一个字段被设置赋值。设置 oneof组中的任意一个成员的值时,其它成员的值被自动清除。 你可以用 case()或 WhickOneof()方法检查 oneof 组中哪个成员的值被设置了,具体选择哪个方法取决于你所使用的编程语言。

使用 Oneof

在.proto 文件中定义 oneof 需要用到 oneof 关键字,其后紧跟的是 oneof 的名字。下例中的 oneof 名字是 test_oneof:

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

然后你就可以往 oneof 定义中添加 oneof 的字段了。 你可以使用任何类型的字段,但是不要使用 repeated 可重复字段。

在编译后生成的代码中,oneof 字段和常规字段一样拥有 setter 或 getter 操作函数。根据你所选择的编程语言,你能够找到一个特殊方法函数用来检查是哪一个 oneof 被赋值了。 更多详情请参考 API 参考

Oneof 特性
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message(); // Will clear name field.
CHECK(!message.has_name());
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name"); // Will delete sub_message
sub_message->set_... // Crashes here
SampleMessage msg1;
msg1.set_name("name");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());
向后兼容的问题

在添加或删除 oneof 字段的时候,需要格外小心。 如果检查一个 oneof 值的时候返回 None 或 NOT_SET,意味着这个 oneof 没有字段被赋值过或者如果被赋值过但是使用的是其它版本的 oneof。 没有办法区分它们,因此没法区分传输介质上的一个未知字段是不是 oneof 成
员。(译注:on the wire,我翻译为传输介质不知道准确否?)

标签重用的问题
Map

如果你想定义 map 类型的数据,ProtoBuf 提供非常便捷的语法:

map<key_type, value_type> map_field = N;

其中,key_type 可以任意整数类型或字符串类型(除浮点类型或 bytes 类型意外的任意 标准类型 )。value_Type 可以是任意类型。
例如,你可以创建一个 map 类型的 projects,关联一个 string 类型和一个 Project 消息类型(译注:作为键-值对),用 map 定义如下:

map<string, Project> projects = 3;

map 类型的字段不可重复(不能用 repeated 修饰)。 需要注意的是:map 类型字段的值在传输介质上的顺序和迭代器的顺序是未定义的,你不能指望 map 类型的字段内容按指定顺序排列。
proto3 目前支持的所有语言都 map 类型的操作 API。 欲知详情,请根据你所选择的编程语言阅读 API 参考 中相关内容。

向后兼容性

在传输介质上,下面的代码等效于 map 语法的实现。因此,即使目前 ProtoBuf 不支持 map 可重复的特性依然可以用下面这种(变通的)方式来处理:

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

你可以在.proto 文件中选择使用 package 说明符,避免 ProtoBuf 消息类型之间的名称冲突。(译注:这个和 java 里面包的概念以及 C++中命名空间的作用一样)

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

你可以在消息类型内部使用包描述符的引用来定义字段:

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

包描述符对编译后生成的代码的影响依赖于你所选择的编程语言:

包和名称解析

ProtoBuf 解析包和名称的方式与 C++语言类似:最内层的最先被查找,然后是次内层,以此类推。每个包对于其父包来说都是“内部”的。以一个'.'(英文句点)开头的包和名称 (例如 .foo.bar.Baz)表示从最外层开始查找。

protoc 编译器通过解析被导入的.proto 文件来解析所有的类型名称。 任何一种被支持的语言的代码生成器都知道如何正确地引用每一种类型,即使该语言具有不同的作用域规则。

定义服务

如果想在 RPC(远程过程调用)系统中使用自定义的消息类型,你可以在.proto 文件中定义一个 RPC 服务,protoc 编译器就会根据你选择的编程语言生成服务器接口和存根代码。例如,如果你想定义一个 RPC 服务,拥有一个方法来根据你的 SearchRequest 返回SearchResponse,可以在你的.proto 文件中这样定义:

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

与 ProtoBuf 最佳搭配的 RPC 系统是 gRPC :一个 Google 开发的平台无关语言无关的开源 RPC 系统。gRPC 和 ProtoBuf 能够非常完美的配合,你可以使用专门的 ProtoBuf 编译插件直接从.proto 文件生成相关 RPC 代码。

如果你不想使用 gRPC,你也可以用自己的 RPC 来实现和 ProtoBuf 协作。 更多的关于RPC 的信息请参考 Proto2 语言指南 。

现在也有很多第三方的采用 ProtoBuf 的 RPC 项目在开展中。 我们已知的这类项目列表,请参考 第三方插件 WIKI 主页

JSON 映射

Proto3 支持标准的 JSON 编码,在不同的系统直接共享数据变得简单。下表列出的是基础的类型对照。

在 JSON 编码中,如果某个值被设置为 null 或丢失,在映射为 ProtoBuf 的时候会转换为相应的 默认值 。 在 ProtoBuf 中如果一个字段是默认值,在映射为 JSON 编码的时候,这个默认值会被忽略以节省空间。可以通过选项设置,使得 JSON 编码输出中字段带有默认值。

选项

你可以在.proto 文件中可以声明若干选项。 选项不会改变整个声明的含意,但可能会影响在特定的上下文中它的处理方式。 完整的可用选项列表在文件google/protobuf/descriptor.proto 中定义。

一些选项是文件级的,它们应该声明在最顶级的作用域范围内,不要在消息、枚举或服务定义中使用它们。一些选项是消息级的,应该在消息结构内声明。一些选项是字段级的,它们应该声明在字段定义语句中。选项也可以声明在枚举类型、枚举值、服务类型、服务方法中。然而,目前没有任何有用的选项采用这种方式。

下面列出最常用的一些选项:

option java_package = "com.example.foo";
option java_outer_classname = "Ponycopter";\
repeated int32 samples = 4 [packed=true];
int32 old_field = 6 [deprecated=true];
自定义选项

ProtoBuf 允许你使用自定义选项。这是一个 高级的功能,大多数人不需要。如果你认为你需要创建自定义选项,请参见 Proto2 语言指南 中 更详细的信息。请注意,创建自定义选项使用 extensions 关键字 ,只能用在 proto3 中的创建自定义选项。

编译创建类

要想从.proto 文件 生成 Java,Python,C++、 Go、 Ruby,JavaNano,Objective-C 或 C#代码,你需要在.proto 文件中定义自己的消息类型,并且使用 protoc 编译器来编译它。如果你还没有安装编译器,请下载安装包并按照自述文件中的说明来执行安装。

对于 Go 语言,还需要为编译器安装特殊的代码生成器插件: 请访问它在 Github 上的代码仓储 golang/protobuf ,下载并按照 安装提示操作。
编译器的调用格式如下:

--javanano_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto
上一篇下一篇

猜你喜欢

热点阅读