本文大部分内容来自Language Guide (proto3)

本指南主要介绍如何使用Protocol Buffers语言定义Protocol Buffers数据结构,包括.proto文件的语法结构以及如何生成数据访问类。

定义message

首先看一个简单的示例,假设定义一个搜索请求的消息格式,每个消息包含一个关键字,当前页,以及每页显示多少条(经典的分页请求输入参数)。下面是请求消息的.proto示例

syntax = "proto3";

package msg.search;

/* 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.
}
  • 第一行声明使用的语法版本是Proto3,如果不增加这一行,Protocol Buffers编译器(protoc)会以proto2的标准解析。snytax声明必须在文件的第一个非空,非注释行。
  • SearchRequest包含了三个变量。每个字段有字段类型和字段名称。

Packages

上面的示例中package是可选的,package可用于防止多个模块的message的命名冲突。

package foo.bar;

option go_package = "protocol/entity";   //下面解释

message Open { ... }

其它包的message引用此类型时,需要指定package

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

package 定义影响生成代码

  • C++ 生成的类被被封装进命名空间,上面的示例中,Open会被封装进foo::bar

  • Java 除非显示的添加了option java_package,否则package被用于生成Java的package。

  • Python package 指令被忽略,

  • Go 除非显示的添加了option go_package,否则package被用于生成Java的package。

  • ruby 生成的类被包装在嵌套的Ruby 命名空间中,转换为Ruby capitalization风格(第一个字符大写;如果第一个字符不是字母则加一个PB_前缀)

  • C# 除非显示的添加了option csharp_namespace,否则package在转换为PascalCase后用作命名空间。

包和命名解析

Protocol Buffers的类型命名解析类似 C++: 首先搜索最内层的范围, 然后最内层的外一层, 依此类推。每个包都被认为是父包的“内部”包

Protocol Buffers编译器通过解析导入的.proto文件来解析所有的类型命名。特定语言的代码生成器语言中如何引用这些类型,即使语言有不同的作用范围。

注释

Protocol Buffers支持C/C++风格的注释 : // 或者 /…./

字段类型

上面的示例中,字段都是标量类型(scalar types),两个整型变量(page_number,result_per_page),一个字符型变量(query)。Protocol Buffers支持复合类型。包括枚举和已经定义的其它消息类型。

字段标识号(标签)

消息定义中的每个字段都有一个唯一的编号,这些字段编号用于在消息的二进制格式中标识字段。一旦定义的Protocol Buffers数据结构被使用,就不建议再修改这个编号。

字段标识号的取值范围比较大,从0到2^29-1。19000~19999 是Protocol Buffers保留字段,不能被用户使用。

虽然字段编号的范围很大,但是在一个message中,建议将最频繁使用的字段的编号设置为1-15。这是因为字段编号 1 - 15 在编码后只占用1字节(16-2047占用两字节)。这样可以减小编码之后的数据大小。同时,因为message的结构会有变更,为了扩展,在一开始定义message时,1 - 15 之间的编号不要全部占用,防止以后变更出现频繁使用的字段却只能使用双字节的编号。

字段约束

消息的字段,可以使用以下修饰符。

  • singular 良好格式的消息中,只能包含0到1个此字段。

  • repeated 良好格式的消息中,这种字段可以重复任意多次(包括0次)。顺序会被保留。

标量类型

Protocol Buffers支持以下标题类型

.proto C++ Java Python Go Ruby C# PHP
double double double float float64 Float double float
float float float float float32 Float float float
int32 int32 int int int32 Fixnum or Bignum (as required) int integer
int64 int64 long int/long int64 Bignum long integer/string
uint32 uint32 int int/long uint32 Fixnum or Bignum (as required) uint integer
uint64 uint64 long int/long uint64 Bignum ulong integer/string
sint32 int32 int int int32 Fixnum or Bignum (as required) int integer
sint64 int64 long int/long int64 Bignum long integer/string
fixed32 uint32 int int int32 Fixnum or Bignum (as required) uint integer
fixed64 uint64 long int/long uint64 Bignum ulong integer/string
sfixed32 int32 int int int32 Fixnum or Bignum (as required) int integer
sfixed64 int64 long int/long int64 Bignum long integer/string
bool bool boolean bool bool TrueClass/FalseClass bool boolean
string string String str/unicode string String (UTF-8) string string
bytes string ByteString str []byte String (ASCII-8BIT) ByteString string

默认值

在解析消息时,当编码后的消息不包含特定字段时,解析对象中的对应字段会被设置为默认值。

  • string 默认是空的字符串

  • byte 默认是空的bytes

  • bool 默认为false

  • numeric 默认为0

  • enums 定义在第一位的枚举值,也就是0

  • message 对于其它的message类型,字段不会被设置值。根据生成的不同语言有不同的表现。

对于可重复字段,会被设置为空(通常是语言中的空List)。

注意对于标题消息,解析后无法判断消息的标量字段是包含了默认值,还是没包含字段(解析器设置了默认值)。例如,某条消息没包含某一个bool字段,解析器默认填充了false。

同时要注意如果一个标题字段被设置了默认值,序列化时,这个字段是不会被序列化的(即二进制数据中不包含这个字段)。

使用其它消息类型

定义message时,字段类型可以使用其它的message类型。例如:SearchResponse可以包含多个Result

message SearchResponse {
  repeated Result results = 1;
}

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

定义多个消息

在一个.proto文件中定义多个message是允许的,多个相关的消息放一个.proto文件中,会非常有用。例如:搜索请求消息与搜索结果消息放在同一个文件中。

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

message SearchResponse {
 ...
}

嵌入类型

message 中可以定义message,在其它消息中,使用Parent.Type引用嵌入的消息

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

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

枚举

在定义message时,某些字段可能只包含一些预先指定的值,比如SearchRequest中,可能只希望搜索结果类型,可能的列表为 UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,VIDEO,PRODUCTS 。此时,可以在SearchRequest中定义一个enum类型的message包含这些类型。

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

枚举定义在message的内部或者外部都可以的,这些枚举可以在.proto文件的任何message中引用。

引用message内部Enum

syntax = "proto3";

package entity;

option go_package = "protocol/entity";
option java_package = "org.cloud.app.entity";
option java_outer_classname = "Search";

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

message SearchResult{
    string title = 1 ;
    string url = 2;
    SearchRequest.Corpus corpus =3;
}

引用message外部Enum

syntax = "proto3";

package entity;

option go_package = "protocol/entity";
option java_package = "org.cloud.app.entity";
option java_outer_classname = "Search";

enum Corpus {
  UNIVERSAL = 0;
  WEB = 1;
  IMAGES = 2;
  LOCAL = 3;
  NEWS = 4;
  PRODUCTS = 5;
  VIDEO = 6;
}

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

message SearchResult{
  string title = 1 ;
  string url = 2;
  Corpus corpus =3;
}

定义枚举的时候,确保第一个枚举常量是0。枚举常量必须在32位整数范围内。并且不推荐用负数(因为负数的编码效率低)

可以通过将相同的值赋给不同的枚举常量来定义别名,但是必须添加 option 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.
}

引用其它的message定义

上面的示例中,Result类型的message和SearchResponse类型的message定义在同一个文件中的。如果要引用另一个.proto文件中的类型,需要先引用.proto文件。

import "myproject/other_protos.proto";

Protocol Buffers编译器使用-I或者--proto_path指定一组import查找.proto文件的目录。如果没有指定,编译器会在处理文件的目录下查找。

示例

如下目录:

.
└── src
    └── protocol
        ├── entity
        ├── main.go
        └── proto
            ├── enums
            │   └── SearchEnum.proto
            └── search
                ├── SearchRequest.proto
                └── SearchResponse.proto

src/protocol/proto/search/SearchRequest.proto

syntax = "proto3";

import "enums/SearchEnum.proto";

package entity;

option go_package = "protocol/entity";

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

src/protocol/proto/search/SearchResponse.proto

syntax = "proto3";

import "enums/SearchEnum.proto";

package entity;

option go_package = "protocol/entity";

message SearchResult{
  string title = 1 ;
  string url = 2;
  Corpus corpus =3;
}

src/protocol/proto/enums/SearchEnum.proto

syntax = "proto3";

package entity;

option go_package = "protocol/entity";

enum Corpus {
  UNIVERSAL = 0;
  WEB = 1;
  IMAGES = 2;
  LOCAL = 3;
  NEWS = 4;
  PRODUCTS = 5;
  VIDEO = 6;
}

编译命令

$ protoc -I=src/protocol/proto --go_out=src src/protocol/proto/search/*.proto

结果

.
└── src
    └── protocol
        ├── entity
        │   ├── SearchRequest.pb.go
        │   └── SearchResponse.pb.go
        ├── main.go
        └── proto
            ├── enums
            │   └── SearchEnum.proto
            └── search
                ├── SearchRequest.proto
                └── SearchResponse.proto

import 与 import public

举例说明

两种场景

A import B B import C

A import B B import public C

第一种场景 A 不可以引用 C 的message定义,第二种场景 A 可以引用 C 的message定义

小技巧

如果被引用的文件被移动到其它位置,可以在原来的位置写一个同名文件,在里面import public文件的新位置,这就不需要修改引用此文件文件内容了。

未知字段

当使用旧版本的Protocol Buffers程序解析新版本的Protocol Buffers二进制数据时,新增的字段被认为是未知字段。起始Proto3会丢弃未知字段,在3.5版本中,Protocol Buffers引入了未知域的来兼容proto2的行为。

Any

Any 消息类型允许将消息作为嵌入类型使用,而不需要有消息类型定义。Any 字段被序列化为一个任意长度的bytes。使用 Any 类型必须 import google/protobuf/any.proto

import "google/protobuf/any.proto";

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

目前用于处理Any类型的运行时类库正在开发中.

Oneof

如果一个消息有多个字段,但是同一时间只有一个字段会被设置,例如聊天的内容包含 消息类型,图片消息的消息体,文本消息的消息体,语音消息的消息体。为了节约存储,可以使用Oneof特征来约束字段。

使用oneof关键字来在.proto中定义Oneof字段, 后面跟 oneof 名字, 在下面的示例中是 test_oneof

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

oneof字段中可以添加任意类型的字段, 但是不能使用重复(repeated)字段.

Oneof的特征

  • 设置一个Oneof字段,会清除其它已设置的字段。上面的示例中,如果先设置了name,再设置sub_message,会清除之前设置的name。

  • 解析消息时,如果解析到同一个Oneof字段的多个成员。只会保留最后一个。

  • oneof不能是被修饰为重复字段(repeated)

  • Reflection APIs work for oneof fields.(反射API可以做用于oneof字段)

  • 如果使用c++, 下面的代码会crash,因为设置sub_messager的时候,set_name被删除

SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name");      // Will delete sub_message
sub_message->set_...            // Crashes here
  • 同样是C++。如果通过调用Swap()来交换两个带有oneof的消息,每个消息将会有另外一个消息的oneof。

在下面这个案例中, msg1将会有sub_message和msg2会有name.

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成员时要小心,当检查 oneof 值返回 None/NOT_SET时,这可能是因为 Oneof 没有被设置,或者设置了一个新版本增加的(当前版本没有的)Oneof字段。没法区分这两种情况。

  • 标签重用问题

将字段移入或者移出Oneof: 消息被序列化和反序列化可能丢失部分信息(某些字段可能被清除)

删除oneof的一个字段再加回来: 这可能在序列化和解析消息之后清除当前设置的oneof字段。

分割或者合并Oneof: 和移动普通字段一样有类似问题

Maps

如果消息中要包含一个Map,Protocol Buffers提供了快捷语法:

map<key_type, value_type> map_field = N;

其中key_type可以是任何整数或字符串类型,枚举不可以做为key_type。value_type可以是任意类型。

map<string, Project> projects = 3;

注意事项

  • map类型字段不能重复

  • map中的数据是无序的

  • 生成文本格式时,Map按照key排序。

  • 在解析时,重复的key只有最后一个生效。从文本格式解析时,存在重复的key会解析失败。

  • 当value为空时,序列化行为跟据语言有所不同。在C++,java,Python中,值为Value类型的默认值,其它语言则不被序列化。

目前Proto3支持的语言都可以生成Map API

定义服务

如果定义的消息用于RPC(远程过程调用)系统,可以在.proto文件中定义RPC service。编译器会生成服务接口代码和stubs

例如,定义一个服务,接收搜索请求,返回搜索结果

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

最直接使用Protocol Buffer的RPC系统是gRPC:google开源的与平台无关,与语言无关的RPC系统。gRPC允许使用编译器插件通过.proto文件直接生成RPC相关代码。

即使不使用gRPC,也可以在自己实现的RPC系统中使用Protocol Buffer

JSON映射

Proto3支持JSON格式的标准编码,这使系统之间分享数据变得容易。

如果在JSON编码的字段存在缺失或者值为null。Protocol Buffer在解析时会将其设置为对应的默认值。如果一个字段的值是protocol buffer的默认值,在默认情况下,这个字段不会出现在json编码的数据。

JSON的类型映射,参见 Language Guide (proto3)

JSON选项

这里貌似是说一个proto3的JSON实现应该具有哪些选项。

Proto3的JSON实现应该提供以下选项:

  • Emit fields with default values

Proto3的JSON输出默认值字段被省略。Proto3的JSON实现应该提供一个选项,用于覆盖此行为,输出默认值的字段。

  • Ignore unknown fields

解析器默认拒绝未知字段,但是,应该提供一个选项,用于忽略未知字段。

  • Use proto field name instead of lowerCamelCase name

默认情况下,Proto3 JSON打印工具应该将字段名转换成小写作为JSON字段名称。Proto3的JSON实现应该提供一个选项,用于使用Proto字段名做为JSON字段名。解析器应该可以接受转换后的小写字符的字段名和proto的字段名。

  • Emit enum values as integers instead of strings

JSON输出中默认使用枚举的值,Proto3的JSON实现应该提供一个选项,用于把枚举的输出替换成枚举的数字形式。

选项

.proto文件中可以添加选项注释。选项不会改变声明的含义,但会影响上下文的处理方式。可用选项的完整列表在/protobuf/description.proto中定义。

选项分为文件级选项(应该写在文件的最顶级域,而不是在message、enum或service定义中),消息级选项(写在消息定义中),字段级选项(写在字段定义中)。

选项还可以写在枚举类型、枚举值、服务类型和服务方法上。但是目前还没有任何有用的这类选项。

在此列举一些常用的选项:

java_package

option java_package = "com.example.foo";

指定生成Java代码的包名。

如果.proto文件中没有给出显式的java_package选项,那么默认情况下将使用proto包(在.proto文件中使用package关键字指定)。

java_multiple_files

option java_multiple_files = true;

将文件中的messageenumservice生成单独的类,而不是由一个外部类包裹的内部类。(个人理解)

java_outer_classname

option java_multiple_files = true;

指定要生成的最外层Java类的类名,如果在.proto文件中没有指定显式的java_outer_classname,那么编译器把.proto文件名转换成驼峰式的名称做为类名。

**optimize_for **

option optimize_for = CODE_SIZE;

可以设置为SPEEDCODE_SIZELITE_RUNTIME。影响Java与C++代码生成。

SPEED 默认值,速度优先。编译器将生成高度优化的消息序列化、解析和执行其他常见操作的代码。

CODE_SIZE 代码大小优先。编译器会生成最少的类, 会依赖共享的, 基于反射的代码来实现序列化, 解析和其他操作。

LITE_RUNTIME 编译器会生成依赖"lite"运行时类库(用libprotobuf-lite 替代 libprotobuf)的类。lite运行时比完整的库小得多(小一个数量级)但是省略了某些特性,比如描述符和反射。这对于在手机等受限平台上运行的应用程序尤其有用。在此模式下,编译器依然会尽量像SPEED模式那样生成尽量优化的代码。

可以从.proto文件生成了什么?

Protocol Buffer编译器编译一个.proto文件时,编译器会生成选择语言操作消息的代码。包括获取、设置字段值的方法,序列化与反序列化消息的方法。

  • C++ 编译器会为每个.proto文件生成.h头文件和.cc文件。.proto文件中的每个message对应一个类

  • Java 编译器为每个message生成一个.java文件,该文件中包含一个Builder类,用于创建消息类。

  • Python Python有点不太一样,编译器为.proto文件中的每个message类型生成一个含有静态描述符的模块。在运行时(runtime),该模块与一个元类(metaclass)被用来创建所需的数据访问类。

  • go 编译器会位每个message生成了一个.pd.go文件。

  • ruby 编译器会为每个消息类型生成了一个.rb文件。

  • Objective-C 编译器会为每个消息类型生成了一个pbobjc.h文件和pbobjc.m文件,.proto文件中的每一个消息有一个对应的类。

  • C# 编译器会为每个消息类型生成了一个.cs文件,.proto文件中的每一个消息有一个对应的类。

保留字段

假设在日常开发过程中更新message结构,删除了一个字段,或者注释掉一个字段。在更新之后,该字段的字段标识号可以被新字段使用。此时,使用旧版本数据结构的应用会出现无法预测的后果。

为了防止这种情况,在删除字段的同时,可以将删除的字段的标识号或者字段名声明为reserved

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

注意 不可同时将字段的字段名字段编号声明在同一个reserved语句中。即 reserved 2, 15,"foo";

更新message定义

如果现有的message定义无法满足业务需要,例如,消息需要额外的字段,同时需要兼容旧的message定义。别担心,记住下面的规则,更新message定义会非常简单,不会破坏现在有代码。

  1. 不要更改任何现有字段的字段标识符(标签)

  2. 添加新的字段,旧message格式序列化的数据依然可以被新生成的代码解析。注意元素的默认值,以便新旧版本的消息可以交互。同样的新代码创建的消息也可以被旧代码解析,新的字段会被忽略。(如果有 Unkonw 类型的数据貌似会处理这些)

  3. 可以删除字段,但是不要使用删除字段的字段标识符(标签)。你可以重命名字段,或者把字段号声明为reserved

  4. int32, uint32, int64, uint64, 和 bool 都是兼容的。这意味着可以将字段从这几种类型之一更改为另一种类型,而不会破坏向前或向后兼容性。

  5. sint32sint64彼此兼容,但不兼容其他整数类型

  6. 只要bytes是有效的UTF-8。stringbytes彼此兼容。

  7. 如果bytes包含嵌入消息的编码后的数据,嵌入消息和bytes彼此兼容。

  8. fixed32sfixed32彼此兼容,fixed64sfixed64彼此兼容

  9. 枚举类型与int32uint32int64uint64相兼容(如果值不相兼容则会被截断)。但是客户端反序列化之后他们可能会有不同的处理方式。

  10. 将现在字段移入一个新的Oneof是安全并且兼容的

  11. 将多个字段移入新的Oneof不一定安全,除非能确定,同时只有一个字段设置了值。

  12. 将任意多个字段移入现有的Oneof都是不安全的。