protobuf通信协议(proto2)
本文主要讲述如何使用protobuf语言来结构化数据,这包括.proto
文件语法,以及如何通过.proto
文件产生相应的访问类(class)。本文内容是以proto2为基础来进行讲解的。
通常我们建议使用proto3,因为其更容易使用,也支持更多的编程语言。
1. 定义消息类型
首先我们来看一个很简单的示例,我们需要定义一个搜索请求的消息格式,给搜素请求具有一个query string,查询的起始页,以及每页显示的条目数。如下所示我们定义一个.proto
文件:
SearchRequest
消息定义了三个字段。
1) 指定字段类型
在上面的例子中,所有的字段都是标量类型
(scalar types): 两个数值类型字段(page_number、result_per_page),一个字符串类型字段(query)。然而你也可以指定复合类型
,这包括枚举类型(enumerations)以及其他message类型.
标量类型(scalar types)是相对复合类型(Compound type)来说的:标量类型只能有一个值,而复合类型可以包含多个值。复合类型是由标量类型构成的
2) 指定字段编号
正如你所看到的,在消息定义中每一个字段都有一个唯一的编号。在protobuf二进制消息格式中这些编号
被用于标识相应的字段,在消息类型被使用之后这些编号值是不能再进行修改的。在1~15范围内的字段采用1个字节来编码,这包括字段编号以及字段类型; 在16~2047范围内的字段采用2个字节来编码。因此为了尽量降低整个消息的长度,对那些频繁出现的消息元素,尽量使用1~15范围内的编号。另外,记得为消息的扩展保留一些1~15范围的编号,这样可使得后面扩展一些频繁使用的消息元素时不会占用太多的空间。
最小的字段编号是1,最大的编号字段是2^29-1(即536870911)。另外,我们禁止使用编号19000至19999范围内的编号(FieldDescriptor::kFirstReservedNumber 至 FieldDescriptor::kLastReservedNumber),这个范围内的编号被protobuf协议所保留。假如你在.proto
文件中采用该范围内的字段,在protoc编译时会产生错误。同样也不能使用消息中明确被指定为保留的字段编号(通过reserved
关键字)。
3) 指定字段时的规则
你可以在指定消息字段时添加如下修饰符:
-
required: 表明该字段在消息中必须存在该字段
-
optional: 表明该字段在消息中可以出现0次或1次
-
repeated: 表明该字段可以在消息中出现任意多次(包括0次,即不出现)。这些重复出现的值的顺序在协议中并未有强制约定
由于历史原因,对于标量数值类型
(scalar numeric types)的repeated字段,其在编码时并不是那么高效。在新代码里我们添加一个额外的选项以获得更高效的编码,例如:
repeated int32 samples = 4 [packed=true];
4) 添加更多消息类型
我们可以在一个.proto
文件中定义多个消息类型。通常我们会把一些有关联
的消息类型都放到同一个.proto
文件中。例如:
5) 添加注释
我们可以使用C/C++风格(//或者/* … */)来为.proto
文件添加注释:
6) 保留字段
在有些情况下我们需要保留某些字段以及字段编号,我们可以通过如下方式:
注: 不能将保留的字段名称
与字段编号
写在同一行
7) 通过.proto会产生什么文件?
当你对一个.proto
文件运行protobuf编译器的时候,编译器会根据你所选定的语言产生特定代码,生成的代码里面通常会包含相应字段的get/set方法,将消息序列化到输出流的方法,以及从输入流中解析消息的方法:
-
对于C++语言,每一个
.proto
文件会产生一个.h
与.cc
文件,.proto
文件中的每一条消息对应一个类 -
对于Java语言,
.proto
文件中的每一条消息都会产生一个.java
文件,文件中包含该消息对应的class,以及一个Builder
类用于创建消息实例 -
对于Python语言,python编译器会产生一个模块,在模块中针对
.proto
文件中每一个消息类型有一个static descriptor。 -
对于Go语言,编译器会产生一个
.pb.go
文件,文件中有相应的类型对应于.proto
文件的每条消息
2. 标量值类型
标量消息类型字段可以是如下类型之一(下表展示了.proto
文件中的类型与该类型在对应语言中的转换):
.proto类型 说明 C++类型 Java类型 Python类型[2] Go类型 ------------------------------------------------------------------------------------------------------ double double double float float64 float float float float float32 int32 采用变长编码。对于负数,编码效率较低。因此假如 int32 int int int32 相应的字段会出现负值的话,建议采用sint32 int64 采用变长编码。对于负数,编码效率较低。因此假如 int64 long int/long[3] int64 相应的字段会出现负值的话,建议采用sint64 uint32 采用变长编码 uint32 int[1] int/long[3] uint32 uint64 采用变长编码 uint64 long[1] int/long[3] uint64 sint32 采用变长编码 int32 int int int32 sint64 采用变长编码 int64 long int/long[3] int64 fixed32 总是采用4个字节长度来编码,假如值经常大于2^28 uint32 int[1] int/long[3] uint32 情况下,比uint32高效 fixed64 总是采用8个字节长度来编码,假如值经常大于2^56 uint64 long[1] int/long[3] uint64 情况下,比uint64高效 sfixed32 总是采用4个字节长度来编码 int32 int int int32 sfixed64 总是采用8个字节长度来编码 int64 long int/long[3] int64 bool bool boolean bool bool string 字符串必须是UTF8编码或7位的ASCII文本 string String unicode(python2) string str(python3) bytes 可以包含任何序列的字节 string ByteString bytes []byte
对于上面类型后面中括号[]的说明如下:
[1]: 在Java中,usigned 32-bit与unsigned 64-bit整数是根据最高位是0
还是1
来进行区分的
[2]: 在所有情况下,当对某一个字段进行设置值的时候都会检查其是否有效
[3]: 64-bit或者unsigned 32-bit整数类型在解码时都会被解析成long类型,但是如果在设置值时传递的是int类型,那么其也可以是一个int
3. 可选字段以及默认值
正如上面所提到的,对于消息中的元素可以添加optional
修饰符。对于一个良好格式
(well-formed)的消息而言,假如在消息解析时并不包含该字段则会将该字段的值设为默认值。默认值可以在字段的后面进行说明,例如:
optional int32 result_per_page = 3 [default = 10];
假如可选字段并未明确指定默认值,则会采用类型特定的默认值。例如,对于字符串类型,该类型的默认值为空字符串。
4. 枚举
你可以使用enum
关键字类定义枚举类型。如下所示:
假如在一个枚举中,有两个不同的枚举常量其值相等(例如下面的STARTED
与RUNNING
),那么需要设置allow_alias
选项位true,否则proto编译器会报错:
另外枚举类型的值必须是一个32-bit整数所能表示的范围。因为enum类型时变长编码,负数通常编码效率较低,因此不建议采用负值。
你既可以在一个message定义中内嵌定义一个enum类型,也可以在message外部定义enum类型。如果我们在一个message内部定义enum类型,在另一个message中要引用该枚举类型的话,可用MessageType.EnumType方式。
4.1 枚举保留值
同样,我们为枚举保留某些name与值。例如:
5. 使用其他消息类型
你可以使用其他消息类型来作为field的类型。例如:
1) 导入定义
在上面的例子中,Result消息类型与SearchResponse消息类型定义在同一个.proto
文件中。但是如果定义在不同的.proto
文件中,我们该如何处理呢?此时我们可以导入另一个.proto
文件,语法如下:
import "myproject/other_protos.proto";
通常情况下,我们只能够使用直接导入的.proto
文件中定义的消息类型,而不能递归。如果想要递归,需要使用import public
。例如:
在client.proto
中我们只能使用old.proto
与new.proto
中定义的消息类型,而并不能使用other.proto
中定义的消息类型。
protobuf编译器在编译.proto
文件时会搜索-I
或--proto_path
选项指定的目录中的.proto
文件。假如并未指定相关选项的话,则只会搜索protoc的当前运行目录。通常你应该将--proto_path
设置为你当前项目的根目录。
2) 使用proto3消息类型
我们可以在proto2
消息中导入proto3
消息类型,或者相反。但proto2中的枚举不能被用在proto3语法中。通常建议不要混合使用。
6. 内嵌类型
你可以在一个message类型中定义和使用其他message类型。如下示例,我们在SearchResponse中定义了Result消息类型:
假如你想要在SearchResponse
外边复用Result
消息类型的话,你可以使用Parent.Type
来引用。例如:
目前来说,内嵌类型的深度暂时没有限制(但不建议有太深的内嵌):
6.1 Groups
Groups是在消息中定义内嵌类型的另一种方式,例如:
上面SearchResponse包含了一个Result列表。
说明
: 不要使用Groups这种方式来定义内嵌类型,该方式已经过时。使用前面介绍的第一种方式。
7. 更新消息类型
假如我们当前的消息类型已经不能很好的满足需求的时候(例如我们想要在message中添加一个新的field),在不破坏向下兼容的情况下,我们可以很简单的更新Message类型。但是请注意,需要遵循如下规则:
-
不要修改现存字段(field)的编号
-
添加一个新的字段(field),并将字段设为optional或者repeated,同时为该新字段设置合适的默认值。这意味着我们的新代码仍可识别该消息的
旧格式
,同时老代码也仍能识别消息的新格式
(其会简单的忽略该新添加的未知字段)。 -
可以将
Non-required
字段简单的移除。但是移除时,可能需要将该字段所占用的字段编号
保留,以免后续新添加的字段用到该字段编号(使用reserved
来保留字段编号) -
将
none-required
字段设置为extensions -
int32, uint32, int64, uint64,bool 之间是兼容的
-
sint32 与 sint64 之间是兼容的
-
string 与 bytes之间是兼容的
-
fixed32 与 sfixed32之间是兼容的,fixed64 与 sfixed64之间是兼容的
-
optional与repeated之间也是兼容的
-
通常来说修改默认值是允许的
-
enum与int32, uint32, int64,uint64是兼容的
8. Extensions
Extensions
允许你在消息中声明某一个范围内字段编号是用作第三方扩展的。其实扩展
就是为一个字段(filed)预留的恶一个占位符,后续就可以在其他的.proto
文件中添加新的字段到该占位符中。例如:
上面显示[100,199]范围内的字段编号被保留用于以后的扩展。因此其他用户可以在自己的.proto
文件中先导入上述.proto
文件,然后再使用该保留的字段编号范围来对Foo
消息进行扩展。例如:
我们添加了一个bar
字段到原来的Foo
消息中,其中字段编号为126.
当Foo消息被序列化时,扩展的bar字段会被正常的序列化进去。然而我们访问扩展字段
与访问普通字段
有些不同,我们有专门的方法来访问扩展字段。如下是C++语言中访问扩展字段的方法:
相似的,Foo
类还还提供了如下的一些模板方法来访问扩展字段:
HasExtension() ClearExtension() GetExtension() MutableExtension() AddExtension()
值得注意的是,扩展字段可以是任何类型,包括message类型,但是不能是oneof
或者map
类型
8.1 内嵌扩展
我们可以在另一个messageB中对messageA进行扩展,例如:
在这种情况下,如果我们要通过C++来访问扩展的话,可以通过如下方式:
注: 我们通常不建议使用此种方式,这里也不对此种使用方式做更多说明。
8.2 选择扩展字段编号
在扩展字段时,很重要的一点是两个不同的用户不会使用相同的字段编号
来对消息进行扩展(这会导致数据被损坏)。假如你向保留的扩展字段编号的范围很大的话,可以采用如下这种方式:
这里max为2^29-1,即 536,870,911。
同时我们应该注意不要选在[19000,19999]范围内的字段编号,该范围内字段编号通常为系统保留。其中19000可以用FieldDescriptor::kFirstReservedNumber来引用;19999可以用FieldDescriptor::kLastReservedNumber来引用。
9. Oneof
假如在一个message中有很多字段(field)是optional
的,并且这些字段在同一时间内至多只有一个会被设置,我们可以使用Oneof
特征来节省内存空间。
oneof中的字段类似于optional
字段,只不过oneof中的所有字段都共享相同的内存空间(类似于C语言中的union),并且oneof在同一时间内最多只有一个字段会被设置。设置oneof中的任何一个字段都会清除其他字段的值。我们可以根据自己所选定的编程语言通过case()或者WhichOneof()方法来检查到底哪一个字段被设置了。
9.1 Oneof的使用
如果要在.proto
文件中定义一个oneof
,那么可以使用oneof
关键字后面跟随对应的名称即可。例如:
我们可以在oneof
中添加任何字段,但是不能使用required
、optional
、repeated
关键词来修饰。假如我们要添加一个repeated
字段到oneof中,这是禁止的,此时我们可以直接在message中添加该repeated字段。
在通过protoc
编译生成的代码中,oneof
中的每一个字段同样会生成对应的getter、settter方法。
9.2 Oneof特征
- 设置oneof中的某一个字段会自动的清空oneof中的其他字段的值(因为它们共享存储空间),因此假如你你设定多个oneof字段的值,那么之后最后设置的那个字段值有效
-
假如解析器遇到多个oneof中的字段,那么只有遇到的最后一个oneof字段有效
-
扩展字段并不允许oneof
-
oneof中的字段并不能是repeated的
-
假如你将某个oneof字段设置有默认值,那么该字段是会被序列化的,并且针对该字段调用
case
,会返回字段被设置
-
假如你采用C++的话,请确保代码不会造成内存被破坏。如下的示例代码会导致程序崩溃,因为调用set_name()时会将sub_message分配的内存给删除
- 假如你采用C++,并且使用Swap()来交换两个带oneof的message,则交换之后每个message都带有对方的一个oneof字段
9.3 Oneof兼容性问题
在添加或移除oneof中的字段时要特别小心。假如检查某个oneof的值返回None或NOT_SET的话,可能对应两种情况:
-
该oneof并未被设置
-
设置了另一个不同版本的oneof(比如我们在oneof的第一个版本中有2个字段,在第二个版本中新添加了一个字段后变成了3个字段)
10. Maps
假如你想要定义一个map的话,可以通过如下方式:
这里key_type
可以是整数类型
或字符串类型
(任何scalar类型都可以,除浮点类型与bytes类型外)。另外,enum类型不能作为key_type。value_type可以是除map类型外的任何类型。
例如:
1) Map的特征
-
map并不支持Extensions
-
map并不能使用repeated、optional、required等关键词修饰
-
序列化或遍历map中的元素时并没有特定的顺序
2) 向下兼容性
map语法定义的数据在网络上传输时等价于如下格式:
因此即使protobuf的实现并不支持map的话,其仍然可以处理对应的数据。
11. Packages
我们可以为某个.proto
文件添加一个package
(可选)以防止消息类型的冲突(类似于namespace的概念):
当我们在定义自己的message类型时可通过如下的方式来引用别的package中的类型:
再用protoc编译器产生对应的代码时,会根据我们选定的语言对package
做不同的处理:
-
C++语言中会产生对应的c++ namespace。比如上面
Open
将会在foo::bar名称空间中 -
Java语言会产生对应的Java package,
-
Python语言会忽略该
package
指令 -
Go语言会忽略该
package
指令
通常情况下我们建议使用package
以防止名称冲突。
12. 定义Service
假如你想要在一个RPC系统中使用message类型,你可以在.proto
文件中定义RPC服务接口(interface),之后protobuf编译器就会产生对应的服务接口代码和stubs。比如,我们想定义一个RPC服务,其有一个方法Search,参数为SearchRequest
,返回结果为SearchResponse
,那么可以如下定义.proto
文件:
默认情况下,protobuf编译器会产生一个抽象接口SearchService
和一个具体的stub
实现(类似于EJB的skeleton与stub概念)。stub会把所有的请求发送到RpcChannel
,之后由我们对RpcChannel的具体实现来真正发送出去。比如,我们有一个RpcChannel其实现了序列化消息,然后把该序列化后的消息通过HTTP发送到服务器。因此,对于C++来说其可能产生类似如下的代码:
如上所示,所有的service类都实现了Service
接口。
另外,在服务器一端,这可以用来实现一个RPC Server,并通过其来注册相关的服务,如下所示:
假如你并不想要将protobuf嵌入到你自己的RPC系统中,那么你可以使用gRPC
: 它是Google所开发的一个跨语言跨平台的开源RPC系统。gRPC可以很好的协同protobuf工作,并且通过特定的protobuf编译器插件可以直接从.proto
文件生成相应的RPC代码。然而由于使用proto2与proto3产生的客户端、服务器服务器代码存在一些潜在的兼容性问题,因此我们建议当使用gPRC
服务时使用proto3。
说明: 要想生成上述代码可能需要在.proto
文件中加上如下选项
option cc_generic_services = true;
13. Options
我们可以在.proto
文件中添加一系列的options
。通过在.proto
文件中添加options,虽然其并不会改变文件中相关声明(message、service等)的含义,但是在一个特定上下文下却可能影响对相关声明的处理。完整的option
列表定义在google/protobuf/descriptor.proto。
其中有一些选项
是文件级别(file-level)的选项,这意味着它们应该写在最外层,而不应该放在message、enum、或service内;有一些选项
是属于消息级别(message-level)的选项,这意味着它们应该放在message定义中;有一些选项是属于字段级别(field-level)的选项,这意味着它们只能用在字段定义中。当前并没有针对enum types、enum values、service types、service methods的选项。
如下我们列出一些常用的选项:
- java_package(file option): 指定你要生成的Java类所需要使用的包名。假如并未在
.proto
文件汇中指定java_package
选项的话, 则默认会使用package
关键字所指定的包名。假如我们并不生成Java代码的话,则本选项并不会起任何作用
option java_package = "com.example.foo";
- java_outer_classname(file option): 用于指定最外层Java类的名称。假如并在
.proto
文件中显式指定本选项的话,则会将.proto
文件名按驼峰格式转换为类名(例如foo_bar.proto会转换为FooBar.java)。假如我们并不生成Java代码的话,则本选项不会起任何作用
option java_outer_classname = "Ponycopter";
- optimize_for (file option): 可以被设置的值有
SPEED
、CODE_SIZE
或LITE_RUNTIME
,这会以如下方式影响C++和Java生成器
如下是使用本选项的格式:
option optimize_for = CODE_SIZE;
- cc_generic_services, java_generic_services, py_generic_services (file options): 用于告诉protobuf编译器在产生C++、Java、Python等代码时是否针对
service
定义产生对应的抽象代码。通常情况下,默认值为为true
。然而自从2.3.0版本以来,考虑到在具体的RPC实现时,由所对应的code generator plugins能够生成更符合系统要求的代码,因此将默认值就改为了false
,表示依赖plugins来生成service代码:
// This file relies on plugins to generate service code. option cc_generic_services = false; option java_generic_services = false; option py_generic_services = false;
-
cc_enable_arenas (file option): 表示针对生成的C++代码启用
arena allocation
-
message_set_wire_format (message option): 假如设置为
true
,则生成的二进制格式(binary format)会兼容Google内部的一种称为MessageSet
老格式。非Google内部员工的话,通常不会用到此选项
message Foo { option message_set_wire_format = true; extensions 4 to max; }
- packed (field option): 假如在一个由
repeated
修饰的数值类型字段上将此选项设置为true
的话,则会使用一种更为紧凑的编码方式。通常使用此选项并不会产生任何缺陷,但是假如在2.3.0
版本之前,当解析器收到packed
的数据时则会将忽略相应的字段,因此这可能会造成不兼容性。在2.3.0
版本(包括该版本)之后,则通产不会产生任何问题。
repeated int32 samples = 4 [packed=true];
- deprecated (field option): 假如将此选项设置为true,则表明该该字段已经过时,在新的代码中不应该被使用。在大多数语言中其并不会产生任何实质性影响。在Java语言中,其会被标记上
@Deprecated
注释。假如对应的字段(field)并不会被任何人使用,可以考虑使用reserved
来阻止新用户继续使用它。本选项的使用格式如下:
optional int32 old_field = 6 [deprecated=true];
13.1 自定义选项
protobuf甚至允许你定义和使用自己的选项(option)。值得指出的是这是一个高级特性
,大部分人并不需要用到此特性。由于options
是定义在google/protobuf/descriptor.proto文件的messages中的,因此在定义我们自己的选项时就相当于扩展
(extend)这些message。例如:
import "google/protobuf/descriptor.proto"; extend google.protobuf.MessageOptions { optional string my_option = 51234; } message MyMessage { option (my_option) = "Hello world!"; }
上面我们通过继承MessageOptions定义了一个新的消息级别
(message-level)的选项。当我们使用此选项的时候,选项名称必须用一对小括号()
括起来,用于指示这是我们的扩充的自定义选项。在C++中,我们可以采用类似于如下的代码来读取my_option
选项的值:
上面MyMessage::descriptor()->options()
会为MyMessage
返回一个MessageOptions
对象,然后就可以从它来读取自定义选项。
类似的,在Java中我们可以用如下代码:
在Python中,我们可以用如下代码:
在protobuf语言中,我们可以为每一种类型都扩展自定义选项,参看如下示例:
值得指出的是,假如你想要在一个package中使用另一个package定义的自定义选项,那么你必须加上对应的package-name
前缀,参看如下示例:
上面在bar.proto
中定义了bar
这个package,现在要使用foo.proto
文件中foo
这个package定义的选项,因此需要加上前缀foo
。
最后一点就是,由于自定义选项是扩展(extensions),因此因此你必须为该字段(field)指定一个编号。在上面的例子中,我们使用的编号范围是50000-99999,该范围内的编号被保留用作各组织
内部使用,因此我们可以在自己的应用程序中自由的使用。假如你想要在一些公开的应用程序(public applications)中使用自定义选项,你最好确保所对应的字段编号是全球唯一的。为了获取全局唯一的字段编号,你可以发送邮件到protobuf global extension registry进行登记。
我们可以只使用一个字段编号
来声明多个自定义选项,那就是将这些选项放到一个子消息(sub-message)中。参看如下:
上面我们定义了一个自定义选项foo_options
,但是其拥有两个属性。我们通过FooOptions
封装了opt1
和opt2
。
同样,每一个选项类型(file-level, message-level, field-level等)都有其自己的字段编号空间
(number space),这意味着我们可以在FieldOptions与MessageOptions中有相同的字段编号的自定义选项。
14. 将.proto文件生成class
为了将.proto
文件中的message类型生成对应的Java、Python、C++代码,你需要运行protobuf编译器protoc
。
protoc
的调用方式类似于如下:
--proto_path
选项的主要作用是当解析到.proto
文件的import指令时,应该从哪个目录导入。假如省略此选项的话,则默认导入目录为当前目录。如果需要导入多个目录的话,可以多次使用本选项。本选项的缩写形式为-I
[参看]: