ProtoBuf
定义:
-
语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。
-
是一种灵活,高效、自动化机制的结构数据序列化方法-可类比XML,但是比XML更小、更快、更为简单。
-
你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。
ProtoBuf是结构数据序列化方法,特点如下:
- 语言无关、平台无关。即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台
- 高效。即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单
- 扩展性、兼容性好。你可以更新数据结构,而不影响和破坏原有的旧程序
对于XML、JSON、ProtoBuf的总结:
- XML、JSON、ProtoBuf都具有数据结构化和序列化的能力。
- XML、JSON 更注重数据结构化,关注人类可读性和语义表达能力。ProtoBuf 更注重数据序列化,关注效率、空间、速度,人类可读性差,语义表达能力不足(为保证极致的效率,会舍弃一部分元信息)。
- ProtoBuf 的应用场景更为明确,XML、JSON 的应用场景更为丰富。
1. 编码结构
TLV:Tag-Length-Value。Tag作为该字段的唯一标识,Length代表Value数据域的长度,Value是数据本身。
Length是可选的,针对不同类型的数据编码结构可能会变成Tag-Value的形式,采用此形式将只能用Varint编码。
Tag由field_number和write_type组成:
- field_number:message定义字段时指定的字段编号。
- write_type:ProtoBuf编码类型,根据这个类型选择不同的value编码方案。
整个Tag采用Varints编码方案进行编码,Tag结构如下图:
3 bit 的 wire_type 最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下图所示:
注意其中的 Start group 和 End group 两种类型已被遗弃。
第一列即是对应的类型编号,第二列为面向最终编码的编码类型,第三列是面向开发者的 message 字段的类型。
2. Varints编码
Varints 编码的规则主要为以下三点:
- 在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节
- 存储数字对应的二进制补码
- 补码的低位排在前面
Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb),来表示是否已经结束(是否还需要读取下一个字节),msb 实际上就起到了 Length 的作用,正因为有了 msb(Length),所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。通过 Varints 我们可以让小的数字用更少的字节表示。从而提高了空间利用和效率。
负数情况:
负数必须在最高位(符号位)置 1,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8 个字节。你没法像正数那样去掉多余的高位(都是 0)。再加上 msb,最终 Varints 编码的结果将固定在 10 个字节。
为什么是十个字节?int32 不应该是 4 个字节吗?这里是 ProtoBuf 基于兼容性的考虑(比如开发者将 int64 的字段改成 int32 后应当不影响旧程序),而将 int32 扩展成 int64 的八个字节。
3. ZigZag编码
ProtoBuf 为我们提供了 sint32、sint64 两种类型,当你在使用这两种类型定义字段时,ProtoBuf 将使用 ZigZag 编码,而 ZigZag 编码将解决负数编码效率低的问题。
定义:有符号整数映射到无符号整数,然后再使用 Varints 编码
这里的“映射”是以移位实现的,并非存储映射表。
4. 补充packed编码
在 proto2 中为我们提供了可选的设置 [packed = true],而这一可选项在 proto3 中已成默认设置。
packed 目前只能用于 primitive 类型。
packed = true 主要使让 ProtoBuf 为我们把 repeated primitive 的编码结果打包,从而进一步压缩空间,进一步提高效率、速度。这里打包的含义其实就是:原先的 repeated 字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因为这些 Tag 都是相同的(同一字段),因此可以将这些字段的 Value 打包,即将编码结构变为 Tag-Length-Value-Value-Value...
5. 序列化源码及解析
ProtoBuf 的序列化使用过程:
- 定义 .proto 文件
- protoc 编译器编译 .proto 文件生成一系列接口代码
- 调用生成的接口实现对 .proto 定义的字段的读取以及 message 对象的序列化、反序列化方法
通过调用 SerializeToString 函数将对象序列化(编码)成字符串。并非编码成字符串数据,string只是作为编码结果的容器。
每一个在.proto文件中定义的message字段都会在代码中构造成一个类
每一个在 .proto 文件中定义的 message 字段都会在代码中构造成一个类,这些message消息类继承于google.protobuf.Message,而google.protobuf.Message继承于更为轻量的MessageLite类。经常调用的序列化函数 SerializeToString 并定义在基类 MessageLite 中。
5.1 编码
当某个 Message 调用 SerializeToString 时,经过一层层调用最终会调用底层的关键编码函数 WriteVarint32ToArray 或 WriteVarint64ToArray,整个过程如下图所示:
WriteVarint32ToArray(以及 WriteVarint64ToArray)便是 Varint 编码的核心
inline uint8* CodedOutputStream::WriteVarint32ToArray(uint32 value, uint8* target) {
// 0x80 -> 1000 0000
// 大于 1000 0000 意味这进行 Varints 编码时至少需要两个字节
// 如果 value < 0x80,则只需要一个字节,编码结果和原值一样,则没有循环直接返回
// 如果至少需要两个字节
while (value >= 0x80) {
// 如果还有后续字节,则 value | 0x80 将 value 的最后字节的最高 bit 位设置为 1,并取后七位
*target = static_cast<uint8>(value | 0x80);
// 处理完七位,后移,继续处理下一个七位
value >>= 7;
// 指针加一,(数组后移一位)
++target;
}
// 跳出循环,则表示已无后续字节,但还有最后一个字节
// 把最后一个字节放入数组
*target = static_cast<uint8>(value);
// 结束地址指向数组最后一个元素的末尾
return target + 1;
}
// Varint64 同理
inline uint8* CodedOutputStream::WriteVarint64ToArray(uint64 value,
uint8* target) {
while (value >= 0x80) {
*target = static_cast<uint8>(value | 0x80);
value >>= 7;
++target;
}
*target = static_cast<uint8>(value);
return target + 1;
}
- value | 0x80:xxx ... xxxx xxxx | 000 ... 1000 0000 的结果其实就是将最后一个字节的第一个 bit(最高位) 置 1,其他位不变,即 xxx ... 1xxx xxxx。注意 target 是 uint8 类型的指针,这意味它只会截断获取最后一个字节,即 1xxx xxxx,这里的 1 意味着什么?这个 1 就是所谓的 msb 了,意味着后续还有字节。之后就是右移 7 位(去掉最后 7 位),处理下一个 7位。
- 通过这里的代码应该可以体会到为什么 Varints 编码结果是低位排在前面了。
5.2 Length delimited字段序列化
因为其编码结构为 Tag - Length - Value,所以其字段完整的序列化会稍微多出一些过程,其中有一些需要我们进一步整理。现在以一个 string 类型字段的序列化为例,来看看其序列化的完整过程,画出其程序时序图(上文出现过)如下:
可对照上述时序图来阅读源码,其序列化实现的几个关键函数为:
- ByteSizeLong:计算对象序列化所需要的空间大小,在内存中开辟相应大小的空间
- WriteTagToArray:将 Tag 值写入到之前开辟的内存中
- WriteStringWithSizeToArray:将 Length + Value 值写入到之前开辟的内存中
其序列化代码的重点过程在上图的右下角,先是调用 WriteTagToArray 函数将 Tag 值写入到内存,返回指向下一个字节的指针以便继续写入。调用 WriteStringWithSizeToArray 函数,这个函数主要又执行了两个函数,先是执行 WriteVarint32ToArray 函数(注意 WriteTagToArray 内部调用的也是这个函数,因为 Tag 和 Length 都采用 Varints 编码),此函数的作用是将 Length 写入。执行的第二个函数为 WriteStringToArray,此函数的作用是将 Value(一个 UTF-8 string 值) 写入到内存,其中底层调用了 memcpy() 函数。
综上,对于 Varint 类型的字段自然采用 Varint 编码。
而对于 Length delimited 类型的字段,Tag-Length-Value 中的 Tag 和 Length 依然采用 Varint 编码,Value 若为 String 等类型,则直接进行 memcpy。
另外对于 embedded message 或 packed repeated ,则套用上述规则。底层编码实现实际便是遍历字段下所有内嵌字段,然后递归调用编码函数即可。
6. 反射技术简介
反射概念最早出现于人工智能领域,20 世纪 70 年代末被引入到程序语言设计中。1982 年 MIT 的 Smith, Brian Cantwell
在他的博士论文中最早提出了程序反射的概念:
既然我们可以构造“有关某个外部世界表示”的计算过程, 并通过它来对那个外部世界进行推理; 那么我们也可以构造能够对自身表示和计算进行推理的计算过程 ,它包含负责管理有关自身的操作和结构表示的内部过程。
—— 1982年 Smith, Brian Cantwell 博士论文首次提出
从某种角度来看,所谓编程实际上就是在构造 “关于外部世界” 的计算过程。如果用 F 表示这个构造过程,用 X 表示外部世界,那么编写一个计算系统可表示为 F(X)。
那么非常有趣的点就在于:我们完全可以构造对上述过程本身进行描述和推理的计算过程。即将 F(X) 视为新的 “世界” 和 研究对象,构造 F(F(X))。
我们平时编写的计算系统是面向特定领域的(通常是面向现实建模), 系统中包含 用来描述领域中的实体
和 实体间关系
的数据结构以及处理这些数据结构的规则。那么反射系统面向领域便是这个系统本身。
反射可以提供这样的能力:
计算机程序在运行时可以访问、检测和修改它本身状态或行为
—— 反射 (计算机科学) Wikipedia
如果用一句话来总结反射实现的关键,笔者会概括为:获取系统元信息
元信息:即系统自描述信息,用于描述系统本身。举例来讲,即系统有哪些类?类中有哪些字段、哪些方法?字段属于什么类型、方法又有怎样的参数和返回值?
7. ProtoBuf反射技术
与其它语言提供的反射类似,ProtoBuf 能够为使用者提供了如下的反射能力:
/* 反射创建实例 */
auto descriptor = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName("Dog");
auto prototype = google::protobuf::MessageFactory::generated_factory()->GetPrototype(descriptor);
auto instance = prototype->New();
/* 反射相关接口 */
auto reflecter = instance.GetReflection();
auto field = descriptor->FindFieldByName("name");
reflecter->SetString(&instance, field, "鸡你太美") ;
// 获取属性的值.
std::cout<<reflecter->GetString(instance , field)<< std::endl ;
return 0 ;
利用上述 ProtoBuf 的反射能力,我们将能够实现许多强大的功能。例如各种 pb2json 库底层多是利用 ProtoBuf 的反射能力,实际上ProtoBuf 自身对编码结果反序列化并构建内存对象的过程用的也正是反射。
正如上一节提到的,反射的核心要点是:获取程序元信息。
ProtoBuf 自然也不会例外,那么 ProtoBuf 反射所需的元信息在哪?答案便是使用 ProtoBuf 的第一步就会接触到的:.proto 文件。
7.1 ProtoBuf反射原理概述
我们在 深入 ProtoBuf - 简介 一文中介绍过使用 ProtoBuf 的第一步便是创建 .proto 文件,定义我们所需的数据结构。但很多人没有意识到,这个过程同时也是为 ProtoBuf 提供我们数据元信息的过程,这些元信息包括数据由哪些字段构成,字段又属于什么类型以及字段之间的组合关系等。
当然元信息也并非一定由 .proto 文件提供,它也可来自于网络或其它可能的输入,只要它满足 ProtoBuf Message 的定义语法即可。那么元信息的可能来源和处理就有:
-
.proto文件
- 使用 ProtoBuf 内置的工具 protoc 编译器编译,protoc 将 .proto 文件内容编码并写入生成的代码中(.pb.cc 文件)
- 使用 ProtoBuf 提供的编译 API 在运行时手动(指编码)解析 .proto 文件内容。实际上 protoc 底层调用的也正是这个编译 API。
-
非.proto文件
- 从远程读取,如将数据与数据元信息一同进行 protobuf 编码并传输:
- 从 Json 或其它格式数据中转换而来
无论 .proto 文件来源于何处,我们都需要对其做进一步的处理,将其解析成内存对象,并构建其与实例的映射,同时也要计算每个字段的内存偏移。可总结出如下步骤:
- 提供 .proto (范指 ProtoBuf Message 语法描述的元信息)
- 解析 .proto 构建 FileDescriptor、FieldDescriptor 等,即 .proto 对应的内存模型(对象)
- 之后每创建一个实例,就将其存到相应的实例池中
- 将 Descriptor 和 instance 的映射维护到表中备查
- 通过 Descriptor 可查到相应的 instance,又由于了解 instance 中字段类型(FieldDescriptor),所以知道字段的内存偏移,那么就可以访问或修改字段的值
8. ProtoBuf3语法
8.1 定义一个消息类型
先来看一个非常简单的例子。假设你想定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、你感兴趣的查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的.proto文件了:
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
- 文件的第一行指定了你正在使用proto3语法:如果你没有指定这个,编译器会使用proto2。这个指定语法行必须是文件的非空非注释的第一个行。
- SearchRequest消息格式有3个字段,在消息中承载的数据分别对应于每一个字段。其中每个字段都有一个名字和一种类型。
8.2 指定字段类型
在上面的例子中,所有字段都是标量类型:两个整型(page_number和result_per_page),一个string类型(query)。当然,你也可以为字段指定其他的合成类型,包括枚举(enumerations)或其他消息类型。
8.3 分配标识符号
正如你所见,在消息定义中,每个字段都有唯一的一个数字标识符。这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。
最小的标识号可以从1开始,最大到2^29 - 1, or 536,870,911。不可以使用其中的[19000-19999]( (从FieldDescriptor::kFirstReservedNumber 到 FieldDescriptor::kLastReservedNumber))的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。同样你也不能使用早期保留的标识号。
8.4 指定字段规则
所指定的消息字段修饰符必须是如下之一:
-
singular:一个格式良好的消息应该有0个或者1个这种字段(但是不能超过1个)。
-
repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括0次)。重复的值的顺序会被保留。
在proto3中,repeated的标量域默认情况下使用packed。
8.5 添加更多消息类型
在一个.proto文件中可以定义多个消息类型。在定义多个相关的消息的时候,这一点特别有用——例如,如果想定义与SearchResponse消息类型对应的回复消息格式的话,你可以将它添加到相同的.proto文件中,如:
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
...
}
8.6 添加注释
向.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.
}
8.7 保留标识符(Reserved)
如果你通过删除或者注释所有域,以后的用户可以重用标识号当你重新更新类型的时候。如果你使用旧版本加载相同的.proto文件这会导致严重的问题,包括数据损坏、隐私错误等等。现在有一种确保不会发生这种情况的方法就是指定保留标识符(and/or names, which can also cause issues for JSON serialization不明白什么意思),protocol buffer的编译器会警告未来尝试使用这些域标识符的用户。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
注:不要在同一行reserved声明中同时声明域名字和标识号
8.8 从.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文件中的每一个消息有一个对应的类。
8.9 标量数值类型
一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:
.proto Type | Notes | C++ Type | Java Type | Python Type[2] | Go Type | Ruby Type | C# Type | PHP Type |
---|---|---|---|---|---|---|---|---|
double | double | double | float | float64 | Float | double | float | |
float | float | float | float | float32 | Float | float | float | |
int32 | 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代 | int32 | int | int | int32 | Fixnum 或者 Bignum(根据需要) | int | integer |
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 |
- 在java中,无符号32位和64位整型被表示成他们的整型对应形似,最高位被储存在标志位中。
- 对于所有的情况,设定值会执行类型检查以确保此值是有效。
- 64位或者无符号32位整型在解码时被表示成为ilong,但是在设置时可以使用int型值设定,在所有的情况下,值必须符合其设置其类型的要求。
- python中string被表示成在解码时表示成unicode。但是一个ASCIIstring可以被表示成str类型。
- Integer在64位的机器上使用,string在32位机器上使用
8.10 默认值
当一个消息被解析的时候,如果被编码的信息不包含一个特定的singular元素,被解析的对象锁对应的域被设置位一个默认值,对于不同类型指定如下:
-
对于strings,默认是一个空string
-
对于bytes,默认是一个空的bytes
-
对于bools,默认是false
-
对于数值类型,默认是0
-
对于枚举,默认是第一个定义的枚举值,必须为0;
-
对于消息类型(message),域没有被设置,确切的消息是根据语言确定的,详见generated code guide
对于可重复域的默认值是空(通常情况下是对应语言中空列表)。
注:对于标量消息域,一旦消息被解析,就无法判断域释放被设置为默认值(例如,例如boolean值是否被设置为false)还是根本没有被设置。你应该在定义你的消息类型时非常注意。例如,比如你不应该定义boolean的默认值false作为任何行为的触发方式。也应该注意如果一个标量消息域被设置为标志位,这个值不应该被序列化传输。
8.11 枚举
当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值。例如,假设要为每一个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,否则编译器会在别名的地方产生一个错误信息。
enum EnumAllowingAlias {
option allow_alias = true;
UNKNOWN = 0;
STARTED = 1;
RUNNING = 1;
}
enum EnumNotAllowingAlias {
UNKNOWN = 0;
STARTED = 1;
// RUNNING = 1; // Uncommenting this line will cause a compile error inside Google and a warning message outside.
}
枚举常量必须在32位整型值的范围内。因为enum值是使用可变编码方式的,对负数不够高效,因此不推荐在enum中使用负数。如上例所示,可以在 一个消息定义的内部或外部定义枚举——这些枚举可以在.proto文件中的任何消息定义里重用。当然也可以在一个消息中声明一个枚举类型,而在另一个不同 的消息中使用它——采用MessageType.EnumType的语法格式。
当对一个使用了枚举的.proto文件运行protocol buffer编译器的时候,生成的代码中将有一个对应的enum(对Java或C++来说),或者一个特殊的EnumDescriptor类(对 Python来说),它被用来在运行时生成的类中创建一系列的整型值符号常量(symbolic constants)。
在反序列化的过程中,无法识别的枚举值会被保存在消息中,虽然这种表示方式需要依据所使用语言而定。在那些支持开放枚举类型超出指定范围之外的语言中(例如C++和Go),为识别的值会被表示成所支持的整型。在使用封闭枚举类型的语言中(Java),使用枚举中的一个类型来表示未识别的值,并且可以使用所支持整型来访问。在其他情况下,如果解析的消息被序列号,未识别的值将保持原样。
8.12 使用其他消息类型
你可以将其他消息类型用作字段类型。例如,假设在每一个SearchResponse消息中包含Result消息,此时可以在相同的.proto文件中定义一个Result消息类型,然后在SearchResponse消息中指定一个Result类型的字段,如:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
8.13 嵌套类型
你可以在其他消息类型中定义、使用消息类型,在下面的例子中,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;
}
8.14 更新一个消息类型
如果一个已有的消息格式已无法满足新的需求——如,要在消息中添加一个额外的字段——但是同时旧版本写的代码仍然可用。不用担心!更新消息而不破坏已有代码是非常简单的。在更新时只要记住以下的规则即可。
- 不要更改任何已有的字段的数值标识。
- 如果你增加新的字段,使用旧格式的字段仍然可以被你新产生的代码所解析。你应该记住这些元素的默认值这样你的新代码就可以以适当的方式和旧代码产生的数据交互。相似的,通过新代码产生的消息也可以被旧代码解析:只不过新的字段会被忽视掉。注意,未被识别的字段会在反序列化的过程中丢弃掉,所以如果消息再被传递给新的代码,新的字段依然是不可用的(这和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类型的字段总会保留他们的
8.15 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 ...
}
}
8.16 Oneof
如果你的消息中有很多可选字段, 并且同时至多一个字段会被设置, 你可以加强这个行为,使用oneof特性节省内存.
Oneof字段就像可选字段, 除了它们会共享内存, 至多一个字段会被设置。 设置其中一个字段会清除其它字段。 你可以使用case()
或者WhichOneof()
方法检查哪个oneof字段被设置, 看你使用什么语言了.
为了在.proto定义Oneof字段, 你需要在名字前面加上oneof关键字, 比如下面例子的test_oneof:
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
然后你可以增加oneof字段到 oneof 定义中. 你可以增加任意类型的字段, 但是不能使用repeated 关键字.
- 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 字段有效.
- 向后兼容性问题
当增加或者删除oneof字段时一定要小心. 如果检查oneof的值返回None/NOT_SET
, 它意味着oneof字段没有被赋值或者在一个不同的版本中赋值了。 你不会知道是哪种情况,因为没有办法判断如果未识别的字段是一个oneof字段。
Tage 重用问题:
- 将字段移入或移除oneof:在消息被序列号或者解析后,你也许会失去一些信息(有些字段也许会被清除)
- 删除一个字段或者加入一个字段:在消息被序列号或者解析后,这也许会清除你现在设置的oneof字段
- 分离或者融合oneof:行为与移动常规字段相似。
8.17 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。
- 向后兼容性问题
map语法序列化后等同于如下内容,因此即使是不支持map语法的protocol buffer实现也是可以处理你的数据的:
message MapFieldEntry {
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N;
8.18 包
当然可以为.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);
}
8.19 JSON映射
Proto3 支持JSON的编码规范,使他更容易在不同系统之间共享数据,在下表中逐个描述类型。
如果JSON编码的数据丢失或者其本身就是null
,这个数据会在解析成protocol buffer的时候被表示成默认值。如果一个字段在protocol buffer中表示为默认值,体会在转化成JSON的时候编码的时候忽略掉以节省空间。具体实现可以提供在JSON编码中可选的默认值。
proto3 | JSON | JSON示例 | 注意 |
---|---|---|---|
message | object | 产生JSON对象,消息字段名可以被映射成lowerCamelCase形式,并且成为JSON对象键,null被接受并成为对应字段的默认值 | |
enum | string | “FOO_BAR” | 枚举值的名字在proto文件中被指定 |
map | object | 所有的键都被转换成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 | 如果一个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 |
- 选项
在定义.proto文件时能够标注一系列的options。Options并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。完整的可用选项可以在google/protobuf/descriptor.proto找到。
一些选项是文件级别的,意味着它可以作用于最外范围,不包含在任何消息内部、enum或服务定义中。一些选项是消息级别的,意味着它可以用在消息定义的内部。当然有些选项可以作用在域、enum类型、enum值、服务类型及服务方法中。到目前为止,并没有一种有效的选项能作用于所有的类型。
如下就是一些常用的选择:
java_package
(文件选项) :这个选项表明生成java类所在的包。如果在.proto文件中没有明确的声明java_package,就采用默认的包名。当然了,默认方式产生的 java包名并不是最好的方式,按照应用名称倒序方式进行排序的。如果不需要产生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 allocationobjc_class_prefix
(文件选项):设置Objective-C类的前缀,添加到所有Objective-C从此.proto文件产生的类和枚举类型。没有默认值,所使用的前缀应该是苹果推荐的3-5个大写字符,注意2个字节的前缀是苹果所保留的。deprecated
(字段选项):如果设置为true
则表示该字段已经被废弃,并且不应该在新的代码中使用。在大多数语言中没有实际的意义。在java中,这回变成@Deprecated
注释,在未来,其他语言的代码生成器也许会在字标识符中产生废弃注释,废弃注释会在编译器尝试使用该字段时发出警告。如果字段没有被使用你也不希望有新用户使用它,尝试使用保留语句替换字段声明。
int32 old_field = 6 [deprecated=true];