Protocol Buffer 语法(syntax)

  • 定义message
    • 可自定义域的类型
    • 分配Tags
    • 域field的介绍
    • 一些简单的操作
    • 数据类型
  • optional域和默认值
  • 枚举
  • message嵌套
  • 导入其他proto文件
    • proto3 Message类型
    • 嵌套nested和组合group
  • 更新Message
  • Extensions
    • 嵌套extensions
    • 选择extensions的tags
  • Oneof
    • 如何使用Oneof
    • oneof的特性
    • 后向兼容
  • Maps
    • Maps的特性
    • 后向兼容
  • packages
    • package 和名字解析
  • 定义Services

本文主要描述如何: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的范围是在 12291 范围内,但是[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是 2191 即,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 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…

你可能感兴趣的:(Protobuf)