本文主要描述如何:1)利用protocol buffer来(在 .proto文件中)构建自定义protocol buffer数据结构以及.proto文件语法。2)如何生成一个“接入类”通过它我们可以接入到.proto文件中。
注:本文均翻译自Google Developers中的文档:https://developers.google.com/protocol-buffers/docs/proto
本文提供URL在Google Developer上面的,需要科学上网,推荐使用lantern
如果本文存在翻译错误,难以理解之处以及笔误等,请大家提出,谢谢!
本文会结合实例介绍protobuf语法。并且会给出一个实例训练。
定义message
首先,我们用一个实例说明。在这里我们定义一个搜索请求消息的格式:每一条搜索请求有一个string类型的请求,一个我们希望搜索的页数,以及每一页期望返回多少结果。那么我们可以在.proto文件中定义以下的结构:
message SearchRequest{
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}
SearchRequset message定义了我们所期望的3个域(field),每一个域都有name/value对,并且每一个域都有一个name 和一个type。
可自定义域的类型
我们在上面展示的例子中,所有的域都是标量:包含两个整数变量和一个string类型的变量。However,protobuf可以表示更复杂的数据形式。比如说我们可以用枚举表示一些向量,以及通过其他message组合出一些type。直观来讲有点类似C++中多个子类组合成一个类。
分配Tags
从上面的例子可以看出,我们在定义message的时候,每一个field都有一个数字。这时一个唯一标识符(在一个message层次中是unique的。嵌套message可以重新开始)。这些标识符称为tag它们是用来标识这些fields的二进制编码方式(序列化以及解析的时候会用到)。当这些tags的值在[1,15]区间内,则表示这些域的类型和以及tag一起用一个byte编码。在区间[16,2047]则表示用2个bytes进行编码。因此我们需要将[1,15]这些tag留给使用频率高的元素,并且在设计的时候要为未来可能高频使用的元素预留一些[1,15]中的tag。
tag的范围是在 1∼229−1 范围内,但是[19000,19999]这些tag是保留的,如果我们使用这些tag那么我们在编译.proto文件的时候会报错。当然如果我们声明了一些保留tag,那么那些tag也是不能使用的。
域(field)的介绍
我们在定义Message的时候protobuf提供了3种可选域:
- required: message中必须至少包含一个required域,并且在序列化之前必须被赋值。
- optional: message中需要包含0个以上optional域。
- repeated:这个域用来保存一些要重复设置的变量,这些变量可以设置0次到多次。并且顺序保存。(用于设置数组)。
由于历史原因repeated域并不会做出非常有效的编码方式,因此我们需要在声明的时候加上一段选项[packed=true],让它以一个更有效的方式编码:
repeated int32 samples = 4 [packed=true];
一些简单的操作
- 将相关的Message定义在一个文件中,比如我们希望在SearchRequest之后再定义一个response:
message SearchRequest{
......
}
message SearchResponse{
......
}
注释,和C++一样,用//
设置保留域
这个设置的目的主要是考虑一个拓展性,如果我们在更新message的时候,仅仅是删除不需要的域或者是将其注释掉,那么将来我们如果需要在这个message上添加fields的时候就会重复利用这些tag。这会带来一个数据读取问题,以及一些数据隐私问题。因为有的用户可能使用的是旧版本的.proto文件生成的类,那么利用旧版本读取新数据的时候将会得到意想不到的结果。
好在Protocol buffer提供了一个机制(google程序员很牛哔~)。来避免这个的发生。我们将这些域设置成为reserved即“保留起来”。
message Foo{
reserved 2, 15, 9 to 11;
reserved "foo", "var";
}
通过这种方法,我们就不能使用这些域名字和这些tag了。
数据类型
.proto文件中必须使用这些数据类型,在编译过后这些数据类型会编译成为对应的类中的数据类型。
.proto中的类型 | 注意 | C++中类型 | java中类型 |
---|---|---|---|
double | double | double | |
float | float | float | |
int32 | 自动调整编码长度,如果需要保存负数,请使用sint32 | int32 | int |
int64 | 自动调整编码长度,如果需要保存负数,请使用sint64 | int64 | long |
uint32 | 自动调整编码长度 | uint32 | int |
uint64 | 自动调整编码长度 | uint64 | long |
sint32 | 自动调整编码长度,表示有符号数,负数的编码效率高于int32 | int32 | int |
sint64 | 自动调整编码长度,表示有符号数,负数的编码效率高于int64 | int64 | long |
fixed32 | 固定使用4bytes编码,在编码大数( 228 )的时候比uint32更有效率 | int32 | int |
fixed32 | 固定使用8bytes编码,在编码大数( 256 )的时候比uint64更有效率 | int42 | long |
sfixed32 | 固定使用4bytes编码 | int32 | int |
sfixed64 | 固定使用8bytes编码 | int64 | long |
bool | bool | boolean | |
string | string只能包含UTF-8和7-bit ASCII文本 | string | String |
bytes | 包含任意长度的bytes | string | ByteString |
optional域和默认值
我们在上面提到,message中的元素可以用optional域来描述。一个message可以包含0个或者多个opational域。
在解析message时,如果不存在optional域那么就会用默认值来替代,如果没有设置默认值。那么就用系统的默认值替代。protobuf设计了很多默认值,比如:string的默认值是空,bool默认值是false,对于数来说所有的默认值都是0。对于所有的枚举类型,默认值均为第一个枚举值。设置optional域默认值的语法是:
optional int32 restult_per_page = 3 [default = 10];
枚举
当我们需要定义Message中的一个域,这个域的取值是我们预定义的一个集合中的一个元素。比如说,我们要在SearchRequest中添加一个域,这个域的取值是:UNIVERSAL, WEB, IMAGES, LOCAL, NEWS, PRODUCTS或者VIDEO中的一个,这时我们就需要枚举来帮忙了。
通过使用枚举,我们可以在message中添加一个枚举域,在这个域中定义一些常量。示例代码:
message SearchRequest{
required string query = 1;
optional int32 page_numer =2;
optional int32 result_per_page = 3[default = 10];
enum Corpus{
UNIVERAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
optional Corpus corpus = 4 [default = UNIVERSAL];
}
我们还可以在枚举中设置多个name对应一个相同的值,这是protocol buffer的别名机制(alias)。当然,我们必须首先设置这个别名,即,在enum中将allow_alias的选项设置为true。代码示例:
enum EnumAllowingAlias{
option allow_alias = true;
UNKOWN = 0;
STARTED = 1;
RUNNING = 1;
}
enum EnumNotAllowingAlias{
UNKNOWN = 0;
STARTED = 1;
}
枚举类型必须能够用32bit整数表示,并且由于编码效率原因不建议使用负数。我们还可以在.proto文件中利用已经声明的枚举类型来声明一个域的类型。在不同message中可以使用相同的枚举类型。通过MessageType.EnumType使用(与C++中对象使用的方式类似)。
message嵌套
例如,我们希望在一个message中使用另一个message类型,则可以使用如下方法:
message SearchResponse{
repeated Result result = 1;
}
message Result{
required string url = 1;
optional string title = 2;
repeated string snippets = 3;
}
导入其他.proto文件
上面的嵌套例子中,Result和SearchResponse在一个文件中,那么如果SearchResponse和Result不在一个文件中。应该怎么办呢?在我们的.proto文件顶部添加一个”import”即可导入其他文件,示例代码:
import "myproject/other_protos.proto"
这个是默认情况下的导入,也是直接导入。protobuf使用了一种间接导入的机制(import public),将A.proto间接导入到B.proto,B.proto直接导入到C.proto。则C.proto也可以使用A.proto中的定义。这解决了什么问题呢?假设我们在原来.proto文件中做修改,那么必须通知所有客户端修改这份文件。如果使用间接导入的机制,我们只需要增加一个间接导入文件即可。
新增加文件:
//new.proto
//我们需要增加的定义放在这里
旧文件:
//old.proto
//这是客户端import的.proto文件
import public "new.proto"
import "other.proto"
客户端文件:
//client.proto
import "old.proto"
//这样就可以在这里使用old.proto和new.proto中的定义,但是不可以使用other.proto中的定义
protocol 的编译器会在编译的时候搜索import文件目录,这里要通过编译选项-I/–proto_path (之后会介绍)设置。如果没有设置这个选项,则protocol编译器会自动在编译器所在目录进行搜索。
proto3 Message类型
由于历史原因protocol buffer有着不同版本,之后我们会介绍Proto3,因此本节仅仅做一个简单介绍。
这里就介绍一点,poto3可以使用proto2语法,但是反之不成立。也就是说我们可以在proto2文件中Import .proto3文件,但是不能再proto3中导入proto2文件。
嵌套(nested)和组合(group)
我们可以在Message中定义Message,这就是嵌套。Protocol可以有多层的嵌套,并且可以在其他的message(message_other)中使用嵌套在message_a中的Message。
在Message中定义一个Message并使用:
message SearchResponse {
message Result {
required string url = 1;
optional string title = 2;
repeated string snippets = 3;
}
repeated Result result = 1;
}
在其他地方使用上面定义的Message
message SomeOtherMessage {
optional SearchResponse.Result result = 1;
}
嵌套定义Message
message Outer { // Level 0
message MiddleAA { // Level 1
message Inner { // Level 2
required int64 ival = 1;
optional bool booly = 2;
}
}
message MiddleBB { // Level 1
message Inner { // Level 2
required int32 ival = 1;
optional bool booly = 2;
}
}
}
我们可以将很多元素集中在Message中的一个域,这就是组合。However,组合已经是过时(deprcated)的用法了,现在我们定义一些新的Message都是用嵌套的方式。
组合的语法:
message SearchResponse {
repeated group Result = 1 {
required string url = 2;
optional string title = 3;
repeated string snippets = 4;
}
}
更新Message
很多时候,随着项目的扩展,我们都需要在Message中增添一些额外的域,但是我们也希望之前的版本兼容新的proto文件。protocol buffer中的更新机制提供的一些兼容性设计,以下是一些更新规则:
- 不可以修改已存在域中的tag
- 所有新增添的域必须是 optional 或者 repeated。这就意味着,只要不遗失已经定义的required域,旧序列化信息可以被新的代码解析。为了新代码可以正常解析旧代码提供的序列,我们需要在更新的时候向新域中添加一些默认值(默认值是不会被传递的,仅仅在本地保存,也就是说不同版本的代码对同一个域可能读取不同的值)。同样,新代码产生的序列化文件也可以被旧代码解析,解析时旧代码会忽略新添加的域,这些域在解析时不会被丢弃,在旧代码序列化时,这些域会跟随其他域一起被序列化。将序列化之后的文件传递给新代码。这些新域也是有效的。
- 非required域可以被删除。但是这些被删除域的tag不可以被重用。所以我们需要将其添加至reserved中,防止被意外使用。
- 非required域可以被转化,转化时可能发生扩展或者截断,此时tag和name都是不变的。
- int32, uint32, int64, uint64 和bool都是相互兼容的。这就意味着这些类型之间可以相互转换,并且都是向前,向后兼容的。如果我们设定的类别不足以表示数字,那么就会发生类似于C++的截断。
- sint32和sint64是相互兼容的,并且不和其他整数类型兼容
- string 在UTF-8编码时与bytes相互兼容
- 嵌套Message在编码方式相同的情况下兼容bytes
- fixed32兼容sfixed32。 fixed64兼容sfixed64。
- optional兼容repeated。发送端发送repeated域,用户使用optional域读取,将会读取repeated域的最后一个元素。
- 改变默认值通常来说是没有问题的。但值得注意的是:只要用户的版本没有更新,那么在没有设置这个域的情况下,用户依旧是读取原来的默认值。
- 枚举类型兼容int32, uint32, int64, uint64(注意如果值和类型不匹配则会发生截断),所以我们需要注意,用户端可能在解析之后对这些值进行非enum的一些处理。通常,没能识别(不在enum定义的集合内)的enum值将会被丢弃,此时如果使用has方法,则会返回false。用getter方法读取这个值,会返回enum 列表的第一个值,如果定义了default则返回default的值。在repeated enum域里面所有不可识别的元素都将被移除这个列表,除非是整数域,它会保留整数域的值。因此,我们在提升一个整数类型为enum类型的时候要注意是否超过其值域。
- 在现有的java和c++应用中,当不能识别的enum被移除时,它其实是和其他不可识别的元素一起保存在了“未知域(unknown fields)”。当客户端在此解析未知域的时候,如果能够识别,则有可能发生一些奇怪行为。在optional域中,在原来消息解析完毕并写入新的数据之后,用户依然可以读取原来的消息。在repeated域中,旧的值将会出现在新添加的值之后,因此其顺序不是固定的。
Extensions
Extensions可以让我们在Message中定义一些域,这些域可以交由第三方进行扩展我们定义的Message。
示例代码:
message Foo{
//.....
extensions 100 to 199;
}
上述代码通过extensions为之后的扩展预留了[100,199]的tag。其他人可以扩展这个Message,并通过这些预留的tag定义自己的域。
extend Foo{
optional int32 bar = 123;
}
这样我们就通过扩展Foo定义了一个域bar。
添加扩展之后的Message和我们自定义的Message(创建者自己添加域到中间)的编码结果是一样的。但是我们通过代码接入的方式略有差别,我们在编译.proto的时候会产生特殊的接入方法,例如在C++中:
Foo foo;
foo.SetExtension(bar,15);
类似的,Foo的类会提供一些其他的接入方式查看Foo的一些属性,如:HasExtensions(), ClearExtensions(), GetExtensions(), MutableExtension(), 以及AddExtension()。这些方法的功能和语义一致。其他语言的接口可以在protocol buffer的相关文档中找到。
**注意:**extensions可以定义为所有的域,包括Message但是不能定义为oneofs 和 maps
嵌套extensions
我们可以在其他域中定义嵌套的extensions:
message Baz{
extend Foo{
optional int32 bar = 126;
}
...
}
这种情况下C++接入的方式为
Foo foo;
foo.SetExtension(Baz::bar, 15);
换句话说,这仅仅作用于Baz的bar域。
extensions很容易让人误以为是对原来域的扩展,其实从上例可以看出,,这种嵌入式的定义,并不表明二者存在子集的关系,并不表明Baz是Foo的子集。在Foo中使用extend表明,符号bar是在Baz中定义
一种比较常规的定义方式是将extensions定义在其中一个域的范围内,例如我们将Foo的extensions定义在Baz内,
message Baz{
extend Foo{
optional Baz foo_ext = 127;
}
}
当然语法中也没有规定必须像上例一样,将extensions定义在它的域的范围内。所以也可以这样定义:
message Baz{
...
}
//这个可以在另外的文件中
extend Foo{
optional Baz foo_baz_ext = 127;
}
这样的定义也许可以更清晰展示其中的关系,而嵌套定义的方式会使得初学者误认为存在一个子集的关系。
选择extensions的tags
在extension中的同一个Message中切忌使用同一个tag。否则,将会造成数据损坏。所以设计的时候需要考虑一种tag序号约定,以免造成以上结果。
如果我们设计的tag序号约定包含了一些很大的tag序号,那么我们可以这么做:选择一个起始tag序号,将其范围设置到最大值(这时需要利用max关键字)
message Foo{
extensions 1000 to max;
}
max是 219−1 即,536,870,911
在定义tag序号约定时候,我们需要避免使用[19000,1999]这些序号,因为这些序号是为protocol buffer预留的。虽然定义的tag需要约束在这个范围内不会报错,但是在protocol 编译的时候不会让我们使用这写保留的tag的。
Oneof
如果我们定义的Message包含很多optional域,但是我们仅仅会set这些optional中的最多一个。我们就可以使用oneof来保证这种set机制,并且会节省memory。(注:并不是说optional域在oneof中,这里只是举个例子)
oneof域和optional域仅有一个不同,就是oneof域中的所有域是共享memory的,并且每一次只能够设置一个域,我们在设置oneof中的任意一个域,将会自动清除其他设置。(很容易理解,因为只有一个使用空间)。如果oneof域中设置了其中一个域的时候,使用case()或者是WhichOneof()方法(这要根据我们使用的语言选择)来查看这个域。
如何使用Oneof
在.proto文件中定义oneof,需要使用oneof关键字,并在其后设置 oneof的名字:
message SampleMessage{
oneof test_oneof{
string name = 4;
SubMessage sub_message = 9;
}
}
我们可以在Oneof域中定义oneof域,并且可以在oneof中添加任意的域,但是不可以使用required,optional,repeated关键字(但是其中出现的自定义结构可以含有这些)。
在proto编译之后的代码中,我们可以看到Oneof的getter和setter方法和普通的optional域生成出来的是一样的,但是它还会提供一写特殊的方法来查看某一个域是不是设置好了。在编写这个的时候可能会需要根据语言选择API,API传送门:https://developers.google.com/protocol-buffers/docs/reference/overview
oneof的特性
- 设置oneof的域,会自动清除之前的设置(毕竟只有一个空间嘛!),所以当我们对oneof进行一系列设置的时候,仅会保留最后一个值。
SampleMessage message;
message.set_name("Mallock");
CHECK(message.has_name());
message.set_mutable_sub_message();//注意,这条语句会清除之前的设置!
CHECK(!message.has_name());
- 如果解析器在一个oneof中解析到了多个值,那么仅仅会使用最后一个解析值
- extensions不支持oneof
- oneof域中不可以出现repeated
- 反射机制的API可以在oneof域上工作
- 如果使用C++编程,需要注意避免代码出现memory crashes。以下代码会崩溃,因为sub_message在调用set_name()的时候已经删除。
SampleMessage message;
Submessage *sub_message = message.mutable_sub_message();
message.set_name("Mallock") ;//注意,这里sub_message已经被删除了
sub_message->set_...//Ops~这里崩溃了
- 又是C++,如果对两个oneof使用了Swap()方法,那么这两个message将会相互交换设置域。如下,msg1将会有sub_message域,msg2将会有name域。
SampleMessage msg1;
msg1.set_name("Mallock");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());
后向兼容
谨记,在添加和删除域的时候要格外注意(其实proto的修改都要注意这个问题)。如果我们在检测oneof的值的时候返回了None/NOT_SET,这表示,oneof还没有被设置,或者是oneof在不同的版本中被设置。没有方法区分这两种情况,由于没法检测这个未知的域是不是oneof中的域。
Tag的重用问题:
* 在oneof中添加或者删除optional域:在Message被序列化或解析之后,有可能会丢失一些信息(一些域可能会被清除)
* 删除一个oneof域或者是把这个域添加回来:在Message被序列化或解析之后,可能会清除现在已经设置的域。
* 分裂或者融合oneof:和第一个类似。
Maps
protocol buffer提供了map关键字,我们可以在数据结构中定义一个关联映射的结构:
map<key_type, value_type> map_field = N;
key_type是一种整数或者string类型(除了浮点和bytes类型的标量均可)。
value_type可以是任意类型。
我们需要定义一个map,它是string类型和Project的关系映射(Project是一个Message)。那么可以定义成为如下形式:
map<string, Project> projects = 3;
Maps的特性
- map不支持extensions
- Maps不能定义为repeated, optional, 或者required
- 在编码之后map中值的顺序是未定义的,遍历map的value时,这些value并不会遵循某一特定顺序。
- 为proto文件生成的文档中,maps是按照key来排序的,如果key是蒸煮,那么就按照整数顺序排序。
- 如果在解析序列化文件的时候出现多个Key的情况,那么将会使用最后一个。如果在解析文本文件的时候出现多个key,那么将会报错。
后向兼容
map的语法和下面的代码生成的数据格式是等价的。因此不支持Map的protocol buffer也可以利用下面的语法来完成map的工作
message MapFieldEnty{
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N;
packages
通过使用packages选项,可以避免一个.proto文件中不同Message中名字冲突问题。
package foo.bar;
message Open{...}
我们可以利用package来定义Message中的类型:
message Foo{
...
required foo.bar.Open open = 1;
...
}
package 和名字解析
这里的类型名字解析类似于C++,先从最内部开始解析。然后向外层解析。每一个package相对于其父package都是内层。添加前导‘.’表示从外层开始解析(如.foo.bar.Baz)。
定义Services
to be continued…