Protobuf

Protobuf 概述

Protocol Buffer (简称Protobuf) 是Google出品的性能优异、跨语言、跨平台的序列化库。

2001年初,Protobuf 首先在 Google 内部创建, 我们把它称之为 proto1,一直以来在 Google 的内部使用,其中也不断的演化,根据使用者的需求也添加很多新的功能,一些内部库依赖它。几乎每个 Google 的开发者都会使用到它。

Google 开始开源它的内部项目时,因为依赖的关系,所以他们决定首先把 Protobuf 开源出去。 proto1 在演化的过程中有些混乱,所以Protobuf 的开发者重写了 Protobuf 的实现,保留了 proto1 的大部分设计,以及 proto1 的很多的想法。但是开源的 proto2 不依赖任何的 Google 的库,代码也相当的清晰。2008年7月7日,Protobuf 开始公布出来。

Protobuf 公布出来也得到了大家的广泛的关注, 逐步地也得到了大家的认可,很多项目也采用 Protobuf 进行消息的通讯,还有基于 Protobuf 的微服务框架 GRPC。在使用的过程中,大家也提出了很多的意见和建议,Protobuf 也在演化,于 2016 年推出了 Proto3。 Proto3 简化了 proto2 的开发,提高了开发的效能,但是也带来了版本不兼容的问题。

Protocol Buffer 名称来自于初期一个主要的类的名称 ProtocolBuffer

官方的发布日志中列举了 proto3 的改变:

  • 移除了原始值字段的出现逻辑。
  • 移除了required字段
  • 移除了缺省值
  • 移除了unknown字段 (3.5中又加上了)
  • 移除了扩展,使用Any代替
  • 修复了未知的枚举值的语义
  • 添加了map类型
  • 添加了一些标准类似,比如time、动态数据的呈现
  • 可以使用 JSON 编码代替二进制 proto 编码

Protobuf 与 XML 比较

相比xml, Protocol buffer在序列化结构化数据方面有很多优势:

  • 更简单
  • 小3 到 10 倍
  • 快 20 到 100 倍
  • 更清晰
  • 生成数据访问类, 更容易编程使用

例如,假设想要用 name 和 email 来构建一个 Person。在XML中,需要这样做:

<person>
    <name>John Doe</name>
    <email>jdoe@example.com</email>
</person>

而对应的 protocol buffer 消息(使用 protocol buffer 文本格式):

// protocol buffer的文本展示
// 这不是实际使用的二进制格式。
person {
  name: "John Doe"
  email: "jdoe@example.com"
}

当这个消息被编码为 protocol buffer 二进制格式(上面的文本格式仅仅是在调试和编辑时方便人阅读的表示方式),它将可能是长 28 个字节并花费 100-200 纳秒来解析。XML版本至少需要 69 个字节,如果删除空白字符,并将花费 5000 - 10000 纳秒来解析。

另外,操作 protocol buffer 也更简单:

cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;

而使用XML,将不得不做类似的事情:

cout << "Name: "
   << person.getElementsByTagName("name")->item(0)->innerText()
   << endl;
cout << "E-mail: "
   << person.getElementsByTagName("email")->item(0)->innerText()
   << endl;

当然,Protocol buffer 也不总是比 XML 更合适。例如,Protocol buffer 不适合建模基于文本的标志(如 HTML)文档,因为无法轻易的使用文本交替结构。此外,XML 是 human-readable 和 human-editable 的。Protocol buffer,至少他们原生的格式不是。XML 也是某种程度上的自描述。Protocol buffer 只有当有消息定义(.proto文件)时才有意义。

Protobuf 语法

定义一个消息类型

假设现在要定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的 .proto 文件了:

syntax = "proto3";

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

在这里:

  1. 第一行指明当前使用的是 proto3 语法, 如果不指定则默认为proto2。必须是 .proto 文件的除空行和注释内容之外的第一行

  2. SearchRequest 消息定义指明了 3 个字段,每个字段有名字和类型。

指定字段类型

在上面的例子中,所有的字段都是标量类型:两个整型和一个字符串类型,此外还有其他字段类型(下面会总结)。

分配标识号

可以看到消息定义的每个字段都有一个唯一的数字标识符。这个标识符用于在消息的二进制格式中标识字段, 一旦消息类型被使用后不可以再修改。

注意标识符的值在 1 和 15 之间时,编码只需一个字节。标识符 在16 到 2047 之间将占用两个字节。因此应该将从 1 到 15 的标识符分派给最频繁出现的消息元素。记得保留一些空间给未来可能添加的频繁出现的元素。

最小的标识号可以从 1 开始,最大到 2 的 29 次方 - 1(536,870,911),另外 19000 到 19999(FieldDescriptor::kFirstReservedNumber through FieldDescriptor::kLastReservedNumber)不能使用,Protobuf协议实现中对这些进行了预留。

指定字段规则

消息字段有以下两种属性:

  • singular:一个格式良好的消息应该有 0 个或者 1 个这种字段(但是不能超过 1 个)。(没有使用 repeated 默认属于这种属性)
  • repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括 0 次)。重复的值的顺序会被保留。(在 go 里面会被转化为数组

在 proto3 中,repeated 的标量域默认情况下会使用 packed 编码(后面说)。

定义多个消息类型

在一个 .proto 文件中可以定义多个消息类型。在定义多个相关的消息的时候,这一点特别有用——例如,如果想定义与 SearchResponse消息类型对应的回复消息格式的话,可以将它添加到相同的 .proto 文件中,如:

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

message SearchResponse {
 ...
}

添加注释

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

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

保留标识符(Reserved)

当更新消息类型,需要彻底删除或者注释掉一个字段时,以后的用户在更新这个类型的时候可以重用这些标识号。如果他们后来使用同一个文件的旧版本加载,会导致严重的问题,包括数据损坏、隐私错误等等。现在有一种确保不会发生这种情况的方法就是为字段 tag(reserved name 可能会 JSON 序列化的问题)指定 reserved 标识符,protocol buffer 的编译器会警告未来尝试使用这些域标识符的用户。

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

注:不要在同一行 reserved 声明中同时声明名字和标签数字

从 .proto 生成的文件

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

  • 对 C++ 来说,编译器会为每个 .proto 文件生成一个 .h 文件和一个 .cc 文件,.proto 文件中的每一个消息有一个对应的类。
  • 对 Java 来说,编译器为每一个消息类型生成了一个 .java 文件,以及一个特殊的 Builder 类(该类是用来创建消息类接口的)。
  • 对 Python 来说,有点不太一样——Python 编译器为 .proto 文件中的每个消息类型生成一个含有静态描述符的模块,该模块与一个元类(metaclass)在运行时(runtime)被用来创建所需的 Python 数据访问类。
  • 对 Go 来说,编译器会位每个消息类型生成了一个 .pd.go 文件。
  • 对于 Ruby 来说,编译器会为每个消息类型生成了一个 .rb 文件。
  • 对 javaNano 来说,编译器输出类似于 java 但是没有 Builder 类
  • 对于 Objective-C 来说,编译器会为每个消息类型生成了一个 pbobjc.h 文件和 pbobjcm 文件,.proto 文件中的每一个消息有一个对应的类。
  • 对于 C# 来说,编译器会为每个消息类型生成了一个 .cs 文件,.proto 文件中的每一个消息有一个对应的类。

Protobuf 的类型与编程语言的对应

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

.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 使用变长编码,对于负值的效率很低,如果值有可能有负值,使用sint32替代 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
int64 使用变长编码,对于负值的效率很低,如果值有可能有负值,使用sint64替代 int64 long int/long int64 Bignum long integer/string
uint32 使用变长编码 uint32 int int/long uint32 Fixnum 或者 Bignum(根据需要) uint integer
uint64 使用变长编码 uint64 long int/long uint64 Bignum ulong integer/string
sint32 使用变长编码,这些编码在负值时比int32高效的多 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
sint64 使用变长编码,有符号的整型值。编码时比通常的int64高效。 int64 long int/long int64 Bignum long integer/string
fixed32 总是4个字节,如果数值总是比228大的话,这个类型会比uint32高效。 uint32 int int uint32 Fixnum 或者 Bignum(根据需要) uint integer
fixed64 总是8个字节,如果数值总是比256大的话,这个类型会比uint64高效。 uint64 long int/long uint64 Bignum ulong integer/string
sfixed32 总是4个字节 int32 int int int32 Fixnum 或者 Bignum(根据需要) int integer
sfixed64 总是8个字节 int64 long int/long int64 Bignum long integer/string
bool bool boolean bool bool TrueClass/FalseClass bool boolean
string 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。 string String str/unicode string String (UTF-8) string string
bytes 可能包含任意顺序的字节数据。 string ByteString str []byte String (ASCII-8BIT) ByteString string
  1. 在 java 中,无符号 32 位和 64 位整型被表示成他们的整型对应形式,最高位被储存在标志位中。
  2. 对于所有的情况,设定值会执行类型检查以确保此值是有效。
  3. 64 位或者无符号 32 位整型在解码时被表示成为 long,但是在设置时可以使用int型值设定,在所有的情况下,值必须符合其设置其类型的要求。
  4. python中 string 被表示成在解码时表示成 unicode。但是一个 ASCII string 可以被表示成 str 类型。
  5. Integer 在 64 位的机器上使用,string 在 32 位机器上使用

默认值

当一个消息被解析的时候,如果被编码的信息不包含一个特定的简单元素,被解析的对象所对应的字段被设置为默认值,对于不同类型指定如下:

  • 对于string,默认是一个空 string
  • 对于bytes,默认是一个空的 bytes
  • 对于 bool,默认是 false
  • 对于数值类型,默认是 0
  • 对于枚举,默认是第一个定义的枚举值,必须为 0;
  • 对于消息类型(message),如果没有被设置,确切的消息是根据语言确定的。

对于可重复的字段,默认值是空(通常情况下是对应语言中空数组)。

对于简单字段,一旦消息被解析,就无法判断这个字段时有设置值但是恰巧是默认值,还是根本没有被设置(例如 boolean 值是否被设置为 false)。另外,如果一个简单消息字段被设置为默认值,这个值不会被序列化传输

更新一个消息类型

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

  • 不要更改任何已有的字段的数值标识。
  • 如果增加新的字段,使用旧格式的字段仍然可以被新产生的代码所解析。应该记住这些元素的默认值,这样新代码就可以以适当的方式和旧代码生成的数据交互。相似的,通过新代码产生的消息也可以被旧代码解析:只不过新的字段会被忽视掉。注意,未被识别的字段会在反序列化的过程中丢弃掉,所以如果消息再被传递给新的代码,新的字段依然是不可用的(这和 proto2 中的行为是不同的,在 proto2 中未定义的域依然会随着消息被序列化)
  • 非 required 的字段可以移除,只要它们的标识号在新的消息类型中不再使用(更好的做法可能是重命名那个字段,例如在字段前添加“OBSOLETE_”前缀,那样的话,使用的 .proto 文件的用户将来就不会无意中重新使用了那些不该使用的标识号)。
  • int32, uint32, int64, uint64, 和 bool 是全部兼容的,这意味着可以将这些类型中的一个转换为另外一个,而不会破坏向前、 向后的兼容性。如果解析出来的数字与对应的类型不相符,那么结果就像在 C++ 中对它进行了强制类型转换一样(例如,如果把一个 64 位数字当作 int32 来读取,那么它就会被截断为 32 位的数字)。
  • sint32 和 sint64 是互相兼容的,但是它们与其他整数类型不兼容。
  • string 和 bytes 是兼容的(只要 bytes 是有效的 UTF-8 编码)。
  • 嵌套消息与 bytes 是兼容的(只要 bytes 包含该消息的一个编码过的版本)。
  • fixed32 与 sfixed32 是兼容的,fixed64 与 sfixed64 是兼容的。
  • 枚举类型与 int32,uint32,int64 和 uint64 相兼容(注意如果值不相兼容则会被截断),然而在客户端反序列化之后他们可能会有不同的处理方式,例如,未识别的 proto3 枚举类型会被保留在消息中,但是他的表示方式会依照语言而定。int 类型的字段总会被保留。

字段的类型

枚举

当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值。例如,假设要为每一个 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 枚举的第一个常量映射为 0:每个枚举类型必须将其第一个类型映射为0,这是因为:

  • 必须有一个 0 值,可以用这个 0 值作为默认值。
  • 这个零值必须为第一个元素,为了兼容 proto2 语义,枚举类的第一个值总是默认值。

可以通过将相同值赋值给不同的枚举常量来定义别名. 为此需要设置allow_alias选项为true, 否则当发现别名时protocol编译器会生成错误消息。

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  // 此时 RUNNING 是 STATRTED 的别名
  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)。

在反序列化的过程中,无法识别的枚举值会被保存在消息中,虽然这种表示方式需要依据所使用语言而定。在那些支持开放枚举类型超出指定范围之外的语言中(例如C++和Go),在 GO 中,因为枚举类型以 int32 来表示,所以对应的值依然用 int32 解析出来,只不过没有对应的枚举值而已。在使用封闭枚举类型的语言中(Java),使用枚举中的一个类型来表示未识别的值,并且可以使用所支持整型来访问。在其他情况下,如果解析的消息被序列号,未识别的值将保持原样。

自定义类型

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

message SearchResponse {
  repeated Result results = 1;
}

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

导入其他文件中的类型

如果是希望导入其他 .proto 文件中的类型定义,可以在文件中添加一个导入声明:

import "myproject/other_protos.proto";

默认情况下只能使用直接导入的 .proto 文件中的定义。然而,有时候需要移动一个 .proto 文件到一个新的位置,可以不直接移动 .proto 文件,只需放入一个伪 .proto 文件在老的位置, 然后使用 import public 转向新的位置。import public 依赖性会通过任意导入包含import public 声明的 proto 文件传递。例如:

// new.proto
// All definitions are moved here
// 这是旧的proto
// 这是所有客户端正在导入的包
import public "new.proto";
import "other.proto";
// 客户端 proto
import "old.proto";
// 现在你可以使用新旧两种包的proto定义了。

通过在编译器命令行参数中使用 -I/--proto_pathprotocal 编译器会在指定目录搜索要导入的文件。如果没有给出标志,编译器会搜索编译命令被调用的目录。通常只要指定 proto_path 标志为工程根目录,并且指定好导入的正确名称就好。

使用 proto2 的消息类型

导入 proto2 的消息类型并在 proto3 消息中使用是可以的,反之也如此。但是,proto2 的枚举不能在 proto3 语法中使用

嵌套类型

可以在其他消息类型中定义、使用消息类型,在下面的例子中,Result 消息就定义在 SearchResponse 消息内,如:

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 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 类型包括一个可以被序列化 bytes 类型的任意消息,以及一个 URL 作为一个全局标识符和解析消息类型。为了使用Any类型,你需要导入import google/protobuf/any.proto

import "google/protobuf/any.proto";

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

对于给定的消息类型的默认类型 URL 是 type.googleapis.com/packagename.messagename

不同语言的实现会支持动态库以线程安全的方式去帮助封装或者解封装 Any 值。例如在 java 中,Any类型会有特殊的 pack()unpack() 访问器,在C++中会有 PackFrom()UnpackTo() 方法。

// 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.Is<NetworkErrorDetails>()) {
    NetworkErrorDetails network_error;
    detail.UnpackTo(&network_error);
    ... processing network_error ...
  }
}

目前,用于Any类型的动态库仍在开发之中

如果熟悉proto2语法,使用Any替换 extensions 关键字。

Oneof

如果消息中有很多可选字段,并且同时至多一个字段会被设置, 可以通过使用 Oneof 特性来强化这个行为并节省内存。

Oneof 字段就像可选字段, 除了它们会共享内存,并且同一时间最多一个字段会被设置。 设置其中一个字段会清除其它字段。 可以使用 case() 或者 WhichOneof() 方法检查哪个 oneof 字段被设置,这取决于使用什么编程语言。

因为 proto3 没有办法区分正常的值是否是设置了还是取得缺省值(比如 int64 类型字段,如果它的值是 0,无法判断数据是否包含这个字段,因为 0 既可能是数据中设置的值,也可能是这个字段的零值),所以可以通过 Oneof 取得这个功能,因为 Oneof 有判断字段是否设置的功能。

使用 Oneof

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

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

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

在产生的代码中, oneof 字段拥有同样的 getters 和setters, 就像正常的可选字段一样,也有一个特殊的方法来检查到底哪个字段被设置。

Oneof 特性

  • 设置 oneof 会自动清楚其它 oneof 字段的值。所以设置多次后,只有最后一次设置的字段有值。
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message();   // Will clear name field.
CHECK(!message.has_name());
  • 如果解析器遇到同一个 oneof 中有多个成员,只有看到的最后一个成员会被解析成消息。
  • oneof 不支持 repeated.
  • 反射 API 对 oneof 字段有效.
  • 如果使用 C++,需确保代码不会导致内存泄漏。下面的代码会崩溃, 因为 sub_message 已经通过 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 的值返回 None/NOT_SET,它意味着 oneof 字段没有被赋值或者在一个不同的版本中赋值了。 没有办法知道是哪种情况,因为没有办法判断一个未知字段是否是 oneof 的成员。

Tag 重用问题

  • 将字段移入或移除oneof:在消息被序列号或者解析后,可能会失去一些信息(有些字段也许会被清除)
  • 删除一个字段或者加入一个字段:在消息被序列号或者解析后,这也许会清除现在设置的 oneof 字段
  • 分离或者融合oneof:和移动普通字段一样有类似问题。

Map

如果希望创建一个关联映射,protocol buffer 提供了一种快捷的语法:

map<key_type, value_type> map_field = N;

其中 key_type 可以是任意 Integer 或者 string 类型(所以,除了 floating 和 bytes 的任意简单类型都是可以的)。

value_type 可以是任意类型。

例如,如果希望创建一个 project 的映射,每个 Projecct 使用一个 string 作为 key,可以像下面这样定义:

map<string, Project> projects = 3;
  • Map 的字段不可以是 repeated。
  • 序列化后的顺序和 map 迭代器的顺序是不确定的,所以不要期望以固定顺序处理 Map。
  • 当为 .proto 文件产生生成文本格式的时候,map 会按照 key 的顺序排序,数值化的 key 会按照数值排序。
  • 从序列化中解析或者融合时,如果有重复的 key 则后一个 key 不会被使用,当从文本格式中解析 map 时,如果存在重复的 key,则可能会导致解析失败。
  • 如果为映射字段提供键但没有值,则序列化字段时的行为取决于语言。在 C ++,Java 和 Python 中,该类型的默认值已序列化,而在其他语言中,则没有序列化。

向后兼容性问题

map语法序列化后等同于如下内容,因此即使是不支持 map 语法的protocol buffer 实现也是可以处理数据的:

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

repeated MapFieldEntry map_field = N

Package

当然可以为 .proto 文件新增一个可选的 package 声明符,用来防止不同的消息类型有命名冲突。如:

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

在其他的消息格式定义中可以使用包名+消息名的方式来定义字段的类型,如:

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

包的声明符会根据使用语言的不同影响生成的代码。

  • 对于 C++,产生的类会被包装在 C++ 的命名空间中,如上例中的 Open 会被封装在 foo::bar 空间中。
  • 对于 Java,包声明符会变为 java 的一个包,除非在 .proto 文件中提供了一个明确有 java_package
  • 对于 Python,这个包声明符是被忽略的,因为 Python 模块是按照其在文件系统中的位置进行组织的。
  • 对于 Go,包可以被用做 Go 包名称,除非显式的提供一个 option go_package 在 .proto 文件中。
  • 对于 Ruby,生成的类可以被包装在内置的 Ruby 名称空间中,转换成 Ruby 所需的大小写样式 (首字母大写;如果第一个符号不是一个字母,则使用PB_前缀),例如 Open 会在 Foo::Bar 名称空间中。
  • 对于 javaNano 包会使用 Java 包,除非在文件中显式的提供一个 option java_package
  • 对于 C# 包可以转换为 PascalCase 后作为名称空间,除非你在你的文件中显式的提供一个 option csharp_namespace,例如,Open会在 Foo.Bar 名称空间中

包及命名的解析

Protocol buffer 语言中类型名称的解析与 C++ 是一致的:首先从最内部开始查找,依次向外进行,每个包都被认为是它父包的”内层”。当然对于 (foo.bar.Baz)这样以“.”分隔的意味着是从最外围开始的。

ProtocolBuffer 编译器会解析 .proto 文件中定义的所有类型名。 对于不同语言的代码生成器会知道如何来指向每个具体的类型,即使它们使用了不同的规则。

定义服务(Service)

如果想要将消息类型用在 RPC (远程方法调用)系统中,可以在 .proto 文件中定义一个 RPC 服务接口,protocol buffer 编译器将会根据所选择的不同语言生成服务接口代码及存根。如,想要定义一个 RPC 服务并具有一个方法,该方法能够接收 SearchRequest 并返回一个 SearchResponse,此时可以在 .proto 文件中进行如下定义:

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

最直观的使用 protocol buffer 的 RPC 系统是 Go 的 RPC 框架 gRPC,一个由谷歌开发的语言和平台中的开源的 PRC 系统,gRPC 在使用 protocl buffer 时非常有效,如果使用特殊的 protocol buffer 插件可以直接从 .proto 文件中产生相关的RPC代码。

如果不想使用 gRPC,也可以使用 protocol buffer 用于自己的 RPC 实现。

JSON 映射

Proto3 支持 JSON 的编码规范,使它更容易在不同系统之间共享数据,在下表中逐个描述类型。

如果 JSON 编码的数据丢失或者其本身就是 null,这个数据会在解析成 protocol buffer 的时候被表示成默认值。如果一个字段在 protocol buffer 中表示为默认值,那么它在转化成 JSON 编码的时候会被忽略掉以节省空间。

proto3 JSON JSON 示例 注意
message object {“fBar”: v, “g”: null, …} 产生 JSON 对象,消息字段名可以被映射成lowerCamelCase 形式,并且成为 JSON 对象键,null 被接受并成为对应字段的默认值
enum string “FOO_BAR” 枚举值的名字在 proto 文件中被指定
map object {“k”: v, …} 所有的键都被转换成 string
repeated V array [v, …] null 被视为空列表
bool true, false true, false
string string “Hello World!”
bytes base64 string “YWJjMTIzIT8kKiYoKSctPUB+”
int32, fixed32, uint32 number 1, -10, 0 JSON 值会是一个十进制数,数值型或者 string 类型都会接受
int64, fixed64, uint64 string “1”, “-10” JSON 值会是一个十进制数,数值型或者 string 类型都会接受
float, double number 1.1, -10.0, 0, “NaN”, “Infinity” JSON 值会是一个数字或者一个指定的字符串如”NaN”,”infinity”或者”-Infinity”,数值型或者字符串都是可接受的,指数符号也可以接受
Any object {“@type”: “url”, “f”: v, … } 如果一个 Any 保留一个特上述的JSON映射,则它会转换成一个如下形式:{"@type": xxx, "value": yyy}否则,该值会被转换成一个JSON对象,@type 字段会被插入所指定的确定的值
Timestamp string “1972-01-01T10:00:20.021Z” 使用RFC 339,其中生成的输出将始终是Z-归一化啊的,并且使用0,3,6或者9位小数
Duration string “1.000340012s”, “1s” 生成的输出总是0,3,6或者9位小数,具体依赖于所需要的精度,接受所有可以转换为纳秒级的精度
Struct object { … } 任意的JSON对象,见struct.proto
Wrapper types various types 2, “2”, “foo”, true, “true”, null, 0, … 包装器在JSON中的表示方式类似于基本类型,但是允许nulll,并且在转换的过程中保留null
FieldMask string “f.fooBar,h” 见fieldmask.proto
ListValue array [foo, bar, …]
Value value 任意JSON值
NullValue null JSON null

Option 选项

定义 .proto 文件时能够标注一系列的 option。Option 并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。

一些选项是文件级别的,意味着它可以作用于最外范围,不包含在任何消息内部、enum 或服务定义中。一些选项是消息级别的,意味着它可以用在消息定义的内部。还有些选项可以作用在字段、enum类型、enum值、服务类型及服务方法中。到目前为止,并没有一种有效的选项能作用于所有的类型。

如下就是一些常用的选项:

  • java_package (文件选项) :这个选项表明生成 java 类所在的包。如果在 .proto 文件中没有明确的声明 java_package,就采用默认的包名。默认方式产生的 java 包名并不是最好的方式,因为 java 包应该是按照应用名称倒序方式进行排序的,而 proto package 不会以这种方式创建包。如果不需要产生 java 代码,则该选项将不起任何作用。如:
option java_package = "com.example.foo";
  • java_outer_classname (文件选项): 该选项表明想要生成 Java 类的名称。如果在 .proto 文件中没有明确的 java_outer_classname 定义,生成的 class 名称将会根据 .proto 文件的名称采用驼峰式的命名方式进行生成。如(foo_bar.proto 生成的 java 类名为 FooBar.java),如果不生成java代码,则该选项不起任何作用。如:
option java_outer_classname = "Ponycopter";
  • optimize_for(文件选项): 可以被设置为 SPEED, CODE_SIZE,或者 LITE_RUNTIME。这些值将通过如下的方式影响 C++及 java 代码的生成:
    • SPEED (default): protocol buffer 编译器将通过在消息类型上执行序列化、语法分析及其他通用的操作。这种代码是最优的。
    • CODE_SIZE: protocol buffer 编译器将会产生最少量的类,通过共享或基于反射的代码来实现序列化、语法分析及各种其它操作。采用该方式产生的代码将比 SPEED 要少得多, 但是操作要相对慢些。当然实现的类及其对外的 API与SPEED 模式都是一样的。这种方式经常用在一些包含大量的 .proto 文件而且并不盲目追求速度的应用中。
    • LITE_RUNTIME: protocol buffer 编译器依赖于运行时核心类库来生成代码(即采用 libprotobuf-lite 替代 libprotobuf)。这种核心类库由于忽略了一些描述符及反射,要比全类库小得多。这种模式经常在移动手机平台应用多一些。编译器采用该模式产生的方法实现与 SPEED 模式不相上下,产生的类通过实现 MessageLite 接口,但它仅仅是 Messager 接口的一个子集。
option optimize_for = CODE_SIZE;
  • cc_enable_arenas(文件选项):对于 C++ 产生的代码启用arena allocation
  • objc_class_prefix(文件选项):设置 Objective-C 类的前缀,添加到所有 Objective-C 前面。proto 文件产生的类和枚举类型。没有默认值,所使用的前缀应该是 proto 推荐的3-5个大写字符,注意2个字节的前缀是 proto 所保留的。
  • deprecated(字段选项):如果设置为 true 则表示该字段已经被废弃,并且不应该在新的代码中使用。在大多数语言中没有实际的意义。在 java 中,这回变成 @Deprecated 注释。在未来,其他语言的代码生成器也许会在字标识符中产生废弃注释,废弃注释会在编译器尝试使用该字段时发出警告。如果字段没有被使用,也不希望有新用户使用它,尝试使用保留语句替换字段声明。
int32 old_field = 6 [deprecated=true];

其他类型

除了前面介绍的类型外,还有一些常见的类型,Protobuf 也提供了定义,比如 TimestampDuration

Protobuf提供了 github.com/golang/protobuf/ptypes/timestamp.Timestampgithub.com/golang/protobuf/ptypes/duration.Duration 两种扩展类型,用来表示时间,并提供了和 go 标准库 time.Timetime.Duration 的转换函数。

可以在 proto 中需要时间戳和 duration 的地方使用这两个类型,而不是使用标准库的 time.Timetime.Duration,因为标注库的类型没有提供 protobuf 序列化反序列化的功能,需要额外处理,所以不如直接使用 Protobuf 提供的对应类型。

同时,它还是以指针的方式定义字段,这也意味着可以分别反序列化的时候,可以区分对应字段是否在数据中存在。

package main

import (
    fmt "fmt"
    "time"

    proto "github.com/golang/protobuf/proto"
    ptypes "github.com/golang/protobuf/ptypes"
)

func main() {
    msg := &WellKnownTypes{
        Now:  ptypes.TimestampNow(),
        Took: ptypes.DurationProto(10 * time.Minute),
    }
    data, err := proto.Marshal(msg)
    if err != nil {
        panic(err)
    }

    err = proto.Unmarshal(data, msg)
    if err != nil {
        panic(err)
    }

    fmt.Println(msg)
}

通过 .proto 文件生成代码

可以通过定义好的 .proto 文件来生成 Java,Python,C++, Ruby, JavaNano, Objective-C,或者 C# 代码,需要基于.proto 文件运行 protocol buffer 编译器 protoc。如果没有安装编译器,下载安装包并遵照 README 安装。对于 Go,还需要安装一个特殊的代码生成器插件。可以通过 GitHub 上的 protobuf 库找到安装过程。

通过如下方式调用 protocol 编译器:

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 --javanano_out=DST_DIR --objc_out=DST_DIR --csharp_out=DST_DIR path/to/file.proto
  • IMPORT_PATH 声明了一个 .proto 文件所在的解析 import 具体目录。如果忽略该值,则使用当前目录。如果有多个目录则可以多次调用 –proto_path,它们将会顺序的被访问并执行导入。-I=IMPORT_PATH--proto_path 的简化形式。
  • 当然也可以提供一个或多个输出路径:
    • --cpp_out 在目标目录 DST_DIR 中产生 C++ 代码。
    • --java_out 在目标目录DST_DIR中产生 Java 代码。
    • --python_out 在目标目录 DST_DIR 中产生 Python 代码。
    • --go_out 在目标目录 DST_DIR 中产生 Go 代码。
    • --ruby_out 在目标目录 DST_DIR 中产生 Ruby 代码。
    • --javanano_out 在目标目录 DST_DIR 中生成 JavaNano,JavaNano 代码生成器有一系列的选项用于定制自定义生成器的输出。
    • --objc_out 在目标目录 DST_DIR 中产生 Object 代码。
    • --csharp_out 在目标目录 DST_DIR 中产生 Object 代码。
    • --php_out 在目标目录 DST_DIR 中产生 Object 代码。

作为一个方便的拓展,如果 DST_DIR 以 .zip 或者 .jar 结尾,编译器会将输出写到一个 ZIP 格式文件或者符合 JAR 标准的 .jar 文件中。注意如果输出已经存在则会被覆盖,编译器还没有智能到可以追加文件。

  • 可以提供一个或多个 .proto 文件作为输入,多个 .proto 文件可以只指定一次。文件路径是相对于当前目录的相对路径命名的,每个文件必须位于其 IMPORT_PATH 下,以便每个文件可以确定其规范的名称。

Protobuf 代码规范

通过遵循下列约定, 可以让 protocol buffer 消息定义和他们对应的类保持一致并容易阅读。

消息和字段名

消息名使用驼峰法,例如, SongServerRequest;字段名使用下划线分隔,例如, song_name。

message SongServerRequest {
  required string song_name = 1;
}

为字段名使用这种命名约定可以得到如下的访问器:

C++:

const string& song_name() { ... }
void set_song_name(const string& x) { ... }

Java:

public String getSongName() { ... }
public Builder setSongName(String v) { ... }

枚举

枚举类型名使用驼峰法(首字母大写),值的名字使用大写加下划线分隔:

enum Foo {
  FIRST_VALUE = 1;
  SECOND_VALUE = 2;
}

每个枚举值以分号(;)结束, 不要用逗号(,)。

服务

如果 .proto 文件定义 RPC 服务, 服务名和任何 rpc 方法应该用驼峰法(首字母大写):

service FooService {
  rpc GetSomething(FooRequest) returns (FooResponse);
}

在 Go 中使用 protobuf

安装 protoc 编译器

下载地址:https://github.com/protocolbuffers/protobuf/releases

在上面寻找相应的版本下载即可

安装插件

Protobuf 核心的工具集是 C++ 语言开发的,官方的 protoc 编译器中并不支持 Go 语言,需要安装一个插件才能生成 Go 代码。用如下命令安装:

go install google.golang.org/protobuf/cmd/protoc-gen-go

此命令会将 protoc-gen-go 可执行文件安装在 $GOPATH/bin 目录下。当编译器调用时传递了--go_out 命令行标志时 protoc 就会使用该插件。--go_out 告诉编译器把 Go 源代码写到哪里。编译器会为每个 .proto 文件生成一个单独的源代码文件。

输出文件的名称是通过获取 .proto 文件的名称并进行两处更改来计算的:

  • 生成文件的扩展名是 .pb.go。比如说 player_record.proto 编译后会得到 player_record.pb.go
  • proto路径(使用 --proto_path-I 命令行标志指定)将替换为输出路径(使用 --go_out 标志指定)。

当运行如下编译命令时:

protoc --proto_path=src --go_out=build/gen src/foo.proto src/bar/baz.proto

编译器会读取文件 src/foo.protosrc/bar/baz.proto,这将会生成两个输出文件build/gen/foo.pb.gobuild/gen/bar/baz.pb.go

如果有必要,编译器会自动生成 build/gen/bar 目录,但是它不能创建 build 或者 build/gen 目录,这两个必须是已经存在的目录。

Package

如果一个 .proto 文件中有包声明,生成的源代码将会使用它来作为Go的包名,如果 .proto 的包名中有 . ,在 Go 包名中会将 . 转换为 _。举例来说 proto 包名 example.high_score 将会生成Go包名 example_high_score

.proto 文件中可以使用 option go_package 指令来覆盖上面默认生成 Go 包名的规则。比如说包含如下指令的一个 .proto 文件

package example.high_score;
option go_package = "hs";

生成的Go源代码的包名是 hs

如果一个 .proto 文件中不包含 package 声明,生成的源代码将会使用 .proto 文件的文件名(去掉扩展名)作为 Go 包名,. 会被首先转换为 _。举例来说,一个名为 high.score.proto 不包含 pack 声明的文件将会生成文件 high.score.pb.go,他的Go包名是 high_score

Message

一个简单的消息声明:

message Foo {}

protocol buffer 编译器将会生成一个名为 Foo 的结构体,实现了 proto.Message 接口的 Foo 类型的指针

type Foo struct {
}

// 重置 proto 为默认值
func (m *Foo) Reset()         { *m = Foo{} }

// String 返回 proto 的字符串表示
func (m *Foo) String() string { return proto.CompactTextString(m) }

// ProtoMessage 作为一个 tag 确保其他人不会意外的实现
// proto.Message 接口
func (*Foo) ProtoMessage()    {}

内嵌的消息

一个 message 可以声明在其他 message 的内部。比如说:

message Foo {
  message Bar {
  }
}

这种情况,编译器会生成两个结构体:Foo Foo_Bar

预定义消息类型

Protobufs 带有一组预定义的消息,称为众所周知的类型(WKT)。这种类型可以理解为可拓展的第三方类型,比如需要使用时间戳时就可以引入。例如,给出如下消息:

import "google/protobuf/struct.proto"
import "google/protobuf/timestamp.proto"

message NamedStruct {
  string name = 1;
  google.protobuf.Struct definition = 2;
  google.protobuf.Timestamp last_modified = 3;
}

生成的Go代码将会像下面这样:

import google_protobuf "github.com/golang/protobuf/ptypes/struct"
import google_protobuf1 "github.com/golang/protobuf/ptypes/timestamp"

...

type NamedStruct struct {
   Name         string
   Definition   *google_protobuf.Struct
   LastModified *google_protobuf1.Timestamp
}

一般来说,不需要将这些类型直接导入代码中。但是,如果需要直接引用其中一种类型,只需导入 github.com/golang/protobuf/ptypes/[TYPE] 包,并正常使用该类型。

字段

编译器会为每个在 message 中定义的字段生成一个 Go 结构体的字段,字段的确切性质取决于它的类型以及它是 singularrepeatedmap 还是 oneof 字段。

需要注意生成的 Go 结构体的字段将始终使用驼峰命名,即使在 .proto 文件中消息字段用的是小写加下划线(应该这样)。大小写转换的原理如下:

  • 首字母会大些,如果 message 中字段的第一个字符是 _,它将被替换为X。
  • 如果内部下划线后跟小写字母,则删除下划线,并将后面跟随的字母大写。

因此,proto字段 foo_bar_baz 在Go中变成 FooBarBaz_my_field_name_2 变为 XMyFieldName_2

单一标量字段

对于字段定义:

int32 foo = 1;

编译器将生成一个带有名为 Foo 的 int32 字段和一个访问器方法 GetFoo() 的结构,该方法返回 Foo 中的 int32 值或该字段的零值(如果字段未设置(数值型零值为0,字符串为空字符串))。

单一 message 字段

给出如下消息类型

message Bar {}

对于一个有Bar类型字段的消息:

// proto3
message Baz {
  Bar foo = 1;
}

编译器将会生成一个Go结构体

type Baz struct {
  Foo *Bar
}

消息类型的字段可以设置为 nil,这意味着该字段未设置,从而有效清除该字段。这不等同于将值设置为消息结构体的“空”实例。

编译器还生成一个func(m * Baz) GetFoo() * Bar辅助函数。如果 m 为 nilfoo 未设置,则此函数返回 nil *Bar 。这让不在中间检查 nil 值而进行链式调用成为可能。

可重复字段

每个重复的字段在 Go 中的结构中生成一个 T 类型的 slice,其中 T 是字段的元素类型。对于带有重复字段的此消息:

message Baz {
  repeated Bar foo = 1;
}

编译器会生成如下结构体:

type Baz struct {
   Foo  []*Bar
}

同样,对于字段定义 repeated bytes foo = 1; 编译器将会生成一个带有类型为 [][]byte 名为 Foo 的字段的 Go 结构体。对于可重复的枚举 repeated MyEnum bar = 2;,编译器会生成带有类型为 []MyEnum名为 Bar 的字段的Go结构体。

下面的例子展示了如何设置值:

baz := &Baz{
  Foo: []*Bar{
    {}, // First element.
    {}, // Second element.
  },
}

如果想要获取字段内容,可以这样做:

foo := baz.GetFoo() // foo type is []*Bar.
b1 := foo[0] // b1 type is *Bar, the first element in foo.

映射字段

每个映射字段会在 Go 的结构体中生成一个 map[TKey]TValue 类型的字段,其中 TKey 是字段的键类型 TValue 是字段的值类型。对于下面这个消息定义:

message Bar {}

message Baz {
  map<string, Bar> foo = 1;
}

编译器生成 Go 结构体:

type Baz struct {
  Foo map[string]*Bar
}

Oneof 字段

对于 oneof 字段,protobuf 编译器会生成一个接口类型为 isMessageName_MyField 的单个字段。它还为其中的每个单一字段(singular field)生成一个结构。这些字段都实现了 isMessageName_MyField 接口。如下面的例子:

package account;
message Profile {
  oneof avatar {
    string image_url = 1;
    bytes image_data = 2;
  }
}

编译器生成 Go 结构体:

type Profile struct {
        // Types that are valid to be assigned to Avatar:
        //      *Profile_ImageUrl
        //      *Profile_ImageData
        Avatar isProfile_Avatar `protobuf_oneof:"avatar"`
}

type Profile_ImageUrl struct {
        ImageUrl string
}
type Profile_ImageData struct {
        ImageData []byte
}

*Profile_ImageUrl*Profile_ImageData 都通过提供一个空的 isProfile_Avatar() 方法来实现 isProfile_Avatar 接口。

如果想要设置值,可以这样做:

p1 := &account.Profile{
  Avatar: &account.Profile_ImageUrl{"http://example.com/image.png"},
}

// imageData is []byte
imageData := getImageData()
p2 := &account.Profile{
  Avatar: &account.Profile_ImageData{imageData},
}

要访问该字段,可以根据使用值上的类型来处理不同的消息类型:

switch x := m.Avatar.(type) {
case *account.Profile_ImageUrl:
        // Load profile image based on URL
        // using x.ImageUrl
case *account.Profile_ImageData:
        // Load profile image based on bytes
        // using x.ImageData
case nil:
        // The field is not set.
default:
        return fmt.Errorf("Profile.Avatar has unexpected type %T", x)
}

编译器还会生成 get 方法 func(m * Profile) GetImageUrl() stringfunc(m * Profile) GetImageData() [] byte。每个 get 函数都返回该字段的值,如果未设置,则返回零值。

枚举

给出如下枚举:

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

编译器将会生成一个枚举类型和一系列该类型的常量。

对于消息中的枚举(像上面那样),类型名字以消息名开头

type SearchRequest_Corpus int32

对于包级别的枚举:

// .proto
enum Foo {
  DEFAULT_BAR = 0;
  BAR_BELLS = 1;
  BAR_B_CUE = 2;
}

Go 中的类型不会对proto中的枚举名称进行修改:

type Foo int32

此类型具有 String() 方法,该方法返回给定值的名称。

Enum() 方法使用给定值初始化新分配的内存并返回相应的指针:

func (Foo) Enum() *Foo

编译器为枚举中的每个值生成一个常量。对于消息中的枚举,常量以消息的名称开头:

const (
        SearchRequest_UNIVERSAL SearchRequest_Corpus = 0
        SearchRequest_WEB       SearchRequest_Corpus = 1
        SearchRequest_IMAGES    SearchRequest_Corpus = 2
        SearchRequest_LOCAL     SearchRequest_Corpus = 3
        SearchRequest_NEWS      SearchRequest_Corpus = 4
        SearchRequest_PRODUCTS  SearchRequest_Corpus = 5
        SearchRequest_VIDEO     SearchRequest_Corpus = 6
)

对于包级别的枚举,常量以枚举名称开头:

const (
        Foo_DEFAULT_BAR Foo = 0
        Foo_BAR_BELLS   Foo = 1
        Foo_BAR_B_CUE   Foo = 2
)

protobuf 编译器还生成从整数值到字符串名称的映射以及从名称到值的映射:

var Foo_name = map[int32]string{
        0: "DEFAULT_BAR",
        1: "BAR_BELLS",
        2: "BAR_B_CUE",
}
var Foo_value = map[string]int32{
        "DEFAULT_BAR": 0,
        "BAR_BELLS":   1,
        "BAR_B_CUE":   2,
}

请注意,.proto 语言允许多个枚举符号具有相同的数值。具有相同数值的符号是同义词。这些在 Go 中以完全相同的方式表示,多个名称对应于相同的数值。反向映射包含数字值的单个条目,数值映射到出现在 proto 文件中首先出现的名称。

服务

默认情况下,Go 代码生成器不会为服务生成输出。如果启用 gRPC 插件(参阅 gRPC Go快速入门指南),则会生成代码以支持 gRPC。

编码解码

在Go中,使用 proto 库的 Marshal 函数来序列化 protocol buffer 数据。指向消息的结构体的指针实现了 proto.Message 接口。调用 proto.Marshal 会返回以其有线格式编码的 protocol buffer。

要解析编码消息,需要使用 proto 库的 Unmarshal 函数。调用它将 buf 中的数据解析为 protocol buffer,并将结果放在结构体中。

func main() {
    p := &pb.Person{
        Id:    1234,
        Name:  "silverming",
        Email: "934933088@qq.com",
        Phones: []*pb.Person_PhoneNumber{
            {
                Number: "0663-15627076633",
                Type:   pb.Person_MOBILE,
            },
        },
    }
    bytes := marshal(p)
    unmarshal(bytes)
}

func marshal(person *pb.Person) []byte {
    p := []*pb.Person{person}
    book := &pb.AddressBook{
        People: p,
    }
    out, err := proto.Marshal(book)
    if err != nil {
        log.Fatalln("Failed to encode address book:", err)
        return nil
    }
    /**
     * [10 53 10 10 115 105 108 118 101 114 109 105 110 103 16 210
     * 9 26 16 57 51 52 57 51 51 48 56 56 64 113 113 46 99 111 109
     * 34 18 10 16 48 54 54 51 45 49 53 54 50 55 48 55 54 54 51 51]
     */
    fmt.Println(out)
    fmt.Println("-------------------------")
    return out
}

func unmarshal(in []byte) *pb.AddressBook {
    book := &pb.AddressBook{}
    if err := proto.Unmarshal(in,book);err != nil {
        log.Fatalln("Failed to parse address book:",err)
        return nil
    }
    /**
     * people:{name:"silverming" id:1234 email:"934933088@qq.com" phones:{number:"0663-15627076633"}}
     */
    fmt.Println(book.String())
    return book
}

protobuf 编码规则

Protobuf 消息由字段(field)构成,每个字段有其规则(rule)、数据类型(type)、字段名(name)、tag,以及选项(option)。比如下面这段代码描述了由10个字段构成的Test消息:

message Test {
    //type 	name	tag	options
    int32 	a = 	1;
    int32 	b = 	2;
    sint32 	c = 	3;
    int32		d =		4[packed=true];
}

序列化时,消息字段会按照 tag 顺序,以 key+val 的格式,编码成二进制数据。

Protobuf 消息序列化之后,会产生二进制数据。这些数据(精确到 bit)按照含义不同,可以划分为6个部分:MSB flagtagwire_type(编码后数据类型)、length(长度)、value(字段值)、以及 padding(填充)。

其中,key 指的是:key = tag << 3 | wire_type,也就是说, key 的最后 3 个比特是 wire_type,剩下的比特是 tag 值。

Protobuf 支持丰富的数据类型,对于不同的数据类型,会使用不同的编码方式(对应不同的 wire_type),主要有以下几种类型:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages(内嵌消息), packed repeated fields(打包重复字段)
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

因此,用 3 个比特来表示 wire_type 已经足够。

Varint 编码规则

假设有下面这个非常简单的消息定义:

message Test1 {
  required int32 a = 1;
}

在应用中, 创建一个 Test1 消息并设置 a 为150。然后序列化这个消息到输出流。如果可以检查编码后的消息,会看到3个字节:

08 96 01

这里采用的就是 varint 编码。

varints 是使用一个或者多个字节序列化整型的方法,越小的数字需要越少数量的字节。

在一个 varint 中的每个字节中, 除了最后一个字节外(设置为0), 前面的字节最高位都设置有 most significant bit (msb)(设置为 1),用来表示后面还有字节需要处理。

例如, 这里有一个数字1,这是一个字节, 因此msb不被设置:

0000 0001

下面是300,这个相对就会复杂一些:

1010 1100 0000 0010

怎么知道这是300呢? 首先将每个字节的 msb 去掉, 这个仅仅是告诉我们是否已经读到数字的结尾(可以看到, 第一个字节被设置了,因为在 varint 中不止一个字节):

// 去掉最高位的 1 bit
1010 1100 0000 0010
→ 010 1100  000 0010

把两个 7bit 的组翻转过来, 然后可以将他们连接起来,去掉前面的0就得到最后的值:

000 0010  010 1100
→  000 0010 ++ 010 1100
→  100101100
→  256 + 32 + 8 + 4 = 300

注: 为了更好的理解这个例子,可以从头到尾来推断一下 300 这个数字的编码过程:

  1. 整型 300 的标准 32 位(4字节)二进制表示为 00000000 00000000 00000001 00101100
  2. 从后向前每次按 7bit 分隔为 0000010 0101100, 剩下全是 0 的忽略
  3. 翻转过来得到 0101100 0000010
  4. 为每个 7bit 增加 msb, 前面 7bit 之前加 1 表示后面还有数据并凑成 8bit 为一个 byte, 最后一个 msb 设置为 0, 这样得到 10101100 00000010

对有符号类型的编码

在上面的例子中,所有和类型 0 关联的 protocol buffer 类型被编码为 varints。但是, 当编码负数的时候, 在有符号整型(sint32和sint64) 和 “标准” 整型类型(int32和int64)之间有一个重要的差别:如果用 int32 或者 int64 作为一个负数的类型, 所得结果的 varint 总是 10 个字节长度,这是因为它被当成一个非常巨大的无符号整型处理(最高位为 1)。如果使用有符号类型, 所得结果的 varint 使用更有效率的 ZigZag 编码。

ZigZag 编码将有符号整型映射到无符号整型,所有绝对值小的值(比如 -1 )数字会得到一个小的 varint 编码值。实现的方式是 “zig-zags”, 在正数和负数整型之间来回摇摆, 因此 -1被编码为1, 1 被编码为 2, -2 被编码为 3, 由此类推, 在下面的表格中可以看到:

原始有符号整型 编码结果
0 0
-1 1
1 2
2 3
2147483647 4294967294
-2147483648 4294967295

换句话说, 对于 sint32, 每个值 n 被编码为:

(n << 1) ^ (n >> 31)

或者 64 位版本:

(n << 1) ^ (n >> 63)

注意第二个移动 (n >> 31) 部分,是一个算数位移运算。 因此,移动的结果要么是0(如果n是正数) 要么是1(如果n是负数)。

当 sint32 或者 sint64 被解析时, 它的值被解码回原始值, 有符号的版本。

64-bit 和 32-bit 编码规则

对于 double 和 fixed64,它们的是类型1, 这会告诉解析器,它们需要一个固定 64 位的数据块;类似的,float 和 fixed32 是类型5,这会告诉解析器会需要一个 32 位的数据块。

Length-delimited 编码规则

字符串String

类型 2 (length-delimited) 意味着值是一个 varint 编码长度加指定数量的数据字节

message Test2 {
  required string b = 2;
}

设置 b 的值为 “testing” 会得到结果:

12 07 74 65 73 74 69 6e 67

下划线字节是 utf8 编码的 “testing”。这里的 key 是 0x12(十进制的18),其求解过程为:tag=2, type=2 ,对 key 进行 Varint 编码后:(key = tag << 3 | wire_type

00010 010

另外 testing 的长度为 7,所以第二个字节是 len = 7,其后面跟随的 7 个字节就是字符串的值 “testing”。

内嵌消息

这是带有一个内嵌消息的消息定义:

message Test3 {
  required Test1 c = 3;
}

Test1 的字段再次设置为150,下面是编码后的版本:

1a 03 08 96 01

可以看到, 最后三个字节和第一个例子里面完全相同(08 96 01),他们前面还有一个数字3 表示后面有三个字节,也就是说,内嵌消息完全是和字符串(wire type = 2)一样对待的。

推导过程如下:

  1. 第一个字节 1A 的二进制是 “0001 1010”
  2. “0001 1010”的位移三位后结果是”011”,表示字段的数字标签值是3, 对应消息定义里面的 c=3
  3. “0001 1010”的后三位”010”值是2, 表示 wire type 为 2, Length-delimited
  4. 从 1A 后按照 varint 读取长度, 03的结果是3, 表示后面有三个字节
  5. 继续读取 3 个字节, 这是内嵌的消息 Test1 c 的内容, 然后按照 Test1 的定义继续解析这三个字节

对于 repeated 字段的编码

如果 proto2 消息定义具有重复的元素(不带[packed = true]选项),则编码消息具有零个或多个具有相同字段编号的键值对。这些重复的值不必连续出现。它们可能与其他字段交错。解析时,元素之间的顺序会保留下来,尽管其他字段的顺序会丢失。

在proto3中,重复字段使用 packed 编码,下面会有提高,这里讲 proto2 的编码有助于理解 packed 编码

通常,编码消息永远不会有一个以上非重复字段的实例。但是,解析器能处理这种实际情况,对于数字类型和字符串,如果同一字段多次出现,则解析器将接受它看到的最后一个值。对于嵌入式消息字段,解析器将合并同一字段的多个实例,就像使用Message :: MergeFrom方法一样。

也就是说,后一个实例中的所有单个标量字段将替换前一个实例中的 singular 字段,repeated 字段会被串联到一块。

这些规则的作用是,解析两个编码的消息的连接所产生的结果与分别解析两个消息并合并结果对象的结果完全相同。也就是说:

MyMessage message;
message.ParseFromString(str1 + str2);

等同于

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

打包重复字段

proto 版本 2.1.0 引入了压缩重复字段,在 proto2 中声明为重复字段,并使用特殊的 [packed = true] 选项。在 proto3 中,默认情况下压缩标量数字类型的重复字段。这些功能类似于重复的字段,但编码方式不同。包含零元素的压缩重复字段不会出现在编码的消息中。否则,该字段的所有元素都将打包为类型为2(定界)的单个键值对。每个元素的编码方式与通常相同,不同之处在于元素之前没有键。也就是说,如果 repeated 字段设置了 packed 选项,则会使用 Length-delimited 格式来编码字段值。

举例来说,有以下消息类型:

message Test4 {
  repeated int32 d = 4 [packed=true];
}

现在假设构造一个 Test4,为重复的字段 d 提供值3、270 和 86942。然后,消息编码后的形式为:

22        // key (field number 4, wire type 2)
06        // payload size (6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)

只能将原始数字类型(使用varint,32位或64位线型的类型)的重复字段声明为 packed

Proto3 中对数字类型的 repeated 字段采用 pack 处理方式,同一个 repeated 元素共享同一个key,之后是字段的整体字节长度,然后是各个元素。因为数字类型天生具有可区分性,不需要额外的分隔符进行区分。

字段顺序

字段编号可以在 .proto 文件中以任何顺序使用。选择使用的顺序对消息的序列化方式没有影响。

序列化消息时,对于如何写入其已知字段或未知字段没有保证的顺序。序列化顺序是一个实现细节,将来任何特定实现的细节都可能更改。因此,protocol buffer 解析器必须能够以任何顺序解析字段。

参考文档

Protobuf3语言指南(汉译 by 千念飞羽)

Protobuf生成Go代码指南

Go Generated Code

在Golang中使用Protobuf

golang使用protobuf简易教程

Protocol Buffer 3 学习笔记——敖小剑的博客

Protocol Buffers-Encoding

Protobuf 终极教程

图解Protobuf编码

Protobuf编码指南