protocol buffers的一句话描述:将一个抽象的实体序列化之后转换成二进制
以下对protocol buffers通称为pb
一:概述
pb是一种与语言无关,平台无关,可扩展的数据存储的方法。可被用于通信协议和数据存储等。在开发中,也会简称这种存储方式pb格式存储。
二:具体的工作流程
- 自定义编写message,message主要是用来存储消息的元数据。message结构体是KV的方式来进行存储的,文件结尾是.proto
- 使用protocol buffer 编译器来对message进行编译,会自动生成指定语言的访问类。类中会包括message中定义的每一个字段的访问器以及将整个结构序列化为原始字节和解析原始字节的方法。
- 在相应的语言项目中引入生成类,就可以开始我们的代码开发了
三:和其他结构的比较
- 和XML的比较优点:对于序列化数据来说,protocol buffer比XML更具有优势,更简单,更小,更快,而且对于每一部分的定义也更加的清晰明了。上面也可以看到他会自动生成我们所需要的数据访问类,很方便使用
- 和XML的比较缺点:protocol buffer我们只可以定义他的原生格式,不是随时可编辑和可读的。
四:简单的例子
message RequestMsg{
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}
这是一个消息元数据定义的例子,需要注意的点有:
- 这里定义的是三个标量类型的字段,当然也可以是复合类型如枚举和自定义message
- 可以看到每一个字段都分配一个唯一的字段编号,可以理解这个编号是用于在消息二进制格式进行唯一标识,所以对于已经在使用的数据类型,已经定义的字段编号最好就不要发生改变了,如果一定要修改的话,那注意需要兼容改之后和改之后的数据解析代码。
(字段编号在1-15之间占用一个字节进行编码,16-2047范围内的占用两个字节进行编码。因此在进行字段编码的时候最好将常用的字段用1-15进行编码,这样可以节省占用的空间)
(字段编号的范围是1-229-1[536,870,911],但是需要注意的是数字19000-19999是保留编号,不要使用) - 修饰词required 表示必选,如上面的query字段,一个正确的消息必须具备query字段
- 修饰词optional表示出现【0-1】次,如上面的page_number,一个正确的消息中page_number只能出现0次或者1次
五:message结构语法规则
-
支持的数据结构
标量类型:- String (字符串必须是utf-8编码或者是7-bitascii编码)
- int32 (变长的编码,对于负值的处理效率很低,如果执行域有负值,可以使用sint64来代替)【varints编码】默认值是0
- Uint32 (无符号整数,变长的编码,不允许使用负数,取值范围是0到4,294,967,295即232-1)【varints编码】默认值是0
- Uint64 (无符号整数,变长的编码,不允许使用负数,取值范围是0到264-1)【varints编码】默认值是0
- Sint32 (变长的编码,在处理负值的时候效率优于int32)【在处理负数的时候优先使用zigzag编码,其他直接使用varints编码】默认值是0
- Sint64 (变长的编码,有符号的整型数,编码时比int64高效)【在处理负数的时候优先使用zigzag编码,其他直接使用varints编码】默认值是0
- Fixed32 (无符号整数,固定长度,4个字节,如果数值总是大于228的时候,效率优于uint32)【因为当数值大于228(4*7)的时候,开始出现第5个字节】默认值是0
- Fixed64 (无符号整数,固定长度,8个字节,如果数值总是大于256的时候,效率优于uint64) 默认值是0
- Sfixed32 (固定长度,4个字节,有符号整数,高效处理负数)【在处理负数的时候优先使用zigzag编码,其他直接使用varints编码】默认值是0
- Sfixed64 (固定长度,8个字节)【在处理负数的时候优先使用zigzag编码,其他直接使用varints编码】默认值是0
注:- 在整数类型的选择上,如果有负数的话也不一定一定要选择sint32,如果负数出现的频率很高的时候,可以使用,如果很低,那个使用int32就可以,因为sint32要经过两次编码,还是有损耗的
- 如果只有正整数的时候,可以使用uint32,因为可以表示正整数的范围更大,表示232个数字(0-4294967295)
- fixed和uint相比而言,打包效率更高,但是占用的空间也更大。Fixed32是固定4个字节的整数。Int32是用varints编码,序列化之后最大占用5个字节,所以当整数很大的时候int32占用的5个字节性能是低于fixed32的4个字节的
- bool 默认值是false
- string 字符串必须始终包含utf8编码或者是7位的ascii文本,长度不能超过232,默认值是空字符串
- bytes 长度不能超过232,默认值是空字节
复合类型
- map
- enum
- 自定义message
- …
-
默认值
正如上面描述的一样,在message中定义为optional的字段,在接收到的消息中可能有,也可能没有,那么在没有的时候就需要有一个默认值,这个默认值可以以如下的这种方式自己设置
optional int32 app = 3; [default=10]
那么对于app这个可选字段,如果在解析消息的时候发现没有这个字段,就会是设置的默认值10。但是如果定义方式是
optional int32 app = 3;
那么在解析消息的时候发现没有这个字段,那么所接收到的默认值就是protobuf 的默认值0.
- 枚举
3.1. 枚举这种类型,指的是当定义一个字段的时候,这个字段的取值只能是预定义的一个值集合中的元素。例如想要定义一个字段city,那么city的值被限定在【beijing,shanghai,chengdu】之间。
message RequestMsg{
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
enum City{
BEIJING = 0;
SHANGHAI = 1;
CHENGDU = 2;
}
optional City city = 4;[default=GUESS]
}
3.2. 如上所示,枚举的第一个常量值被映射到0(每一个enum都必须包含一个被映射到0的常量定义作为第一个元素),这是因为:
- 必须要有一个零值来作为默认值
- 为了和protocol2兼容,0要作为第一个元素
3.3. 枚举的常量值必须是32位整数访问内的,因为enum是使用varints编码,这种编码方式对负数很低效
3.4. enum可以在message内部定义,也可以外部定义,因为enum是可以被重用的。可以使用同一个enum在不同的message 里面声明不同的字段
MessageType.EnumType
3.5 如果在一个enum中想要两个常量映射到同一个值,需要指定option allow_alias = true;,不然编译会失败
message RequestMsg{
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
enum City{
option allow_alias = true;
BEIJING = 0;
SHANGHAI = 1;
CHENGDU = 1;
}
optional City city = 4;[default=GUESS]
}
-
保留值
对于已经在使用中的massage,如果贸然修改枚举条目或者更新枚举类型,都可能对解析之前的消息产生影响。当用旧版本的message解析新数据或者用新版本的message取解析旧数据,都会造成数据的损害,错误等。
所以针对于上面说的这种问题,我们可以使用reserved来保留一些数值,那么之后再使用这些保留值的时候,新版本的message在编译代码过程就会失败
enum Foo {
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";
}
如上所示,第一条是保留2,15,9,10,11,40 到max,max当然是我们自定义的期望保留最大值
第二条是保留字段FOO,BAR
注:在同一个reserved语句中,保留字段名和数值不能混用
- message 导入
如果想要使用一个已有的message作为数据类型:
message SearchResponse {
repeated Result results = 1;
}
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
message导入到在Java中不可用
如果这个已有的message和目标message不在同一个文件中,那就需要用导入定义
-
message嵌套
我们可以在message内部定义message,同样这种嵌套的message也是可以被外部所引用的
定义:
message SearchResponse {
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
repeated Result results = 1;
}
引用:
message SomeOtherMessage {
SearchResponse.Result result = 1;
}
-
更改数据类型
如果是更改已有的message结果的时候,需要注意如下几点:- 对于已有字段的数字编号不要进行修改
- 可以增加新的字段,但是要注意的是新增字段的默认值,以便新代码可以正确解析旧代码生成的消息。旧代码在解析新代码生成的消息的时候会自动忽略新字段
- 新message中不再使用的字段编号,那么对应的字段就可以删除
- 可以修改message的字段类型,但是需要注意新旧字段类型的兼容性:
1. int32、uint32、int64、uint64和bool都兼容
2. sint32和sint64兼容
3. string和bytes只要是有效的 UTF-8 编码字节就兼容
4. fixed32兼容sfixed32,fixed64兼容sfixed64
5. 对于string,bytes,message类型,optional和repeated也是兼容的。如果旧message中字段是repeated修饰的,那么当新message中用optional修饰的时候,如果是基本类型的话,就会去取整个数据的最后一个值,如果是message类型的话,就会合并所有的值。注意对于布尔和枚举,这种操作是不安全的。被repeat修饰的布尔和枚举是以打包的方式进行序列化的,如果用optional去解析的话可能不会正确的解析
6. 在wire format 中,enum和int32, uint32, int64, and uint64 是兼容的(注意,当值不匹配的时候,多余的部分就会被裁掉)。反序列化消息的时候不同的语言客户端处理方式是不一样的,比如无法识别的enum类型会被保留在消息中,解析时如果处理取决于使用的语言环境,但是int将总是会被保留的
-
未知字段
未知字段指的是针对正确格式的消息,protocol buffer解析器却无法识别的字段。例如用旧的message去解析由新的message生成的消息,那么新增字段旧无法解析就属于是未知字段
在protocol3中,在解析过程中未知字段会被丢弃。在3.5之上的版本,重新引入了未知字段的保留以匹配protocol2行为。 -
any
any类型的使用,可以不用声明作为一个嵌入式类型来使用。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.
在不同的语言实现中都支持运行库去以一种类型安全的方式去pack和unpack any类型的值。例如,在Java语言中,Any 类型将具有特殊的pack()和unpack()访问器,而在 C++ 中有PackFrom()andUnpackTo()方法
- one of
如果我们的消息由很多个字段,并且最多再同一时间设置一个字段,那么就可以强制使用one of来修饰从而可以减少内存
one of 和其他的普通字段是一样的,除了可以共享内存中的所有字段外,而且在同一时间最多可以设置一个字段的值。如果设置了一个成员的值,那么其他成员的值会被清除掉。可以使用case()和WhichOneof()这两个方法来验证one of字段的值
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
如上例子所示,用oneof关键字来标识oneof的声明,后面跟的test_oneof是oneof的名称,我们可以增加除了map和repeated修饰之外任何类型的字段作为oneof字段。
在编译器生成的代码中,oneof字段和常规字段一样,都拥有自己的getter和setter方法,更多是oneof字段还可以检查设置的是哪个值
特点:
1)当设置了oneof字段以后会清除其他成员的值。因此如果设置了多次的化,那么以最后一次的设置为准
2)如果在解析数据的时候,发现了多个oneof字段的话,那么就以最后一个oneof字段为主
3)oneof不能由repeated
4)反射API适用于oneof字段
- 向后兼容
当增加或者是移除一个oneof字段的时候需要注意。检查oneof的值的时候如果返回None/NOT_SET,那就意味着oneof没有被set或者是设置为其他版本中独有的字段,这两种不同情况是无法确定的,是因为无法确定这个未知字段是否是oneof字段
标签重用问题
1) 将字段移入或者是移除oneof:如果字段被清除之后,在消息序列化和解析后,那么这些清除字段的信息就会丢失。但是,对于一个新的oneof,我们可以增加值也可以在已知只使用了一个值的情况下移除多个字段
2)把一个oneof字段删除了之后又添加回来:消息在被序列化和解析之后可能会删除当前设置的oneof字段
3)拆分或者是合并oneof:和移动一个常规字段是一样的
- Maps
如果我们想要创建一个关联映射作为数据定义的一部分,可以使用如下语法:
map<key_type, value_type> map_field = N;
key_type可以是整数和字符串类型(除了byte和float的标量类型)
注:
enum不是一个有效的key_type
value_type可以是除了map之外的任何类型
- map不可以被repeated修饰
- map并没有保证map中元素的顺序,所以不可以依赖顺序
- 当把a.proto转换成文本格式的时候,map会根据key进行排序,如果key的数字的话,那么就使用数字顺序进行排序
- 当解析或者合并的时候,如果有重复的key那么就以最后一个key为准。当解析文本状态的map的时候,如果有重复的key那么就会失败
- 如果map只有key没有value,那么序列化时候的行为取决于语言,在 C++、Java、Kotlin 和 Python 中,类型的默认值是序列化的,而在其他语言中则没有序列化。
- map的向后兼容
map语法等价于如下定义,所以对于不支持map的protocol buffer也是可以处理这类数据的
message MapFieldEntry {
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N;
任何支持map的协议缓冲区实现都可以生成和接受上述定义可以接受的数据。
- package
可以给protocol文件添加一个可选的package标识符,来预防protocol 名称的冲突
package foo.bar;
message Open { ... }
也可以在定义messgae字段的时候来使用package标识符
message Foo {
...
foo.bar.Open open = 1;
...
}
package对生成代码的影响主要是依赖使用的语言
- 在C++ 中,生成的类被包装在 C++ 命名空间中。例如,Open将在命名空间中foo::bar。
在Java和Kotlin 中,该包用作 Java 包,除非您option java_package在.proto文件中明确提供。 - 在Python 中, package 指令被忽略,因为 Python 模块是根据它们在文件系统中的位置组织的。
- 在Go 中,包用作 Go 包名称,除非您option go_package在.proto文件中明确提供。
- 在Ruby 中,生成的类被包裹在嵌套的 Ruby 命名空间中,转换为所需的 Ruby 大写样式(第一个字母大写;如果第一个字符不是字母,PB_则在前面)。例如,Open将在命名空间中Foo::Bar。
- 在C# 中,包在转换为 PascalCase 后用作命名空间,除非您option csharp_namespace在.proto文件中明确提供。例如,Open将在命名空间中Foo.Bar。
-
package和name的解决方案
protocol buffer中的类型名称解析和c++中的解析很相似,首先是搜索最内部的范围,然后依次向外扩展
protocol buffer编译器会通过解析导入的.proto文件来解析所有类型名称 -
定义服务
如果想要在rpc(远程过程调用)系统中使用消息类型,那么可以在.proto文件中定义一个服务接口,编译器会生成服务接口代码。例如:如果想要定义一个rpc服务方法,使用SearchRequest作为入参并返回SearchResponse,那么可以参照如下代码:
service SearchService {
rpc Search(SearchRequest) returns (SearchResponse);
}
与protocol buffer一起使用,最直接的rpc系统是grpc。grpc是一套语言和平台成熟的google开源系统,grpc特别使用与protocol buffer,并允许使用专门的 protocol buffer编译插件从.proto文件中直接生成相关的rpc代码
如果不想要使用grpc,也可以用自己实现的rpc去使用protocol buffer
-
json
proto3支持json的编码规范,使得系统之间的数据共享更加方便。
如果在json编码中缺少值或者值为null,在pb解析的时候都会被解析为默认值。如果这个字段是pb中有默认值,那么在json数据编码中就会省略该字段,以节省空间 -
option
在.proto文件中的每一个声明都可以有多个option。option并不会影响声明的整体含义,他只会影响他在特定上下文中处理方式。支持的option的完整可以在google/protobuf/descriptor.proto中看。
有些选项是文件级的选项,这意味着应该被写在最外层,而不是在message,enum和服务的定义中。
有一些选项是message级别的,那么就应该被定义在message内部。
有一些选项是field级别的,这意味着应该被定义在字段定义中。选项也可以写在枚举类型、枚举值、一个字段、服务类型和服务方法上;然而,目前没有任何有用的选择。
这是一些常用的选项:
- java_package(文件级选项):用于生成java/kotlin类的包。如果在.proto文件中没有明确指定java_package的option,那么就默认使用proto 包(使用文件中的“package”关键字指定.proto)。然而,proto包通常不会成为一个好的Java包,因为proto不会以反向域名开头。如果不生成Java或者是kotlin代码,那么该option就无效
option java_package = "com.example.foo";
- java_outer_classname(文件选项):用于你想要生成Java类名,如果在.proto文件中没有指定java_outer_classname,Java类名会以.proto的文件名转换为驼峰式为准(因此foo_bar.proto生成的类名是FooBar)。如果java_multiple_files的option指定为disabled,那么其他的classes/enums/
option java_outer_classname = "Ponycopter";
- java_multiple_files(文件选项):如果设置为false,一个proto文件将只会生成一个Java类,由最外层的message,services和enum生成的Java 类,枚举都会内嵌在java_outer_classname里面。如果设置为true,最外层的message,services和enum生成的Java类,枚举将作为一个单独的文件,并且由.proto映射生成的这个Java类不会包含任何的子类和子枚举。这个选项的值是一个布尔类型,默认是false,如果不是生成Java代码的话,这个选项就不会产生作用
- optimize_for(文件选项):可以被设置为SPEED, CODE_SIZE, or LITE_RUNTIME,主要影响由C++和java生成的代码:
1. SPEED(默认值):pb的编译器会生成序列化,解析和一些常规化的操作针对message。这是一个高效的代码
2. CODE_SIZE:pb的编译器会生成最少的类,并依赖共享和反射的代码来实现序列化,解析和其他的操作。因此生成的代码比SPEED模式下生成的代码要小,但是操作会更慢。每一个类都会和SPEED一样实现一些公共的API。这种模式主要用于包含大量的.proto文件且不需要快速执行的应用程序中
3. LITE_RUNTIME:pb的编译器会生成依赖于lite(libprotobuf-lite)运行库的类,lite运行库要比完整库小的多(大约会小一个量级),但是会省略掉一些功能,比如描述符和反射。这对于在手机等受限平台上运行的应用程序很管用。编译器仍然会像SPEED模式一样快速的生成所有的方法代码。生成的类只会实现MessageLite在每种语言中的接口。它只提供完整Message接口的方法的一个子集。
option optimize_for = CODE_SIZE;
- cc_enable_arenas(文件选项):为 C++ 生成的代码启用arena 分配。
- objc_class_prefix(文件选项):设置 Objective-C 类前缀,该前缀添加到所有 Objective-C 生成的类和此 .proto 中的枚举。没有默认值。您应该使用Apple 推荐的3-5 个大写字符之间的前缀。请注意,所有 2 个字母前缀都由 Apple 保留。
- deprecated(文件选项):如果设置为true,就说明这个字段已经被弃用了,在新的代码中就不应该使用了。在大部分的语言中,是没有实际效果的。在 Java 中,这变成了一个@Deprecated注解。将来,其他特定于语言的代码生成器可能会在字段的访问器上生成弃用注释,这反过来会导致在编译尝试使用该字段的代码时发出警告。如果该字段未被任何人使用并且您希望阻止新用户使用它,请考虑使用保留语句替换该字段声明。
int32 old_field = 6 [deprecated = true];
-
自定义选项
pb允许使用自定义的option。这个大部分人都不会使用的高级功能,如果您确实认为需要创建自己的选项,请参阅Proto2 语言指南了解详细信息。请注意,创建自定义选项使用extensions,仅允许用于 proto3 中的自定义选项。 -
生成自己的类
要生成 Java、Kotlin、Python、C++、Go、Ruby、Objective-C 或 C# 代码,您需要使用.proto文件中定义的消息类型,您需要protoc在.proto. 如果您尚未安装编译器,请下载该软件包并按照自述文件中的说明进行操作。对于 Go,您还需要为编译器安装一个特殊的代码生成器插件:您可以在 GitHub 上的golang/protobuf存储库中找到此插件和安装说明。
协议编译器的调用方式如下:
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指定.proto解析import指令时查找文件的目录。如果省略,则使用当前目录。通过–proto_path多次传递选项可以指定多个导入目录;将按顺序搜索它们。-I=_IMPORT_PATH_可以用作–proto_path.
您可以提供一个或多个输出指令:- cpp_out生成 C++ 代码DST_DIR。有关更多信息,请参阅C++ 生成的代码参考。
- java_out生成 Java 代码DST_DIR。有关更多信息,请参阅Java 生成的代码参考。
- kotlin_out生成额外的 Kotlin 代码DST_DIR。有关更多信息,请参阅Kotlin 生成的代码参考。
- python_out生成 Python 代码DST_DIR。有关更多信息,请参阅Python 生成的代码参考。
- go_out生成 Go 代码DST_DIR。有关更多信息,请参阅Go 生成的代码参考。
- ruby_out生成 Ruby 代码DST_DIR。Ruby 生成的代码参考即将推出!
- objc_out在DST_DIR. 有关更多信息,请参阅Objective-C 生成的代码参考。
- csharp_out生成 C# 代码DST_DIR。有关更多信息,请参阅C# 生成的代码参考。
- php_out生成 PHP 代码DST_DIR。有关更多信息,请参阅PHP 生成的代码参考。为方便起见,如果DST_DIR以.zip或结尾.jar,编译器会将输出写入具有给定名称的单个 ZIP 格式存档文件。.jar输出还将根据 Java JAR 规范的要求提供清单文件。请注意,如果输出存档已经存在,它将被覆盖;编译器不够聪明,无法将文件添加到现有存档中。
-
必须提供一个或多个.proto文件作为输入。.proto可以一次指定多个文件。尽管文件是相对于当前目录命名的,但每个文件都必须驻留在IMPORT_PATHs之一中,以便编译器可以确定其规范名称。