一、protobuf初识
(一)protocol buffers 是什么?
protocol buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小、更快、更为简单。你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏根据旧数据结构编译而成并且已部署的程序。
(二)它是如何工作的?
你可以通过在 .proto 文件中定义 protocol buffer message 类型,来指定你想如何对序列化信息进行结构化。每一个 protocol buffer message 是一个信息的小逻辑记录,包含了一系列的 name-value 对。这里有一个非常基础的 .proto 文件样例,它定义了一个包含 "person" 相关信息的 message:
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phone = 4;
}
正如你所见,message 格式很简单 - 每种 message 类型都有一个或多个具有唯一编号的字段,每个字段都有一个名称和一个值类型,其中值类型可以是数字(整数或浮点数),布尔值,字符串,原始字节,甚至(如上例所示)其它 protocol buffer message 类型,这意味着允许你分层次地构建数据。你可以指定 optional 字段,required 字段和 repeated 字段。 你可以在 Protocol Buffer 语言指南 中找到有关编写 .proto
文件的更多信息。
译者注: proto3 已舍弃 required 字段,optional 字段也无法显示使用(因为缺省默认就设置为 optional)
一旦定义了 messages,就可以在 .proto 文件上运行 protocol buffer 编译器来生成指定语言的数据访问类。这些类为每个字段提供了简单的访问器(如 name()和 set_name()),以及将整个结构序列化为原始字节和解析原始字节的方法 - 例如,如果你选择的语言是 C++,则运行编译器上面的例子将生成一个名为 Person 的类。然后,你可以在应用程序中使用此类来填充,序列化和检索 Person 的 messages。于是你可以写一些这样的代码:
Person person;
person.set_name("John Doe");
person.set_id(1234);
person.set_email("jdoe@example.com");
fstream output("myfile", ios::out | ios::binary);
person.SerializeToOstream(&output);
之后,你可以重新读取解析你的 message
fstream input("myfile", ios::in | ios::binary);
Person person;
person.ParseFromIstream(&input);
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;
你可以在 message 格式中添加新字段,而不会破坏向后兼容性;旧的二进制文件在解析时只是忽略新字段。因此,如果你的通信协议使用 protocol buffers 作为其数据格式,则可以扩展协议而无需担心破坏现有代码。 你可以在 API 参考部分 中找到使用生成的 protocol buffer 代码的完整参考,你可以在 协议缓冲区编码 中找到更多关于如何对 protocol buffer messages 进行编码的信息。
(三)为什么不使用 XML?
对于序列化结构数据,protocol buffers 比 XML 更具优势。Protocol buffers:
-
更简单
-
小 3 ~ 10 倍
-
快 20 ~ 100 倍
-
更加清晰明确
-
自动生成更易于以编程方式使用的数据访问类
例如,假设你想要为具有姓名和电子邮件的人建模。在XML中,你需要:
<person>
<name>John Doe</name>
<email>jdoe@example.com</email>
</person>
而相对应的 protocol buffer message(参见 protocol buffer 文本格式)是:
# Textual representation of a protocol buffer.
# This is *not* the binary format used on the wire.
person {
name: "John Doe"
email: "jdoe@example.com"
}
当此消息被编码为 protocol buffer 二进制格式 时(上面的文本格式只是为了调试和编辑的方便而用人类可读的形式表示),它可能是 28 个字节长,需要大约 100-200 纳秒来解析。如果删除空格,XML版本至少为 69 个字节,并且需要大约 5,000-10,000 纳秒才能解析。 此外,比起 XML,操作 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 buffers 并不总是比 XML 更好的解决方案 - 例如,protocol buffers 不是使用标记(例如 HTML)对基于文本的文档建模的好方法,因为你无法轻松地将结构与文本交错。此外,XML 是人类可读的和人类可编辑的;protocol buffers,至少它们的原生格式,并不具有这样的特点。XML 在某种程度上也是自我描述的。只有拥有 message 定义(.proto文件)时,protocol buffer 才有意义。
(四)介绍 proto3
我们最新的版本3 release ,它引入了新的语言版本 - Protocol Buffers 语言版本3(又称 proto3),并且添加了现有语言版本(又称 proto2)的一些新功能。Proto3 简化了 Protocol Buffers 语言,既易于使用,又可以在更广泛的编程语言中使用:这个版本允许你使用 Java,C ++,Python,Java Lite,Ruby,JavaScript,Objective-C 和 C# 生成 protocol buffer 代码。此外,你可以使用最新的 Go protoc 插件为 Go 生成 proto3 代码,该插件可从 github 库 golang/protobuf 获得。更多语言正在筹备中。
请注意,两种语言版本的 API 不完全兼容。为避免给现有用户带来不便,我们将继续在新版本的 protocol buffers 中支持以前的语言版本。
你可以在 发行说明 中看到与当前默认版本的主要差异,并在 Proto3 语法指引 中了解proto3 语法)。proto3 的完整文档即将推出!
(如果名称 proto2 和 proto3 看起来有点令人困惑,那是因为当我们最初开源 protocol buffers 时,它实际上是 Google 的第二个语言版本 - 也称为 proto2。这也是为什么我们的开源版本从 v2.0.0 开始)。
(五)一点点历史
Protocol buffers 最初是在 Google 开发的,用于处理索引服务器请求/响应协议。在 protocol buffer 之前,有一种请求和响应的格式,它手动进行编组/解组,并支持许多版本的协议。这导致了一些非常丑陋的代码,例如:
if (version == 3) {
...
} else if (version > 4) {
if (version == 5) {
...
}
...
}
明确格式化的协议也使新协议版本的推出变得复杂,因为开发人员必须确保请求的发起者和处理请求的实际服务器之间的所有服务器都能理解新协议,然后才能切换开关以开始使用新协议。
协议缓冲区旨在解决这些问题:
-
可以轻松引入新字段,中间服务器不需要检查数据,可以简单地解析它并传递数据而无需了解所有字段。
-
格式更具自我描述性,可以用各种语言处理(C ++,Java 等)
但是,用户仍然需要手写自己的解析代码。
随着系统的发展,它获得了许多其他功能和用途:
-
自动生成的序列化和反序列化代码避免了手动解析的需要。
-
除了用于短期 RPC(远程过程调用)请求之外,人们还开始使用 protocol buffers 作为一种方便的自描述格式,用于持久存储数据(例如在 Bigtable 中)。
-
服务器 RPC 接口开始被声明为协议文件的一部分,protocol 编译器生成存根类,用户可以使用服务器接口的实际实现来覆盖这些类。
Protocol buffers 现在是 Google 的数据通用语言 - 在撰写本文时,Google 代码树中有 12183 个 .proto 文件,其中一共定义了 48162 种不同的 message 类型。它们既可用于 RPC 系统,也可用于各种存储系统中的数据持久存储。
二、语言指导(proto3)
定义一个消息类型
我们先看一个简单示例。比如说我们想定义个关于搜索请求的消息,每个搜索请求包含一个查询字符串,一个特定的页码,和每页的结果数量。下面是用于定义消息类型的 .proto
文件:
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
-
文件的第一行指明了我们使用的是 proto3 语法:若不指定该行 protocol buffer 编译器会认为是 proto2 。该行必须是文件的第一个非空或非注释行。
-
SearchRequest
消息定义了三个字段(名称/值对),字段就是每个要包含在该类型消息中的部分数据。每个字段都具有名称和类型 。
指定字段类型
上面的例子中,全部字段都是标量类型:两个整型(page_number
和 result_per_page
)和一个字符串型(query
)。同样,也可以指定复合类型的字段,包括枚举型和其他消息类型。
分配字段编号
正如你所见,消息中定义的每个字段都有一个唯一编号。字段编号用于在消息二进制格式中标识字段,同时要求消息一旦使用字段编号就不应该改变。注意一点 1 到 15 的字段编号需要用 1 个字节来编码,编码同时包括字段编号和字段类型( 获取更多信息请参考 Protocol Buffer Encoding )。16 到 2047 的字段变化使用 2 个字节。因此应将 1 到 15 的编号用在消息的常用字段上。注意应该为将来可能添加的常用字段预留字段编号。
最小的字段编号为 1,最大的为 2^29 - 1,或 536,870,911。注意不能使用 19000 到 19999 (FieldDescriptor::kFirstReservedNumber
到 FieldDescriptor::kLastReservedNumber
)的字段编号,因为是 protocol buffer 内部保留的——若在 .proto 文件中使用了这些预留的编号 protocol buffer 编译器会发出警告。同样也不能使用之前预留的字段编号。
指定字段规则
消息的字段可以是一下规则之一:
-
singular , 格式良好的消息可以有 0 个或 1 个该字段(但不能多于 1 个)。这是 proto3 语法的默认字段规则。
-
repeated ,格式良好的消息中该字段可以重复任意次数(包括 0 次)。重复值的顺序将被保留。
在 proto3 中,标量数值类型的重复字段默认会使用 packed 压缩编码。
更多关于 packed 压缩编码的信息请参考 Protocol Buffer Encoding 。
增加更多消息类型
单个 .proto 文件中可以定义多个消息类型。这在定义相关联的多个消息中很有用——例如要定义与搜索消息SearchRequest
相对应的回复消息 SearchResponse
,则可以在同一个 .proto 文件中增加它的定义:
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
增加注释
使用 C/C++ 风格的 //
和 /* ... */
语法在 .proto 文件添加注释。
/* 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.
}
保留字段
在采取彻底删除或注释掉某个字段的方式来更新消息类型时,将来其他用户再更新该消息类型时可能会重用这个字段编号。后面再加载该 .ptoto 的旧版本时会引发好多问题,例如数据损坏,隐私漏洞等。一个防止该问题发生的办法是将删除字段的编号(或字段名称,字段名称会导致在 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
文件,对于每个消息类型会生成一个类。 -
对于 Java, 编译器会生成一个
.java
文件和每个消息类型对应的类,同时包含一个特定的Builder
类用于构建消息实例。 -
Python 有些不同 – Python 编译器会对于 .proto 文件中每个消息类型生成一个带有静态描述符的模块,以便于在运行时使用 metaclass 来创建必要的 Python 数据访问类。
-
对于 Go, 编译器会生成带有每种消息类型的特定数据类型的定义在
.pb.go
文件中。 -
对于 Ruby,编译器会生成带有消息类型的 Ruby 模块的
.rb
文件。 -
对于Objective-C,编辑器会针对于每个
.proto
文件生成pbobjc.h
和pbobjc.m.
文件,对于每个消息类型会生成一个类。 -
对于 C#,编辑器会针对于每个
.proto
文件生成.cs
文件,对于每个消息类型会生成一个类。 -
对于 Dart,编辑器会针对于每个
.proto
文件生成.pb.dart
文件,对于每个消息类型会生成一个类。
可以参考所选编程语言的教程了解更多 API 的信息。更多 API 详细信息,请参阅相关的 API reference 。
标量数据类型
消息标量字段可以是以下类型之一——下表列出了可以用在 .proto 文件中使用的类型,以及在生成代码中的相关类型:
.proto Type | Notes | C++ Type | Java Type | Python Type[2] | Go Type | Ruby Type | C# Type | PHP Type | Dart Type |
---|---|---|---|---|---|---|---|---|---|
double | double | double | float | float64 | Float | double | float | double | |
float | float | float | float | float32 | Float | float | float | double | |
int32 | 使用变长编码。负数的编码效率较低——若字段可能为负值,应使用 sint32 代替。 | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
int64 | 使用变长编码。负数的编码效率较低——若字段可能为负值,应使用 sint64 代替。 | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
uint32 | 使用变长编码。 | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | int |
uint64 | 使用变长编码。 | uint64 | long[1] | int/long[3] | uint64 | Bignum | ulong | integer/string[5] | Int64 |
sint32 | 使用变长编码。符号整型。负值的编码效率高于常规的 int32 类型。 | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
sint64 | 使用变长编码。符号整型。负值的编码效率高于常规的 int64 类型。 | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
fixed32 | 定长 4 字节。若值常大于2^28 则会比 uint32 更高效。 | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | int |
fixed64 | 定长 8 字节。若值常大于2^56 则会比 uint64 更高效。 | uint64 | long[1] | int/long[3] | uint64 | Bignum | ulong | integer/string[5] | Int64 |
sfixed32 | 定长 4 字节。 | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
sfixed64 | 定长 8 字节。 | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
bool | bool | boolean | bool | bool | TrueClass/FalseClass | bool | boolean | bool | |
string | 包含 UTF-8 和 ASCII 编码的字符串,长度不能超过 2^32 。 | string | String | str/unicode[4] | string | String (UTF-8) | string | string | String |
bytes | 可包含任意的字节序列但长度不能超过 2^32 。 | string | ByteString | str | []byte | String (ASCII-8BIT) | ByteString | string | List<int> |
可以在 Protocol Buffer Encoding 中获取更多关于消息序列化时类型编码的相关信息。
[1] Java 中,无符号 32 位和 64 位整数使用它们对应的符号整数表示,第一个 bit 位仅是简单地存储在符号位中。
[2] 所有情况下,设置字段的值将执行类型检查以确保其有效。
[3] 64 位或无符号 32 位整数在解码时始终表示为 long,但如果在设置字段时给出 int,则可以为 int。在所有情况下,该值必须适合设置时的类型。见 [2]。
[4] Python 字符串在解码时表示为 unicode,但如果给出了 ASCII 字符串,则可以是 str(这条可能会发生变化)。
[5] Integer 用于 64 位机器,string 用于 32 位机器。
默认值
当解析消息时,若消息编码中没有包含某个元素,则相应的会使用该字段的默认值。默认值依据类型而不同:
-
字符串类型,空字符串
-
字节类型,空字节
-
布尔类型,false
-
数值类型,0
-
枚举类型,第一个枚举元素
-
内嵌消息类型,依赖于所使用的编程语言。参考 generated code guide 获取详细信息。
对于可重复类型字段的默认值是空的( 通常是相应语言的一个空列表 )。
注意一下标量字段,在消息被解析后是不能区分字段是使用默认值(例如一个布尔型字段是否被设置为 false )赋值还是被设置为某个值的。例如你不能通过对布尔值等于 false 的判断来执行一个不希望在默认情况下执行的行为。同时还要注意若一个标量字段设置为默认的值,那么是不会被序列化以用于传输的。
查看 generated code guide 来获得更多关于编程语言生成代码的内容。
枚举
定义消息类型时,可能需要某字段值是一些预设值之一。例如当需要在 SearchRequest
消息类型中增加一个 corpus
字段, corpus
字段的值可以是 UNIVERSAL
, WEB
,IMAGES
, LOCAL
, NEWS
, PRODUCTS
或 VIDEO
。仅仅需要在消息类型中定义带有预设值常量的 enum
类型即可完成上面的定义。
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_alisa
选项设置为 true
,否则 protocol 编译器当发现别名定义时会报错。
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 位整数的范围内。因为枚举值在传输时采用的是 varint 编码,同时负值无效因而不建议使用。可以如上面例子所示,将枚举定义在消息类型内,也可以将其定义外边——这样该枚举可以用在 .proto 文件中定义的任意的消息类型中以便重用。还可以使用 MessageType.EnumType 语法将枚举定义为消息字段的某一数据类型。
使用 protocol buffer 编译器编译 .proto 中的枚举时,对于 Java 或 C 会生成相应的枚举类型,对于 Python 会生成特定的 EnumDescriptor
类用于在运行时创建一组整型值符号常量即可。
反序列化时,未识别的枚举值会被保留在消息内,但如何表示取决于编程语言。若语言支持开放枚举类型允许范围外的值时,这些未识别的枚举值简单的以底层整型进行存储,就像 C++ 和 Go。若语言支持封闭枚举类型例如 Java,一种情况是使用特殊的访问器(译注:accessors)来访问底层的整型。无论哪种语言,序列化时的未识别枚举值都会被保留在序列化结果中。
更多所选语言中关于枚举的处理,请参考 generated code guide 。
保留值
在采取彻底删除或注释掉某个枚举值的方式来更新枚举类型时,将来其他用户再更新该枚举类型时可能会重用这个枚举数值。后面再加载该 .ptoto 的旧版本时会引发好多问题,例如数据损坏,隐私漏洞等。一个防止该问题发生的办法是将删除的枚举数值(或名称,名称会导致在 JSON 序列化时产生问题)设置为保留项 reserved
。protocol buffer 编译器在用户使用这些特定数值时会发出警告。可以使用 max
关键字来指定保留值的范围到最大可能值。
enum Foo {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
}
注意不能在 reserved
语句中混用字段名称和数值。
使用其他消息类型
消息类型也可作为字段类型。例如,我们需要在 SearchResponse
消息中包含 Result
消息——想要做到这一点,可以将 Result
消息类型的定义放在同一个 .proto 文件中同时在 SearchResponse
消息中指定一个 Result
类型的字段:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
导入定义
前面的例子中,我们将 Result
消息定义在了与 SearchResponse
相同的文件中——但若我们需要作为字段类型使用的消息类型已经定义在其他的 .proto 文件中了呢?
可以通过导入操作来使用定义在其他 .proto 文件中的消息定义。在文件的顶部使用 import 语句完成导入其他 .proto 文件中的定义:
import "myproject/other_protos.proto";
默认情况下仅可以通过直接导入 .proto 文件来使用这些定义。然而有时会需要将 .proto 文件移动位置。可以通过在原始位置放置一个伪 .proto 文件使用 import public
概念来转发对新位置的导入,而不是在发生一点更改时就去更新全部对旧文件的导入位置。任何导入包含 import public
语句的 proto 文件就会对其中的 import public
依赖产生传递依赖。例如:
// new.proto
// 全部定义移动到该文件
// old.proto
// 这是在客户端中导入的伪文件
import public "new.proto";
import "other.proto";
// client.proto
import "old.proto";
// 可使用 old.proto 和 new.proto 中的定义,但不能使用 other.proto 中的定义
protocol 编译器会使用命令行参数 -I
/--proto_path
所指定的目录集合中检索需要导入的文件。若没有指定,会在调用编译器的目录中检索。通常应该将 --proto_path
设置为项目的根目录同时在 import 语句中使用全限定名。
使用 proto2 类型
可以在 proto3 中导入 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;
}
}
}
消息类型的更新
如果现有的消息类型不再满足您的所有需求——例如,需要扩展一个字段——同时还要继续使用已有代码,别慌! 在不破坏任何现有代码的情况下更新消息类型非常简单。仅仅遵循如下规则即可:
-
不要修改任何已有字段的字段编号
-
若是添加新字段,旧代码序列化的消息仍然可以被新代码所解析。应该牢记新元素的默认值以便于新代码与旧代码序列化的消息进行交互。类似的,新代码序列化的消息同样可以被旧代码解析:旧代码解析时会简单的略过新字段。参考未知字段获取详细信息。
-
字段可被移除,只要不再使用移除字段的字段编号即可。可能还会对字段进行重命名,或许是增加前缀
OBSOLETE_
,或保留字段编号以保证后续不能重用该编号。 -
int32
,uint32
,int64
,uint64
, 和bool
是完全兼容的——意味着可以从这些字段其中的一个更改为另一个而不破坏前后兼容性。若解析出来的数值与相应的类型不匹配,会采用与 C++ 一致的处理方案(例如,若将 64 位整数当做 32 位进行读取,则会被转换为 32 位)。 -
sint32
和sint64
相互兼容但不与其他的整型兼容。 -
string
andbytes
在合法 UTF-8 字节前提下也是兼容的。 -
嵌套消息与
bytes
在 bytes 包含消息编码版本的情况下也是兼容的。 -
fixed32
与sfixed32
兼容,fixed64
与sfixed64
兼容。 -
enum
与int32
,uint32
,int64
,和uint64
兼容(注意若值不匹配会被截断)。但要注意当客户端反序列化消息时会采用不同的处理方案:例如,未识别的 proto3 枚举类型会被保存在消息中,但是当消息反序列化时如何表示是依赖于编程语言的。整型字段总是会保持其的值。 -
将一个单独值更改为新
oneof
类型成员之一是安全和二进制兼容的。 若确定没有代码一次性设置多个值那么将多个字段移入一个新oneof
类型也是可行的。将任何字段移入已存在的oneof
类型是不安全的。
未知字段
未知字段是解析结构良好的 protocol buffer 已序列化数据中的未识别字段的表示方式。例如,当旧程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段。
本来,proto3 在解析消息时总是会丢弃未知字段,但在 3.5 版本中重新引入了对未知字段的保留机制以用来兼容 proto2 的行为。在 3.5 或更高版本中,未知字段在解析时会被保留同时也会包含在序列化结果中。
Any 类型
Any 类型允许我们将没有 .proto 定义的消息作为内嵌类型来使用。一个 Any
包含一个类似 bytes
的任意序列化消息,以及一个 URL 来作为消息类型的全局唯一标识符。要使用 Any
类型,需要导入 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 字段除了全部字段位于 oneof 共享内存以及大多数情况下一次仅会设置一个字段外与常规字段类似。对任何oneof 成员的设置会自动清除其他成员。可以通过 case()
或 WhichOneof()
方法来检测 oneof 中的哪个值被设置了,这个需要基于所选的编程语言。
使用 oneof
使用 oneof
关键字在 .proto 文件中定义 oneof,同时需要跟随一个 oneof 的名字,就像本例中的 test_oneof
:
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
然后将字段添加到 oneof 的定义中。可以增加任意类型的字段,但不能使用 repeated 字段。
在生成的代码中,oneof 字段和常规字段一致具有 getters 和 setters 。同时也会获得一个方法以用于检测哪个值被设置了。更多所选编程语言中关于 oneof 的 API 可以参考 API reference 。
Oneof 特性
-
设置 oneof 的一个字段会清除其他字段。因此入设置了多次 oneof 字段,仅最后设置的字段生效。
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message(); // 会清理 name 字段
CHECK(!message.has_name());
-
若解析器在解析得到的数据时碰到了多个 oneof 的成员,最后一个碰到的是最终结果。
-
oneof 不能是
repeated
。 -
反射 API 可作用于 oneof 字段。
-
若将一个 oneof 字段设为了默认值(就像为 int32 类型设置了 0 ),那么 oneof 字段会被设置为 "case",同时在序列化编码时使用。
-
若使用 C++ ,确认代码不会造成内存崩溃。以下的示例代码就会导致崩溃,因为
sub_message
在调用set_name()
时已经被删除了。
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name"); // 会删除 sub_message
sub_message->set_... // 此处会崩溃
-
同样在 C++ 中,若
Swap()
两个 oneof 消息,那么消息会以另一个消息的 oneof 的情况:下例中,msg1
会是sub_message1
而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 字段。没有方法可以区分,因为无法确定一个未知字段是否是 oneof 的成员。
标记重用问题
-
移入或移出 oneof 字段: 消息序列化或解析后,可能会丢失一些信息(某些字段将被清除)。然而,可以安全地将单个字段移入新的 oneof 中,同样若确定每次操作只有一个字段被设置则可以移动多个字段。
-
删除一个 oneof 字段并又将其加回: 消息序列化和解析后,可能会清除当前设置的 oneof 字段。
-
拆分或合并 oneof:这与移动常规字段有类似的问题。
Map 映射表
若需要创建关联映射表作为定义的数据的一部分,protocol buffers 提供了方便的快捷语法:
map<key_type, value_type> map_field = N;
key_type
处可以是整型或字符串类型(其实是除了 float 和 bytes 类型外任意的标量类型)。注意枚举不是合法的 key_type
。value_type
是除了 map 外的任意类型。
例如,若需要创建每个项目与一个字符串 key 相关联的映射表,可以采用下面的定义:
map<string, Project> projects = 3;
-
映射表字段不能为
repeated
-
映射表的编码和迭代顺序是未定义的,因此不能依赖映射表元素的顺序来操作。
-
当基于 .proto 生成文本格式时,映射表的元素基于 key 来排序。数值型的 key 基于数值排序。
-
当解析或合并时,若出现冲突的 key 以最后一个 key 为准。当从文本格式解析时,若 key 冲突则会解析失败。
-
若仅仅指定了映射表中某个元素的 key 而没有指定 value,当序列化时的行为是依赖于编程语言。在 C++,Java,和 Python 中使用类型的默认值来序列化,但在有些其他语言中可能不会序列化任何东西。
生成的映射表 API 当前可用于全部支持 proto3 的编程语言。在 API reference 中可以获取更多关于映射表 API 的内容。
向后兼容问题
映射表语法与以下代码是对等的,因此 protocol buffers 的实现即使不支持映射表也可以正常处理数据:
message MapFieldEntry {
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N;
任何支持映射表的 protocol buffers 实现都必须同时处理和接收上面代码的数据定义。
包
可以在 .proto 文件中使用 package
指示符来避免 protocol 消息类型间的命名冲突。
package foo.bar;
message Open { ... }
这样在定义消息的字段类型时就可以使用包指示符来完成:
message Foo {
...
foo.bar.Open open = 1;
...
}
包指示符的处理方式是基于编程语言的:
-
C++ 中生成的类位于命名空间中。例如,
Open
会位于命名空间foo::bar
中。 -
Java 中,使用 Java 的包,除非在 .proto 文件中使用
option java_pacakge
做成明确的指定。 -
Python 中,package 指示符被忽略,这是因为 Python 的模块是基于文件系统的位置来组织的。
-
Go 中,作为 Go 的包名来使用,除非在 .proto 文件中使用
option java_pacakge
做成明确的指定。 -
Ruby 中,生成的类包裹于 Ruby 的命名空间中,还要转换为 Ruby 所需的大小写风格(首字母大写;若首字符不是字母,则使用
PB_
前缀)。例如,Open
会位于命名空间Foo::Bar
中。 -
C# 中作为命名空间来使用,同时需要转换为 PascalCase 风格,除非在 .proto 使用
option csharp_namespace
中明确的指定。例如,Open
会位于命名空间Foo.Bar
中。
包和名称解析
protocol buffer 中类型名称解析的工作机制类似于 C++ :先搜索最内层作用域,然后是次内层,以此类推,每个包被认为是其外部包的内层。前导点(例如,.foo.bar.Baz
)表示从最外层作用域开始。
protocol buffer 编译器会解析导入的 .proto 文件中的全部类型名称。基于编程语言生成的代码也知道如何去引用每种类型,即使编程语言有不同的作用域规则。
定义服务
若要在 RPC (Remote Procedure Call,远程过程调用)系统中使用我们定义的消息类型,则可在 .proto 文件中定义这个 RPC 服务接口,同时 protocol buffer 编译器会基于所选编程语言生成该服务接口代码。例如,若需要定义一个含有可以接收 SearchRequest
消息并返回 SearchResponse
消息方法的 RPC 服务,可以在 .proto 文件中使用如下代码定义:
service SearchService {
rpc Search (SearchRequest) returns (SearchResponse);
}
最直接使用 protocal buffer 的 RPC 系统是 gRPC :一款 Google 开源,语言和平台无关的 RPC 系统。gRPC 对 protocol buffer 的支持非常好同时允许使用特定的 protocol buffer 编译器插件来基于 .proto 文件生成相关的代码。
若不想使用 gRPC,同样可以在自己的 RPC 实现上使用 protocol buffer。可以在 Proto2 Language Guide 处获得更多关于这方面的信息。
同样也有大量可用的第三方使用 protocol buffer 的项目。对于我们了解的相关项目列表,请参考 third-party add-ons wiki page 。
JSON 映射
Proto3 支持 JSON 的规范编码,这使得系统间共享数据变得更加容易。下表中,将逐类型地描述这些编码。
若 JSON 编码中不存在某个值或者值为 null,当将其解析为 protocol buffer 时会解析为合适的默认值。若 procol buffer 中使用的是字段的默认值,则默认情况下 JSON 编码会忽略该字段以便于节省空间。实现上应该提供一个选项以用来将具有默认值的字段生成在 JSON 编码中。
proto3 | JSON | JSON 示例 | 说明 |
---|---|---|---|
message | object | {"fooBar": v, "g": null,…} | 生成 JSON 对象。消息字段名映射为对象的 lowerCamelCase(译著:小驼峰) 的 key。若指定了 json_name 选项,则使用该选项值作为 key。解析器同时支持 lowerCamelCase 名称(或 json_name 指定名称)和原始 proto 字段名称。全部类型都支持 null 值,是当做对应类型的默认值来对待的。 |
enum | string | "FOO_BAR" | 使用 proto 中指定的枚举值的名称。解析器同时接受枚举名称和整数值。 |
map<K,V> | object | `{"k": v, …} | 所有的 key 被转换为字符串类型。 |
repeated V | array | [v, …] | null 被解释为空列表 []。 |
bool | true, false | true, false | |
string | string | "Hello World!" | |
bytes | base64 string | "YWJjMTIzIT8kKiYoKSctPUB+" | JSON 值是使用标准边界 base64 编码的字符串。不论标准或 URL 安全还是携带边界与否的 base64 编码都支持。 |
int32, fixed32, uint32 | number | 1, -10, 0 | JSON 值是 10 进制数值。数值或字符串都可以支持。 |
int64, fixed64, uint64 | string | "1", "-10" | JSON 值是 10 进制字符串。数值或字符串都支持。 |
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} 。否则,会被转换到一个对象中,同时会插入一个 "@type" 元素用以指明实际的类型。 |
Timestamp | string | "1972-01-01T10:00:20.021Z" | 采用 RFC 3339 格式,其中生成的输出总是 Z规范的,并使用 0、3、6 或 9 位小数。除 “Z” 以外的偏移量也可以。 |
Duration | string | "1.000340012s", "1s" | 根据所需的精度,生成的输出可能会包含 0、3、6 或 9 位小数,以 “s” 为后缀。只要满足纳秒精度和后缀 “s” 的要求,任何小数(包括没有)都可以接受。 |
Struct | object | { … } | 任意 JSON 对象。参见 struct.proto . |
Wrapper types | various types | 2, "2", "foo", true,"true", null, 0, … | 包装器使用与包装的原始类型相同的 JSON 表示,但在数据转换和传输期间允许并保留 null。 |
FieldMask | string | "f.fooBar,h" | 参见field_mask.proto 。 |
ListValue | array | [foo, bar, …] | |
Value | value | Any JSON value | |
NullValue | null | JSON null | |
Empty | object | {} | 空 JSON 对象 |
JSON 选项
proto3 的 JSON 实现可以包含如下的选项:
-
省略使用默认值的字段:默认情况下,在 proto3 的 JSON 输出中省略具有默认值的字段。该实现可以使用选项来覆盖此行为,来在输出中保留默认值字段。
-
忽略未知字段:默认情况下,proto3 的 JSON 解析器会拒绝未知字段,同时提供选项来指示在解析时忽略未知字段。
-
使用 proto 字段名称代替 lowerCamelCase 名称: 默认情况下,proto3 的 JSON 编码会将字段名称转换为 lowerCamelCase(译著:小驼峰)形式。该实现提供选项可以使用 proto 字段名代替。Proto3 的 JSON 解析器可同时接受 lowerCamelCase 形式 和 proto 字段名称。
-
枚举值使用整数而不是字符串表示: 在 JSON 编码中枚举值是使用枚举值名称的。提供了可以使用枚举值数值形式来代替的选项。
选项
.proto 文件中的单个声明可以被一组选项来设置。选项不是用来更改声明的含义,但会影响在特定上下文下的处理方式。完整的选项列表定义在 google/protobuf/descriptor.proto
中。
有些选项是文件级的,意味着可以卸载顶级作用域,而不是在消息、枚举、或服务的定义中。有些选项是消息级的,意味着需写在消息的定义中。有些选项是字段级的,意味着需要写在字段的定义内。选项还可以写在枚举类型,枚举值,服务类型,和服务方法上;然而,目前还没有任何可用于以上位置的选项。
下面是几个最常用的选项:
-
java_package
(文件选项):要用在生成 Java 代码中的包。若没有在 .proto 文件中对java_package
选项做设置,则会使用 proto 作为默认包(在 .proto 文件中使用 "package" 关键字设置)。 然而,proto 包通常不是合适的 Java 包,因为 proto 包通常不以反续域名开始。若不生成 Java 代码,则此选项无效。
option java_package = "com.example.foo";
-
java_multiple_files (文件选项):导致将顶级消息、枚举、和服务定义在包级,而不是在以 .proto 文件命名的外部类中。
option java_multiple_files = true;
-
java_outer_classname(文件选项):想生成的最外层 Java 类(也就是文件名)。若没有在 .proto 文件中明确指定
java_outer_classname
选项,类名将由 .proto 文件名转为 camel-case 来构造(因此foo_bar.proto
会变为FooBar.java
)。若不生成 Java 代码,则此选项无效。
option java_outer_classname = "Ponycopter";
-
optimize_for (文件选项): 可被设为
SPEED
,CODE_SIZE
,或LITE_RUNTIME
。这会影响 C++ 和 Java 代码生成器(可能包含第三方生成器) 的以下几个方面: -
SPEED (默认): protocol buffer 编译器将生成用于序列化、解析和消息类型常用操作的代码。生成的代码是高度优化的。
-
CODE_SIZE :protocol buffer 编译器将生成最小化的类,并依赖于共享的、基于反射的代码来实现序列化、解析和各种其他操作。因此,生成的代码将比 SPEED 模式小的多,但操作将变慢。类仍将实现与 SPEED 模式相同的公共 API。这种模式在处理包含大量 .proto 文件同时不需要所有操作都要求速度的应用程序中最有用。
-
LITE_RUNTIME :protocol buffer 编译器将生成仅依赖于 “lite” 运行库的类(libprotobuf-lite 而不是libprotobuf)。lite 运行时比完整的库小得多(大约小一个数量级),但会忽略某些特性,比如描述符和反射。这对于在受限平台(如移动电话)上运行的应用程序尤其有用。编译器仍然会像在 SPEED 模式下那样生成所有方法的快速实现。生成的类将仅用每种语言实现 MessageLite 接口,该接口只提供
Message
接口的一个子集。
option optimize_for = CODE_SIZE;
-
cc_enable_arenas
(文件选项):为生成的 C++ 代码启用 arena allocation 。 -
objc_class_prefix
(文件选项): 设置当前 .proto 文件生成的 Objective-C 类和枚举的前缀。没有默认值。你应该使用 recommended by Apple 的 3-5 个大写字母作为前缀。注意所有 2 个字母前缀都由 Apple 保留。 -
deprecated
(字段选项):若设置为true
, 指示该字段已被废弃,新代码不应使用该字段。在大多数语言中,这没有实际效果。在 Java 中,这变成了一个@Deprecated
注释。将来,其他语言的代码生成器可能会在字段的访问器上生成弃用注释,这将导致在编译试图使用该字段的代码时发出警告。如果任何人都不使用该字段,并且您希望阻止新用户使用它,那么可以考虑使用保留语句替换字段声明。
int32 old_field = 6 [deprecated=true];
自定义选项
protocol buffer 还允许使用自定义选项。大多数人都不需要此高级功能。若确认要使用自定义选项,请参阅 Proto2 Language Guide 了解详细信息。注意使用 extensions 来创建自定义选项,只允许用于 proto3 中。
生成自定义类
若要生成操作 .proto 文件中定义的消息类型的 Java、Python、C++、Go、Ruby、Objective-C 或 C# 代码,需要对 .proto 文件运行 protocol buffer 编译器 protoc
。若还没有安装编译器,请 download the package 并依据 README 完成安装。对于 Go ,还需要为编译器安装特定的代码生成器插件:可使用 GitHub 上的 golang/protobuf 库。
Protocol buffer 编译器的调用方式如下:
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
为import
指令检索 .proto 文件的目录。若未指定,使用当前目录。多个导入目录可以通过多次传递--proto_path
选项实现;这些目录会依顺序检索。-I=*IMPORT_PATH*
可作为--proto_path
的简易格式使用。 -
可以提供一个或多个输出指令:
-
--cpp_out
在DST_DIR
目录 生成 C++ 代码。参阅 C++ generated code reference 获取更多信息。 -
--java_out
在DST_DIR
目录 生成 Java 代码。参阅 Java generated code reference 获取更多信息。 -
--python_out
在DST_DIR
目录 生成 Python代码。参阅 Python generated code reference 获取更多信息。 -
--go_out
在DST_DIR
目录 生成 Go 代码。参阅 Go generated code reference 获取更多信息。 -
--ruby_out
在DST_DIR
目录 生成 Ruby 代码。 coming soon! -
--objc_out
在DST_DIR
目录 生成 Objective-C 代码。参阅 Objective-C generated code reference 获取更多信息。 -
--csharp_out
在DST_DIR
目录 生成 C# 代码。参阅 C# generated code reference 获取更多信息。 -
--php_out
在DST_DIR
目录 生成 PHP代码。参阅 PHP generated code reference 获取更多信息。
作为额外的便利,若 DST_DIR 以 .zip
或 .jar
结尾,编译器将会写入给定名称的 ZIP 格式压缩文件,.jar
还将根据 Java JAR 的要求提供一个 manifest 文件。请注意,若输出文件已经存在,它将被覆盖;编译器还不够智能,无法将文件添加到现有的存档中。
-
必须提供一个或多个 .proto 文件作为输入。可以一次指定多个 .proto 文件。虽然这些文件是相对于当前目录命名的,但是每个文件必须驻留在
IMPORT_PATHs
中,以便编译器可以确定它的规范名称。
三、规范指引
Message 和 字段命名
使用驼峰命名法(首字母大写)命名 message,例子: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) { ... }
枚举 Enums
使用驼峰命名法(首字母大写)命名枚举类型,使用 “大写下划线大写” 的方式命名枚举值:
enum Foo {
FIRST_VALUE = 0;
SECOND_VALUE = 1;
}
每一个枚举值以分号结尾,而非逗号。
服务 Services
如果你在 .proto 文件中定义 RPC 服务,你应该使用驼峰命名法(首字母大写)命名 RPC 服务以及其中的 RPC 方法:
service FooService {
rpc GetSomething(FooRequest) returns (FooResponse);
}
四、编码
一个简单的 Message
假设你有以下一个非常简单的消息定义:
message Test1 {
optional int32 a = 1;
}
在一个应用程序中,你创建一个 Test1 message,并将 a 设置为150。然后,将 message 序列化为输出流。如果你能够查看相应的编码后的结果,你会看到以下三个字节:
08 96 01
到目前为止,是如此的小巧和简单-但是这几个字节具体代表什么含义?请往下读...
Base 128 Varints (编码方法)
要理解上述涉及到的简单编码,你首先需要理解 varints 的概念。所谓的 varints 是一种用一个或多个字节序列化(编码)整数的方法。较小的数字将占用较少的字节数。
varint 中的每个字节都设置了一个标识位(msb) - msb 表示后面还有字节需要读取(当前编码还未结束)。每个字节的低 7 位用于以 7 位一组的方式存储数字的二进制补码,二进制补码的低位排在编码序列的前头。
译者注: 可以总结出 varint 编码最主要的三点:
存储数字对应的二进制补码
在每个字节开头设置了 msb,标识是否需要继续读取下一个字节(这种标识实际上代替长度的作用)
补码的低位排在前面
另外这里原文为 Each byte in a varint, except the last byte, has the most significant bit (msb) set,即 varint 中的每个字节,除了最后一个字节,都设置了最高有效位 msb。
原文的意思不容易让人理解,实际上应该是每个字节的最高 bit 都作为一个标识作用,1 标识需要继续读取下一个字节,0 标识当前字节为最后一个字节。
而原文中 msb 可能仅仅指得是最高比特位等于 1 的情况,所以它有个 “除了最后一个字节” 这样的表述。
我们来看一个例子:数字 1 该如何编码 – 这只需要单个字节,所以无需设置 msb:
0000 0001
译者注: 第一个 bit 位还是需要用来标识是否还有后续字节,这里为 0 表示无后续字节。
因为 msb 的作用相当于长度,所以哪怕只有一个字节,也是需要设置 msb 的,不然解码的时候无法识别该读多少个字节。这里的 “无需设置 msb” 估计又是将 msb 寓意成 bit = 1 的 msb。
来看一个稍微复杂点的例子,数字 300 该如何编码:
1010 1100 0000 0010
以上编码如何得出是整型 300?首先我们将每个字节的 msb 位去掉,因为这只是告诉我们是否已达到数字的末尾(如你所见,它在第一个字节中设置成 1 ,因为后面还有字节(第二个字节)需要继续读取):
1010 1100 0000 0010
→ 010 1100 000 0010
译者注: 这里去掉 msb 位时又将第二个字节的首位比特 0 当做 msb 去掉了。说实话 PB 的官方文档在很多细节还是容易让人产生误解的。
接下来你可以反转这两组 7 位,因为如前所述,varints 会将补码的低位排在前面。反转过程如下所示:
000 0010 010 1100 // 将两个 7 位组反转
→ 000 0010 + 010 1100
→ 100101100 // 去掉计算时多余的 0
→ 256 + 32 + 8 + 4 = 300 // 计算对应的整型
译者注: varints 之所以将低位的放在前头,是为了进行位操作(还原数值)的时候更加方便。
Message 结构
如我们所知,一个 protocol buffer message 实际上是一系列的键值对。消息的二进制版本只使用字段的数字作为 key - 而每个字段的名称和声明的类型只能通过引用 message 类型的定义(即 .proto 文件)在解码端确定。
在对一个 message 进行编码时,其键值将连接成字节流。在解码消息时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏那些无法识别(新字段)的旧程序。为此,识别 message 编码中每个字段的 key 实际上是两个值 - 来自 .proto 文件的字段编号,以及一个提供足够信息以查找 “值的(字节)长度” 的类型。在大多数语言实现中,该 key 被称为一个 tag (标记)。
可用的类型如下:
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 |
message 消息流中的每个 Tag (field_number + wire_type) 都使用 varint 进行编码,且最后三位 bit 存储类型 wire_type(其它位存储字段编号 field_number)。
现在让我们再看一下上面提到的简单例子。你现在知道 message 消息流中的第一个数字总是一个 varint 编码的 tag ,这里是 08,即(删除 msb):
000 1000
我们取最后三位 bit 从而得到类型为 0 (varint),右移 3 位得到 varint 编码的 1。所以我们现在知道字段编号为 1,并且接下来要读取的 value 的类型为 varint。使用上一节讲解的 varint 编码相关知识,我们可以得到接下来的两个字节代表 150。
96 01 = 1001 0110 0000 0001
→ 000 0001 + 001 0110 (drop the msb and reverse the groups of 7 bits)
→ 10010110
→ 128 + 16 + 4 + 2 = 150
更多的值类型
Signed Integers
正如我们在上一节中看到的,类型 0 对应的各种 protocol buffer types 会被编码为 varints。但是,在对负数进行编码时,signed int 类型(sint32 和 sint64)与标准的 int 类型(int32 和 int64)之间存在着重要差异。如果使用 int32 或 int64 作为负数的类型,则生成的 varint 总是十个字节长-它实际上被视为一个非常大的无符号整数。如果使用 signed int 类型(sint32 和 sint64),则生成的 varint 将使用 ZigZag 编码,这样效率更高。
译者注: 如果是负数,那么必须有最高位表示符号位,也就是说天然的会用尽所有字节。
如果是 4 个字节的 int,那么负数总是要占 4 个字节。可以为什么 int32 会占十个字节呢?不是应该占 5 个字节吗(每个字节还需要拿出一个 bit 位做 msb,所以要 5 个字节)?
这里是 protobuf 为了兼容性做的一种措施,为了 int32 和 int64 能够兼容(比如在 .proto 文件中将 int32 改成 int64),所以 int32 的负数会扩展到 int64 的长度。
那么正数的兼容呢?请仔细品味上述的 varints 编码,这种编码天然使得 int32 和 int64 的正数兼容。
ZigZag 编码将有符号整数映射到无符号整数,因此具有较小绝对值(例如 -1)的数字也具有较小的 varint 编码值。它通过正负整数来回 “zig-zags” 的方式做到这一点,因此 -1 被编码为 1, 1 被编码为 2,-2 被编码为 3,依此类推,如同下表所示:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2 | 4 |
... | ... |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
换句话说,每个 sint32 类型的 n 编码处理如下:
(n << 1) ^ (n >> 31)
而对于每个 sint64 类型的 n:
(n << 1) ^ (n >> 63)
注意,第二个移位(n >> 31)部分是算术移位。因此,换句话说,移位的结果将会是一个全 0(如果 n 为正)或是全 1(如果n为负)。
解析 sint32 或 sint64 时,其值将被解码回原始的 signed 版本。
Non-varint Numbers
non-varint 数字类型很简单 - double 和 fixed64 对应的类型(wire type)为 1,它告诉解析器期读取一个固定的 64 位数据块。类似地,float 和 fixed32 对应的类型(wire type)为 5,这意味着它期望 32 位。在这两种情况下,值都以 little-endian (二进制补码低位在前)字节顺序存储。
Strings
类型(wire type)为 2(长度划分)表示接下来的字节将是 varint 编码的长度,之后跟指定长度的数据字节。
message Test2 {
optional string b = 2;
}
将 b 的值设置为 "testing" 后得到如下编码:
12 07 | 74 65 73 74 69 6e 67
后七个字节为 "testing" 的 UTF8 编码。第一个字节 0x12 为 key → 字段编码 field_number = 2, 类型 wire type = 2。 varint 值的长度为 7,并且看到它后面有七个字节 -即为我们需要读取的值(字符串)。
译者注: 0x12 -> 0001 0010,先进行 varints 解码,解码结果为 001 0010,如果还是不清楚这个结果是怎么得出来的,可能需要重新看一遍上面的内容。取后面三位 010 得出 wire_type = 2。剩下的位表示 field_number,容易看出 0010 = 2。
Embedded Messages(内嵌 message)
下面的 message,内嵌了我们之前的简单例子 Test1:
message Test3 {
optional Test1 c = 3;
}
设置其中的 Test1 的 a = 150,最终编码如下:
1a 03 08 96 01
正如我们所见,后三个字节和我们的第一个例子结果相同(08 96 01),在这三个字节之前为 03 编码(代表着字节长度)-嵌入消息的处理方式与字符串完全相同(wire type = 2)。
Optional 和 Repeated 元素
如果一个 proto2 message 定义有 repeated
字段(没有使用 [packed=true]
选项),则对应的 message 编码将具有零个或多个相同字段编号的键值对。这些重复值不必连续出现。它们可能与其他字段交错。解析时将保留这些 repeated
元素彼此的相对顺序,虽然相对于其他字段的排序将会丢失。 而在 proto3 中,repeated
字段默认使用 packed 编码,下面将讲解该方法。
译者注: 这里官方文档的描述又不太严谨,到目前为止,proto3 只会对 primitive 类型的
repeated
字段默认进行打包处理,string 之类的repeated
字段是不会默认进行打包的。详见下面 [打包 Repeated 字段] 部分。
对于 proto3 中的任何 non-repeated 字段或 proto2 中的 optional 字段,message 编码可能具有或不具有该字段编号对应的键值对。
通常,编码消息永远不会有多个 non-repeated 字段的实例。但是,解析器应该能够处理这个情况。对于数字类型和字符串,如果多次出现相同的字段,则解析器接受它 “看到” 的最后一个值。 对于嵌入式消息字段,解析器合并同一字段的多个实例,就像使用 Message::MergeFrom 方法一样 - 也就是说,后面实例中的所有单个标量字段都替换前者,单个嵌入消息被合并,而 repeated 字段将被串联起来。这些规则的作用是使得两个消息(字符串)串联起来解析产生的结果与分别解析两个消息(字符串)后合并结果对象产生的结果完全相同。也就是:
MyMessage message;
message.ParseFromString(str1 + str2);
等价于:
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
此属性偶尔会有用,因为它允许我们合并两条消息,即使我们不知道它们的类型。
打包 Repeated 字段
protobuf 版本 2.1.0 引入了对打包的 repeated 字段,在 proto2 中声明为具有特殊的 [packed = true] 选项的 repeated 字段。在 proto3 中,repeated 字段默认被打包。packed repeated 和 repeated 在编码方式上会有所不同。没有包含任何元素的 packed repeated 字段不会出现在编码消息中。否则(包含有元素),该字段的所有元素都被打包成一个键值对,其中类型(wire type)为 2(length-delimited)。每个元素的编码方式与正常情况相同,只是前面没有键。
译者注: string 类型不会被打包,为什么?
译者认为的原因: 将 primitive 进行打包后编码格式为 tag-length-value-value...,因为 primitive 使用 varints 进行编码,读取 length 长度的字节后有 msb 作为读取 value 的边界标识,因此可以对 value-value-value 进行正确的边界识别,正确的一个个取出 value。
如果 value 是 string 类型,对应的 packed 结果如果是 tag-length-value-value-value...(其中 length 是后续的字节数)因为 string 直接 UTF-8 编码,读取出对应的字节后,无法正确划分 value 边界。 如果 packed 结果为 tag-length-[length-value-length-value],那么和不打包的编码结果 tag-length-value-tag-length-value-tag-length-value.... 相比,packed 结果多出了一个 length (第一个出现的 length,这个 length 表达后续所有的字节数,这个可能会很大),同时少了后续每个字节 tag (tag 的大小通常很小,最大不会超过 5 字节)的开销,那么最后 packed 的结果是增大开销还是减少开销就不一定了。所以不对 Length-delimited 系列的类型进行打包。
例如,假设我们有如下消息类型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
现在假设你构造一个 Test4,为重复的字段 d 分别赋值为 3、270 和 86942。那么对应的编码将是:
22 // key (或称 tag,字段编码 4, wire type 2)
06 // payload 长度 (即后面需要读取的所有值的长度6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)
只有原始数字类型(使用 Varint,32-bit 或 64-bit)的 repeated 字段才能声明为 “packed”。
请注意,虽然通常没有理由为打包的 repeated 字段编码多个键值对,但编码器必须准备好接受多个键值对。在这种情况下,应该连接 payload (对应的 value)。每对必须包含许多元素。
protocol buffer 解析器必须能够解析打包的 repeated 字段,就好像它们没有打包一样,反之亦然。这允许以前向和后向兼容的方式将 [packed = true] 添加到现有字段。
字段排序
虽然我们可以在 .proto
中以任何顺序使用字段编号,但在序列化消息时,其已知字段应按字段编号顺序编写,如提供的 C ++,Java 和 Python 序列化代码实现的那样。这允许解析代码能够依赖于字段序列进行优化。但是,protocol buffer 解析器必须能够以任何顺序解析字段,因为并非所有 message 都是通过简单地序列化对象来创建的-例如,通过简单连接来合并两个消息(有时很有用)。
如果一个 message 具有 [未知字段],当前的 Java 和 C++ 实现将在按顺序排序的已知字段之后以任意顺序写入它们,而当前的 Python 实现不会记录(跟踪)未知字段
五、相关技术
多消息(Message)流
如果要将多条消息(Message)写入单个文件或流,则需要跟踪一条消息的结束位置和下一条消息的开始位置。因为 Protocol Buffer 数据格式不是自定界限的,因此 Protocol Buffer 解析器无法确定消息自身的结束位置。解决此问题最简单的方法就是在每条消息本身内容之前记录消息的大小或长度。当你重新读取消息时,可读取其大小,读取对应字节到单独的缓冲区,然后从该缓存区中解析消息内容。(如果你不想将字节复制到单独的缓冲区,请查看 CodedInputStream 类(C++ 和 Java 都具有此类),这个类可以限制读入缓冲区的字节数)。
大数据集
Protocol Buffers 并不是为处理大信息(large messages)而设计的。依据一般的经验法则,如果你处理的是每个 message 都大于兆字节的数据(messages),那么这个时候可能需要考虑换一种策略。
也就是说,protocol buffers 非常适合处理大数据集中的单个消息。通常大数据集是一些小块数据的集合,而其中每个小块可能是结构化的数据。 尽管 protocol buffers 无法同时处理整个数据集,但可以使用 protocol buffers 对每一小块进行编码从而极大简化我们的问题:现在我们只需要处理一组字节字符串而不是一组结构。
Protocol Buffers 并没有内置对大数据集的支持,因为不同的情况通常需要不同的解决方案。有时一个简单的记录列表就能满足需求,而有时你需要的可能是一个更接近数据库的东西。每一个解决方案都应该作为单独的库去开发,而只有真正需要这些相应解决方案的才需要付出相应成本。
自描述信息
Protocol Buffers 并不包含其自身类型的描述。所以如果没有给出定义类型的 .proto 文件,而只有原始信息(raw message)是很难提取任何有用数据的。
译者注: 自描述信息对于反射实现至关重要
然而,值得注意的是 .proto 文件的内容本身实际上也可以使用 protocol buffers 来表达。源码中的 src/google/protobuf/descriptor.proto 定义了所需的相关类型。protoc 命令可以使用 --descriptor_set_out 选项来输出 FileDescriptorSet(此类就表示一组 .proto 文件)。通过这种方式,你可以定义一个自描述的协议消息,如下所示:
message SelfDescribingMessage {
// Set of .proto files which define the type.
required FileDescriptorSet proto_files = 1;
// Name of the message type. Must be defined by one of the files in
// proto_files.
required string type_name = 2;
// The message data.
required bytes message_data = 3;
}
通过 DynamicMessage 类(C++ 和 Java 中可用),你可以编写操作上述 SelfDescribingMessages 的工具。
简单来讲的话,这个功能之所以未包含在 Protocol Buffer 库中,是因为我们从未在 Google 内部使用过它。
此技术需要对 proto2 语法的支持(因为这是 descriptor.proto 使用的语法)以及对使用描述符的动态信息的支持。在使用自描述消息之前,请检查你需要的平台是否支持这些功能
六、Protocol Buffer Basics: C++
本教程为 C++ 程序员如何使用 protocol buffers 做一个基本介绍。通过创建一个简单的示例应用程序,它向你展示:
-
如何在一个
.proto
文件中定义 message -
如何使用 protocol buffer 编译器
-
如何使用 C++ protocol buffer 的 API 读写 message
这不是一篇通过 C ++ 使用 protocol 的综合指南。如果想获取更详细的参考信息,请参阅 Protocol Buffer 语法指引、C++ API 指引、C++ 生成代码指引 和 Protocol Buffer 的 编码指引。
为什么要 Protocol Buffers?
我们将要使用的示例是一个非常简单的 “地址簿” 应用程序,可以在文件中读写联系人的详细信息。地址簿中的每个人都有姓名、ID、电子邮件地址和联系电话。
你该如何序列化和反序列化如上结构的数据呢?这里有几种解决方案:
-
可以以二进制形式发送/保存原始内存中数据结构。随着时间的推移,这是一种脆弱的方法,因为接收/读取代码必须使用完全相同的内存布局、字节顺序等进行编译。此外,由于文件以原始格式累积数据,并且解析该格式的软件副本四处传播,因此很难扩展格式。
-
你可以发明一种特殊的方法将数据项编码为单个字符串 - 例如将 4 个整数编码为 "12:3:-23:67"。这是一种简单而灵活的方法,虽然它确实需要编写一次性编码和解析的代码,并且解析会产生一些小的运行时成本。但这非常适合非常简单的数据的编码。
-
将数据序列化为 XML。这种方法非常有吸引力,因为 XML(差不多)是人类可读的,并且有许多语言的绑定库。如果你想与其他应用程序/项目共享数据,这可能是一个不错的选择。然而,XML 是众所周知需要更多的空间,并且编码/解码 XML 会对应用程序造成巨大的性能损失。此外,导航 XML DOM 树比通常在类中导航简单字段要复杂得多。
而 Protocol buffers 是灵活,高效,自动化的解决方案。采用 protocol buffers,你可以写一个 .proto
文件描述你想要读取的数据的结构。由此, protocol buffer 编译器将创建一个类,该类使用有效的二进制格式实现 protocol buffer 数据的自动编码和解析。生成的类为构成 protocol buffer 的字段提供 getter 和 setter,并负责读写 protocol buffer 单元的细节。重要的是,protocol buffer 的格式支持随着时间的推移扩展格式的想法,使得代码仍然可以读取用旧格式编码的数据。
从哪找到示例代码
示例代码包含在源代码包中的 "examples" 目录下。下载地址
定义你的 protocol 格式
要创建地址簿应用程序,你需要从 .proto 文件开始。.proto 文件中的定义很简单:为要序列化的每个数据结构添加 message 定义,然后为 message 中的每个字段指定名称和类型。下面就是定义相关 message 的 .proto 文件,addressbook.proto。
syntax = "proto2";
package tutorial;
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
required string number = 1;
optional PhoneType type = 2 [default = HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
如你所见,语法类似于 C++ 或 Java。让我们浏览文件的每个部分,看看它们的作用。
.proto 文件以 package 声明开头,这有助于防止不同项目之间的命名冲突。在 C++ 中,生成的类将放在与包名匹配的 namespace (命名空间)中。
接下来,你将看到相关的 message 定义。message 只是包含一组类型字段的集合。许多标准的简单数据类型都可用作字段类型,包括 bool、int32、float、double 和 string。你还可以使用其他 message 类型作为字段类型在消息中添加更多结构 - 在上面的示例中,Person 包含 PhoneNumber message ,而 AddressBook 包含 Person message。你甚至可以定义嵌套在其他 message 中的 message 类型 - 如你所见,PhoneNumber 类型在 Person 中定义。如果你希望其中一个字段具有预定义的值列表中的值,你还可以定义枚举类型 - 此处你指定(枚举)电话号码,它的值可以是 MOBILE,HOME 或 WORK 之一。
每个元素上的 "=1","=2" 标记表示该字段在二进制编码中使用的唯一 “标记”。标签号 1-15 比起更大数字需要少一个字节进行编码,因此以此进行优化,你可以决定将这些标签用于常用或重复的元素,将标记 16 和更高的标记留给不太常用的可选元素。repeated 字段中的每个元素都需要重新编码 Tag,因此 repeated 字段特别适合使用此优化。
译者注: “repeated 字段中的每个元素都需要重新编码 Tag”,指的应该是 string 等类型的 repeated 字段。
必须使用以下修饰符之一注释每个字段:
-
required: 必须提供该字段的值,否则该消息将被视为“未初始化”。如果是在调试模式下编译 libprotobuf,则序列化一个未初始化的 message 将将导致断言失败。在优化的构建中,将跳过检查并始终写入消息。但是,解析未初始化的消息将始终失败(通过从解析方法返回 false)。除此之外,required 字段的行为与 optional 字段完全相同。
-
optional: 可以设置也可以不设置该字段。如果未设置可选字段值,则使用默认值。对于简单类型,你可以指定自己的默认值,就像我们在示例中为电话号码类型所做的那样。否则,使用系统默认值:数字类型为 0,字符串为空字符串,bools 为 false。对于嵌入 message,默认值始终是消息的 “默认实例” 或 “原型”,其中没有设置任何字段。调用访问器以获取尚未显式设置的 optional(或 required)字段的值始终返回该字段的默认值。
-
repeated: 该字段可以重复任意次数(包括零次)。重复值的顺序将保留在 protocol buffer 中。可以将 repeated 字段视为动态大小的数组。
对于 required 类型你应该时时刻刻留意,将字段设置为 required 类型是一个值得谨慎思考的事情。如果你希望在某个时刻停止写入或发送 required 字段,则将字段更改为 optional 字段会有问题 - 旧读者会认为没有此字段的邮件不完整,可能会无意中拒绝或删除这些消息。你应该考虑为你的 buffers 编写特定于应用程序的自定义验证例程。谷歌的一些工程师得出的结论是,使用 required 弊大于利;他们更喜欢只使用 optional 和 repeated。但是,这种观点还未得到普及。
你可以在 Protocol Buffer 语法指南 中找到编写 .proto
文件(包括所有可能的字段类型)的完整指南。不要去寻找类似于类继承的工具(设计),protocol buffers 不会这样做。
编译你的 Protocol Buffers
既然你已经有了一个 .proto
文件,那么你需要做的下一件事就是生成你需要读写AddressBook
(以及 Person
和 PhoneNumber
) message 所需的类。为此,你需要在 .proto
上运行 protocol buffer 编译器 protoc
:
-
如果尚未安装编译器,请 下载软件包 并按照 README 文件中的说明进行操作。
-
现在运行编译器,指定源目录(应用程序的源代码所在的位置 - 如果不提供值,则使用当前目录),目标目录(你希望生成代码的目标目录;通常与源目录
$SRC_DIR
相同),以及
.proto
的路径。在这种情况下,你可以执行如下命令:
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/addressbook.proto
因为你需要 C ++ 类,所以使用 --cpp_out
选项 - 当然,为其他支持的语言也提供了类似的选项。
这将在指定的目标目录中生成以下文件:
-
addressbook.pb.h
: 类声明的头文件 -
addressbook.pb.cc
:类实现
The Protocol Buffer API
让我们看看一些生成的代码,看看编译器为你创建了哪些类和函数。如果你查看 addressbook.pb.h,你会发现你在 addressbook.proto 中指定的每条 message 都有一个对应的类。仔细观察 Person 类,你可以看到编译器已为每个字段生成了访问器。例如,对于 name ,id,email 和 phone 字段,你可以使用以下方法:
// required name
inline bool has_name() const;
inline void clear_name();
inline const ::std::string& name() const;
inline void set_name(const ::std::string& value);
inline void set_name(const char* value);
inline ::std::string* mutable_name();
// required id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);
// optional email
inline bool has_email() const;
inline void clear_email();
inline const ::std::string& email() const;
inline void set_email(const ::std::string& value);
inline void set_email(const char* value);
inline ::std::string* mutable_email();
// repeated phones
inline int phones_size() const;
inline void clear_phones();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
inline const ::tutorial::Person_PhoneNumber& phones(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
inline ::tutorial::Person_PhoneNumber* add_phones();
如你所见,getter 的名称与小写字段完全相同,setter 方法以 set_ 开头。每个单数(required 或 optional)字段也有 has_ 方法,如果设置了该字段,则返回 true。最后,每个字段都有一个 clear_ 方法,可以将字段重新设置回 empty 状态。
虽然数字 id 字段只有上面描述的基本访问器集,但是 name 和 email 字段因为是字符串所以有几个额外的方法:一个 mutable_ 的 getter,它允许你获得一个指向字符串的直接指针,以及一个额外的 setter。请注意,即使尚未设置 email ,也可以调用 mutable_email();它将自动初始化为空字符串。如果在这个例子中你有一个单数的 message 字段,它也会有一个 mutable_ 方法而不是 set_ 方法。
repeated 字段也有一些特殊的方法 - 如果你看一下 repeated phones 字段的相关方法,你会发现你可以:
-
检查 repeated 字段长度(换句话说,与此人关联的电话号码数)
-
使用索引获取指定的电话号码
-
更新指定索引处的现有电话号码
-
在 message 中添加另一个电话号码同时之后也可进行再修改(repeated 的标量类型有一个 add_,而且只允许你传入新值)
有关 protocol 编译器为任何特定字段定义生成的确切成员的详细信息,请参阅 C++ 生成代码参考。
枚举和嵌套类
生成的代码包含与你的 .proto 枚举对应的 PhoneType 枚举。你可以将此类型称为 Person::PhoneType,其值为 Person::MOBILE,Person::HOME 和 Person::WORK(实现细节稍微复杂一些,但你如果仅仅只是使用不需要理解里面的实现原理)。
编译器还为你生成了一个名为 Person::PhoneNumber 的嵌套类。如果查看代码,可以看到 “真实” 类实际上称为 Person_PhoneNumber,但在 Person 中定义的 typedef 允许你将其视为嵌套类。唯一会造成一点差异的情况是,如果你想在另一个文件中前向声明该类 - 你不能在 C ++ 中前向声明嵌套类型,但你可以前向声明 Person_PhoneNumber。
标准 Message 方法
每个 message 类还包含许多其他方法,可用于检查或操作整个 message,包括:
-
bool IsInitialized() const;
: 检查是否已设置所有必填 required 字段 -
string DebugString() const;
: 返回 message 的人类可读表达,对调试特别有用
-
void CopyFrom(const Person& from);
: 用给定的 message 的值覆盖 message -
void Clear();
: 将所有元素清除回 empty 状态
这些和下一节中描述的 I/O 方法实现了所有 C++ protocol buffer 类共享的 Message
接口。更多的更详细的有关信息,请参阅 Message 的完整 API 文档。
解析和序列化
最后,每个 protocol buffer 类都有使用 protocol buffer 二进制格式 读写所选类型 message 的方法。包括:
-
bool SerializeToString(string* output) const;
:序列化消息并将字节存储在给定的字符串中。请注意,字节是二进制的,而不是文本;我们只使用string
类作为方便的容器。 -
bool ParseFromString(const string& data);
: 解析给定字符串到 message -
bool SerializeToOstream(ostream* output) const;
: 将 message 写入给定的 C++ 的 ostream -
bool ParseFromIstream(istream* input);
: 解析给定 C++ istream 到 message
这些只是解析和序列化提供的几个选项。请参阅 Message API 参考 以获取完整列表。
Protocol Buffers 和 O-O 设计的 Protocol Buffers 类基本上是 dumb data 持有者(如 C 中的结构); 他们没有在对象模型中成为优秀的一等公民。如果要为生成的类添加更丰富的行为,最好的方法是将生成的 Protocol Buffers 类包装在特定于应用程序的类中。如果你无法控制 .proto 文件的设计(如果你正在重用另一个项目中的一个),那么包装 Protocol Buffers 的类也是一个好主意。在这种情况下,你可以使用包装器类来创建更适合应用程序的独特环境的接口:隐藏一些数据和方法,公开便利功能等。永远不应该通过继承它们来为生成的类添加行为。这将打破内部机制,无论如何这都不是良好的面向对象的实践。
译者注:对象模型设计原则之一:使用组合代替继承
写入一个 Message
现在让我们尝试使用你的 Protocol Buffer 类。你希望地址簿应用程序能够做的第一件事可能是将个人详细信息写入的地址簿文件。为此,你需要创建并填充 Protocol Buffer 类的实例,然后将它们写入输出流。
这是一个从文件中读取 AddressBook 的程序,根据用户输入向其添加一个新 Person,并将新的 AddressBook 重新写回文件。其中直接调用或引用 protocol 编译器生成的代码部分将高亮显示。
译者注: “直接调用或引用 protocol 编译器生成的代码部分” 采用注释 @@@ 的方式标出
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
// This function fills in a Person message based on user input.
void PromptForAddress(tutorial::Person* person) {
cout << "Enter person ID number: ";
int id;
cin >> id;
person->set_id(id);
cin.ignore(256, '\n');
cout << "Enter name: ";
getline(cin, *person->mutable_name());
cout << "Enter email address (blank for none): ";
string email;
getline(cin, email);
if (!email.empty()) {
person->set_email(email);
}
while (true) {
cout << "Enter a phone number (or leave blank to finish): ";
string number;
getline(cin, number);
if (number.empty()) {
break;
}
// @@@ Person::PhoneNumber
tutorial::Person::PhoneNumber* phone_number = person->add_phones();
phone_number->set_number(number);
cout << "Is this a mobile, home, or work phone? ";
string type;
getline(cin, type);
if (type == "mobile") {
// @@@ Person
phone_number->set_type(tutorial::Person::MOBILE);
} else if (type == "home") {
// @@@ Person
phone_number->set_type(tutorial::Person::HOME);
} else if (type == "work") {
// @@@ Person
phone_number->set_type(tutorial::Person::WORK);
} else {
cout << "Unknown phone type. Using default." << endl;
}
}
}
// Main function: Reads the entire address book from a file,
// adds one person based on user input, then writes it back out to the same
// file.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
// @@@ AddressBook
tutorial::AddressBook address_book;
{
// Read the existing address book.
fstream input(argv[1], ios::in | ios::binary);
if (!input) {
cout << argv[1] << ": File not found. Creating a new file." << endl;
// @@@ ParseFromIstream
} else if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
// Add an address.
PromptForAddress(address_book.add_people());
{
// Write the new address book back to disk.
fstream output(argv[1], ios::out | ios::trunc | ios::binary);
// @@@ SerializeToOstream
if (!address_book.SerializeToOstream(&output)) {
cerr << "Failed to write address book." << endl;
return -1;
}
}
// Optional: Delete all global objects allocated by libprotobuf.
// @@@ ShutdownProtobufLibrary
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
请注意 GOOGLE_PROTOBUF_VERIFY_VERSION 宏。在使用 C++ Protocol Buffer 库之前执行此宏是一种很好的做法 - 尽管不是绝对必要的。它验证你没有意外链接到与你编译的头文件不兼容的库版本。如果检测到版本不匹配,程序将中止。请注意,每个 .pb.cc 文件在启动时都会自动调用此宏。
另请注意在程序结束时调用 ShutdownProtobufLibrary()。所有这一切都是删除 Protocol Buffer 库分配的所有全局对象。对于大多数程序来说这是不必要的,因为该过程无论如何都要退出,并且操作系统将负责回收其所有内存。但是,如果你使用了内存泄漏检查程序,该程序需要释放每个最后对象,或者你正在编写可以由单个进程多次加载和卸载的库,那么你可能希望强制使用 Protocol Buffers 来清理所有内容。
读取一个 Message
当然,如果你无法从中获取任何信息,那么地址簿就不会有多大用处!此示例读取上面示例创建的文件并打印其中的所有信息。
#include <iostream>
#include <fstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;
// Iterates though all people in the AddressBook and prints info about them.
void ListPeople(const tutorial::AddressBook& address_book) {
for (int i = 0; i < address_book.people_size(); i++) {
const tutorial::Person& person = address_book.people(i);
cout << "Person ID: " << person.id() << endl;
cout << " Name: " << person.name() << endl;
if (person.has_email()) {
cout << " E-mail address: " << person.email() << endl;
}
for (int j = 0; j < person.phones_size(); j++) {
const tutorial::Person::PhoneNumber& phone_number = person.phones(j);
switch (phone_number.type()) {
case tutorial::Person::MOBILE:
cout << " Mobile phone #: ";
break;
case tutorial::Person::HOME:
cout << " Home phone #: ";
break;
case tutorial::Person::WORK:
cout << " Work phone #: ";
break;
}
cout << phone_number.number() << endl;
}
}
}
// Main function: Reads the entire address book from a file and prints all
// the information inside.
int main(int argc, char* argv[]) {
// Verify that the version of the library that we linked against is
// compatible with the version of the headers we compiled against.
GOOGLE_PROTOBUF_VERIFY_VERSION;
if (argc != 2) {
cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl;
return -1;
}
tutorial::AddressBook address_book;
{
// Read the existing address book.
fstream input(argv[1], ios::in | ios::binary);
if (!address_book.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}
ListPeople(address_book);
// Optional: Delete all global objects allocated by libprotobuf.
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
扩展一个 Protocol Buffer
在发布使用 protocol buffer 的代码之后,无疑早晚有一天你将会想要 “改进” protocol buffer 的定义。如果你希望你的新 buffer 向后兼容,并且你的旧 buffer 是向前兼容的(实际上你一定想要这种兼容性) - 那么你需要遵循一些规则。在新版本的 protocol buffer 中:
-
你不得更改任何现有字段的字段编号
-
你不得添加或删除任何 required 字段
-
你可以删除 optional 或 repeated 的字段
-
你可以添加新的 optional 或 repeated 字段,但必须使用新的标记号(即从未在此协议缓冲区中使用的编号,甚至包括那些已删除的字段的编号)
(这些规则有一些 例外,但它们很少使用)。
如果你遵循这些规则,旧代码将很乐意阅读新消息并简单地忽略任何新字段。对于旧代码,已删除的可选字段将只具有其默认值,删除的重复字段将为空。新代码也将透明地读取旧消息。但是,请记住旧的 message 中不会出现新的可选字段,因此你需要明确通过调用 has_ 方法来检查它们是否被设置,或者在字段编号后面使用 [default = value] 在 .proto 文件中提供合理的默认值。如果未为 optional 元素指定默认值,则使用特定于类型的默认值:对于字符串,默认值为空字符串。对于布尔值,默认值为 false。对于数字类型,默认值为零。另请注意,如果添加了新的 repeated 字段,则新代码将无法判断它是否为空(通过新代码)或从未设置(通过旧代码),因为它没有 has_ 标志。
优化技巧
C++ Protocol Buffers 已经做了极大优化。但是,正确使用可以进一步提高性能。以下是压榨最后一点速度的一些提示和技巧:
-
尽可能重用 message 对象。message 会为了重用尝试保留它们分配的任何内存,即使它们被清除。因此,如果你连续处理具有相同类型和类似结构的许多 message,则每次重新使用相同的 message 对象来加载内存分配器是个好主意。但是,随着时间的推移,对象会变得臃肿,特别是如果你的 message 在 “形状” 上有所不同,或者你偶尔构造的 message 比平时大得多。你应该通过调用 SpaceUsed 方法来监控邮件对象的大小,一旦它们变得太大就删除它们。
-
你的系统内存分配器可能没有针对从多个线程分配大量小对象这种情况进行良好优化。请尝试使用 Google 的 tcmalloc。
高级用法
Protocol buffers 的用途不仅仅是简单的访问器和序列化。请务必浏览 C++ API 参考,以了解你可以使用它们做些什么。
Protocol buffers 类提供的一个关键特性是反射。你可以迭代 message 的字段并操纵它们的值,而无需针对任何特定的 message 类型编写代码。使用反射的一种非常有用的应用是将 protocol messages 转换为其他编码,例如 XML 或 JSON。更高级的反射用法可能是找到两个相同类型的 message 之间的差异,或者开发一种 “protocol messages 的正则表达式”,你可以在其中编写与某些 message 内容匹配的表达式。如果你运用自己的想象力,可以将 Protocol Buffers 应用于比你最初预期更广泛的问题!
反射由 Message::Reflection 接口. 提供。
作者:404_89_117_101 链接:https://www.jianshu.com/p/d2bed3614259 来源:简书 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。