先来看一个非常简单的例子。假设你想定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、你感兴趣的查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的.proto文件了:

SearchRequest消息格式有3个字段,在消息中承载的数据分别对应于每一个字段。其中每个字段都有一个名字和一种类型。
1.1.1指定字段类型在上面的例子中,所有字段都是标量类型:两个整型(page_number和result_per_page),一个string类型(query)。当然,你也可以为字段指定其他的合成类型,包括枚举(enumerations)或其他消息类型。
1.1.2分配标识号正如上述文件格式,在消息定义中,每个字段都有唯一的一个数字标识符,这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留[1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。
最小的标识号可以从1开始,最大到2^29-1,or536,870,911。不可以使用其中的[19000-19999]的标识号,Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。
1.1.3指定字段规则所指定的消息字段修饰符必须是如下之一:
required:一个格式良好的消息一定要含有1个这种字段。表示该值是必须要设置的;
optional:消息格式中该字段可以有0个或1个值(不超过1个)。
repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括0次)。重复的值的顺序会被保留。表示该值可以重复,相当于java中的List。
由于一些历史原因,基本数值类型的repeated的字段并没有被尽可能地高效编码。在新的代码中,用户应该使用特殊选项[packed=true]来保证更高效的编码。如:

required是永久性的:在将一个字段标识为required的时候,应该特别小心。如果在某些情况下不想写入或者发送一个required的字段,将原始该字段修饰符更改为optional可能会遇到问题——旧版本的使用者会认为不含该字段的消息是不完整的,从而可能会无目的的拒绝解析。在这种情况下,你应该考虑编写特别针对于应用程序的、自定义的消息校验函数。Google的一些工程师得出了一个结论:使用required弊多于利;他们更愿意使用optional和repeated而不是required。当然,这个观点并不具有普遍性。
1.1.4添加更多消息类型在一个.proto文件中可以定义多个消息类型。在定义多个相关的消息的时候,这一点特别有用——例如,如果想定义与SearchResponse消息类型对应的回复消息格式的话,你可以将它添加到相同的.proto文件中,如:

向.proto文件添加注释,可以使用C/C++/java风格的双斜杠(//)语法格式,如:

当用protobuf编译器来运行.proto文件时,编译器将生成所选择语言的代码,这些代码可以操作在.proto文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中,以及从一个输入流中解析消息。
对C++来说,编译器会为每个.proto文件生成一个.h文件和一个.cc文件,.proto文件中的每一个消息有一个对应的类。
对Java来说,编译器为每一个消息类型生成了一个.java文件,以及一个特殊的Builder类(该类是用来创建消息类接口的)。
对Python来说,有点不太一样——Python编译器为.proto文件中的每个消息类型生成一个含有静态描述符的模块,该模块与一个元类(metaclass)在运行时(runtime)被用来创建所需的Python数据访问类。
1.1.7标量数值类型一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:
.proto类型
Java类型
C++类型
备注
double
double
double
float
float
float
int32
int
int32
使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用sint32。
int64
long
int64
使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用sint64。
uint32
int[1]
uint32
Usesvariable-lengthencoding.
uint64
long[1]
uint64
Usesvariable-lengthencoding.
sint32
int
int32
使用可变长编码方式。有符号的整型值。编码时比通常的int32高效。
sint64
long
int64
使用可变长编码方式。有符号的整型值。编码时比通常的int64高效。
fixed32
int[1]
uint32
总是4个字节。如果数值总是比228大的话,这个类型会比uint32高效。
fixed64
long[1]
uint64
总是8个字节。如果数值总是比256大的话,这个类型会比uint64高效。
sfixed32
int
int32
总是4个字节。
sfixed64
long
int64
总是8个字节。
bool
boolean
bool
string
String
string
一个字符串必须是UTF-8编码或者7-bitASCII编码的文本。
bytes
ByteString
string
可能包含任意顺序的字节数据。
1.1.8Optional的字段和默认值如上所述,消息描述中的一个元素可以被标记为“可选的”(optional)。一个格式良好的消息可以包含0个或一个optional的元素。当解析消息时,如果它不包含optional的元素值,那么解析出来的对象中的对应字段就被置为默认值。默认值可以在消息描述文件中指定。例如,要为SearchRequest消息的result_per_page字段指定默认值10,在定义消息格式时如下所示:
如果没有为optional的元素指定默认值,就会使用与特定类型相关的默认值:对string来说,默认值是空字符串。对bool来说,默认值是false。对数值类型来说,默认值是0。对枚举来说,默认值是枚举类型定义中的第一个值。
1.1.9枚举当需要定义一个消息类型的时候,可能想为一个字段指定某“预定义值序列”中的一个值。例如,假设要为每一个SearchRequest消息添加一个corpus字段,而corpus的值可能是UNIVERSAL,WEB,IMAGES,LOCAL,NEWS,PRODUCTS或VIDEO中的一个。其实可以很容易地实现这一点:通过向消息定义中添加一个枚举(enum)就可以了。一个enum类型的字段只能用指定的常量集中的一个值作为其值(如果尝试指定不同的值,解析器就会把它当作一个未知的字段来对待)。在下面的例子中,在消息格式中添加了一个叫做Corpus的枚举类型——它含有所有可能的值——以及一个类型为Corpus的字段:
你可以为枚举常量定义别名。需要设置allow_aliasoption为true,否则protocol编译器会产生错误信息。
1.2使用其他消息类型你可以将其他消息类型用作字段类型。例如,假设在每一个SearchResponse消息中包含Result消息,此时可以在相同的.proto文件中定义一个Result消息类型,然后在SearchResponse消息中指定一个Result类型的字段,如:
1.2.1导入定义在上面的例子中,Result消息类型与SearchResponse是定义在同一文件中的。如果想要使用的消息类型已经在其他.proto文件中已经定义过了呢?
你可以通过导入(importing)其他.proto文件中的定义来使用它们。要导入其他.proto文件的定义,你需要在你的文件中添加一个导入声明,如:
默认情况下你只能使用直接导入的.proto文件中的定义.然而,有时候你需要移动一个.proto文件到一个新的位置,可以不直接移动.proto文件,只需放入一个文件在老的位置,然后使用import转向新的位置:
protocol编译器就会在一系列目录中查找需要被导入的文件,这些目录通过protocol编译器的命令行参数-I/–import_path指定。如果不提供参数,编译器就在其调用目录下查找。
1.2.2嵌套类型你可以在其他消息类型中定义、使用消息类型,在下面的例子中,Result消息就定义在SearchResponse消息内,如:
如果你想在它的父消息类型的外部重用这个消息类型,你需要以的形式使用它,如:
当然,你也可以将消息嵌套任意多层,如:
1.2.3更新一个消息类型如果一个已有的消息格式已无法满足新的需求——如,要在消息中添加一个额外的字段——但是同时旧版本写的代码仍然可用。不用担心!更新消息而不破坏已有代码是非常简单的。在更新时只要记住以下的规则即可。
不要更改任何已有的字段的数值标识。
*所添加的任何字段都必须是optional或repeated的。这就意味着任何使用“旧”的消息格式的代码序列化的消息可以被新的代码所解析,因为它们不会丢掉任何required的元素。应该为这些元素设置合理的默认值,这样新的代码就能够正确地与老代码生成的消息交互了。类似地,新的代码创建的消息也能被老的代码解析:老的二进制程序在解析的时候只是简单地将新字段忽略。然而,未知的字段是没有被抛弃的。此后,如果消息被序列化,未知的字段会随之一起被序列化——所以,如果消息传到了新代码那里,则新的字段仍然可用。注意:对Python来说,对未知字段的保留策略是无效的。
非required的字段可以移除——只要它们的标识号在新的消息类型中不再使用(更好的做法可能是重命名那个字段,例如在字段前添加“OBSOLETE_”前缀,那样的话,使用的.proto文件的用户将来就不会无意中重新使用了那些不该使用的标识号)。
一个非required的字段可以转换为一个扩展,反之亦然——只要它的类型和标识号保持不变。
int32,uint32,int64,uint64,和bool是全部兼容的,这意味着可以将这些类型中的一个转换为另外一个,而不会破坏向前、向后的兼容性。如果解析出来的数字与对应的类型不相符,那么结果就像在C++中对它进行了强制类型转换一样(例如,如果把一个64位数字当作int32来读取,那么它就会被截断为32位的数字)。
sint32和sint64是互相兼容的,但是它们与其他整数类型不兼容。
string和bytes是兼容的——只要bytes是有效的UTF-8编码。
嵌套消息与bytes是兼容的——只要bytes包含该消息的一个编码过的版本。
fixed32与sfixed32是兼容的,fixed64与sfixed64是兼容的。
1.2.4扩展通过扩展,可以将一个范围内的字段标识号声明为可被第三方扩展所用。然后,其他人就可以在他们自己的.proto文件中为该消息类型声明新的字段,而不必去编辑原始文件了。看个具体例子:
这个例子表明:在消息Foo中,范围[100,199]之内的字段标识号被保留为扩展用。现在,其他人就可以在他们自己的.proto文件中添加新字段到Foo里了,但是添加的字段标识号要在指定的范围内——例如:
这个例子表明:消息Foo现在有一个名为bar的optionalint32字段。
当用户的Foo消息被编码的时候,数据的传输格式与用户在Foo里定义新字段的效果是完全一样的。
然而,要在程序代码中访问扩展字段的方法与访问普通的字段稍有不同——生成的数据访问代码为扩展准备了特殊的访问函数来访问它。例如,下面是如何在C++中设置bar的值:
可以在另一个类型的范围内声明扩展,如:
在此例中,访问此扩展的C++代码如下:
Inotherwords,theonlyeffectisthatbarisdefinedwithinthescopeofBaz.
Thisisacommonsourceofconfusion:Declaringanextblocknestedinsideamessagetyped,;it'ssimplyastaticmember.
一个通常的设计模式就是:在扩展的字段类型的范围内定义该扩展——例如,下面是一个Foo的扩展(该扩展是Baz类型的),其中,扩展被定义为了Baz的一部分:
然而,并没有强制要求一个消息类型的扩展一定要定义在那个消息中。也可以这样做:
事实上,这种语法格式更能防止引起混淆。正如上面所提到的,嵌套的语法通常被错误地认为有子类化的关系——尤其是对那些还不熟悉扩展的用户来说。
1.2.6选择可扩展的标量符号在同一个消息类型中一定要确保两个用户不会扩展新增相同的标识号,否则可能会导致数据的不一致。可以通过为新项目定义一个可扩展标识号规则来防止该情况的发生。
如果标识号需要很大的数量时,可以将该可扩展标符号的范围扩大至max,其中max是229-1,或536,870,911。如下所示:
max是2^29-1,或者536,870,911.
通常情况下在选择标符号时,标识号产生的规则中应该避开[19000-19999]之间的数字,因为这些已经被ProtocolBuffers实现中预留了。
1.3Oneof如果你的消息中有很多可选字段,并且同时至多一个字段会被设置,你可以加强这个行为,使用oneof特性节省内存.
Oneof字段就像可选字段,除了它们会共享内存,至多一个字段会被设置。设置其中一个字段会清除其它oneof字段。你可以使用case()或者WhichOneof()方法检查哪个oneof字段被设置,看你使用什么语言了.
1.3.1使用Oneof为了在.proto定义Oneof字段,你需要在名字前面加上oneof关键字,比如下面例子的test_oneof:
然后你可以增加oneof字段到oneof定义中.你可以增加任意类型的字段,但是不能使用required,optional,repeated关键字.
在产生的代码中,oneof字段拥有同样的getters和setters,就像正常的可选字段一样.也有一个特殊的方法来检查到底那个字段被设置.你可以在相应的语言API中找到oneofAPI介绍.
Oneof特性:
设置oneof会自动清楚其它oneof字段的值.所以设置多次后,只有最后一次设置的字段有值.
Iftheparserencountersmultiplemembersofthesameoneofonthewire,onlythelastmemberseenisusedintheparsedmessage.
oneof不支持扩展.
oneof不能repeated.
反射API对oneof字段有效.
如果使用C++,需确保代码不会导致内存泄漏.下面的代码会崩溃,因为sub_message已经通过set_name()删除了.
AgaininC++,ifyouSwap()twomessageswithoneofs,eachmessagewillupwiththeother’soneofcase:intheexamplebelow,msg1willhaveasub_messageandmsg2willhaveaname.
1.3.1向后兼容性问题当增加或者删除oneof字段时一定要小心.如果检查oneof的值返回None/NOT_SET,它意味着oneof字段没有被赋值或者在一个不同的版本中赋值了。你不会知道是哪种情况。
Tag重用问题
Moveoptionalfieldsintooroutofaoneof:Youmaylosesomeofyourinformation(somefieldswillbecleared)afterthemessageisserializedandparsed.
Deleteaoneoffieldandadditback:Thismayclearyourcurrentlysetoneoffieldafterthemessageisserializedandparsed.
Splitormergeoneof:Thishassimilarissuestomovingregularoptionalfields.
1.4包(Package)当然可以为.proto文件新增一个可选的package声明符,用来防止不同的消息类型有命名冲突。如:
在其他的消息格式定义中可以使用包名+消息名的方式来定义域的类型,如:
包的声明符会根据使用语言的不同影响生成的代码。
对于C++,产生的类会被包装在C++的命名空间中,如上例中的Open会被封装在foo::bar空间中;
对于Java,包声明符会变为java的一个包,除非在.proto文件中提供了一个明确有java_package;
对于Python,这个包声明符是被忽略的,因为Python模块是按照其在文件系统中的位置进行组织的。
1.4.1包及名称的解析Protocolbuffer语言中类型名称的解析与C++是一致的:首先从最内部开始查找,依次向外进行,每个包会被看作是其父类包的内部类。当然对于()这样以“.”分隔的意味着是从最外围开始的。ProtocolBuffer编译器会解析.proto文件中定义的所有类型名。对于不同语言的代码生成器会知道如何来指向每个具体的类型,即使它们使用了不同的规则。
1.5定义服务(Service)如果想要将消息类型用在RPC(远程方法调用)系统中,可以在.proto文件中定义一个RPC服务接口,protocolbuffer编译器将会根据所选择的不同语言生成服务接口代码及存根。如,想要定义一个RPC服务并具有一个方法,该方法能够接收SearchRequest并返回一个SearchResponse,此时可以在.proto文件中进行如下定义:
protocol编译器将产生一个抽象接口SearchService以及一个相应的存根实现。存根将所有的调用指向RpcChannel,它是一个抽象接口,必须在RPC系统中对该接口进行实现。如,可以实现RpcChannel以完成序列化消息并通过HTTP方式来发送到一个服务器。换句话说,产生的存根提供了一个类型安全的接口用来完成基于protocolbuffer的RPC调用,而不是将你限定在一个特定的RPC的实现中。C++中的代码如下所示:
所有service类都必须实现Service接口,它提供了一种用来调用具体方法的方式,即在编译期不需要知道方法名及它的输入、输出类型。在服务器端,通过服务注册它可以被用来实现一个RPCServer。
1.6选项(Options)在定义.proto文件时能够标注一系列的options。Options并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。完整的可用选项可以在google/protobuf/找到。
一些选项是文件级别的,意味着它可以作用于最外范围,不包含在任何消息内部、enum或服务定义中。一些选项是消息级别的,意味着它可以用在消息定义的内部。当然有些选项可以作用在域、enum类型、enum值、服务类型及服务方法中。到目前为止,并没有一种有效的选项能作用于所有的类型。
如下就是一些常用的选择:
java_package(fileoption):这个选项表明生成java类所在的包。如果在.proto文件中没有明确的声明java_package,就采用默认的包名。当然了,默认方式产生的java包名并不是最好的方式,按照应用名称倒序方式进行排序的。如果不需要产生java代码,则该选项将不起任何作用。如:
java_outer_classname(fileoption):该选项表明想要生成Java类的名称。如果在.proto文件中没有明确的java_outer_classname定义,生成的class名称将会根据.proto文件的名称采用驼峰式的命名方式进行生成。如(foo_生成的java类名为),如果不生成java代码,则该选项不起任何作用。如:
optimize_for(fileoption):可以被设置为SPEED,CODE_SIZE,orLITE_RUNTIME。这些值将通过如下的方式影响C++及java代码的生成:
SPEED(default):protocolbuffer编译器将通过在消息类型上执行序列化、语法分析及其他通用的操作。这种代码是最优的。
CODE_SIZE:protocolbuffer编译器将会产生最少量的类,通过共享或基于反射的代码来实现序列化、语法分析及各种其它操作。采用该方式产生的代码将比SPEED要少得多,但是操作要相对慢些。当然实现的类及其对外的API与SPEED模式都是一样的。这种方式经常用在一些包含大量的.proto文件而且并不盲目追求速度的应用中。
LITE_RUNTIME:protocolbuffer编译器依赖于运行时核心类库来生成代码(即采用libprotobuf-lite替代libprotobuf)。这种核心类库由于忽略了一些描述符及反射,要比全类库小得多。这种模式经常在移动手机平台应用多一些。编译器采用该模式产生的方法实现与SPEED模式不相上下,产生的类通过实现MessageLite接口,但它仅仅是Messager接口的一个子集。
cc_generic_services,java_generic_services,py_generic_services(fileoptions):在C++、java、python中protocolbuffer编译器是否应该基于服务定义产生抽象服务代码。由于历史遗留问题,该值默认是true。但是自2.3.0版本以来,它被认为通过提供代码生成器插件来对RPC实现更可取,而不是依赖于“抽象”服务。
message_set_wire_format(messageoption):如果该值被设置为true,该消息将使用一种不同的二进制格式来与Google内部的MessageSet的老格式相兼容。对于Google外部的用户来说,该选项将不会被用到。如下所示:
packed(fieldoption):如果该选项在一个整型基本类型上被设置为真,则采用更紧凑的编码方式。当然使用该值并不会对数值造成任何损失。在2.3.0版本之前,解析器将会忽略那些非期望的包装值。因此,它不可能在不破坏现有框架的兼容性上而改变压缩格式。在2.3.0之后,这种改变将是安全的,解析器能够接受上述两种格式,但是在处理protobuf老版本程序时,还是要多留意一下。
deprecated(fieldoption):如果该选项被设置为true,表明该字段已经被弃用了,在新代码中不建议使用。在多数语言中,这并没有实际的含义。在java中,它将会变成一个@Deprecated注释。也许在将来,其它基于语言声明的代码在生成时也会如此使用,当使用该字段时,编译器将自动报警。
1.6.1自定义选项ProtocolBuffers允许自定义并使用选项。该功能应该属于一个高级特性,对于大部分人是用不到的。由于options是定在google/protobuf/中的,因此你可以在该文件中进行扩展,定义自己的选项。如:
在上述代码中,通过对MessageOptions进行扩展定义了一个新的消息级别的选项。当使用该选项时,选项的名称需要使用()包裹起来,以表明它是一个扩展。在C++代码中可以看出my_option是以如下方式被读取的。
正如上面的读取方式,定制选项对于Python并不支持。定制选项在protocolbuffer语言中可用于任何结构。下面就是一些具体的例子:
最后一件事情需要注意:因为自定义选项是可扩展的,它必须象其它的域或扩展一样来定义标识号。正如上述示例,[50000-99999]已经被占用,该范围内的值已经被内部所使用,当然了你可以在内部应用中随意使用。如果你想在一些公共应用中进行自定义选项,你必须确保它是全局唯一的。可以通过protobuf-global-extension-registry@来获取全局唯一标识号。只需提供你的项目名和项目网站.通常你只需要一个扩展号。你可以使用一个扩展号声明多个选项:
可以通过定义好的.proto文件来生成Java、Python、C++代码,需要基于.proto文件运行protocolbuffer编译器protoc。运行的命令如下所示:
protoc--proto_path=IMPORT_PATH--cpp_out=DST_DIR--java_out=DST_DIR--python_out=DST_DIRpath/to/
IMPORT_PATH声明了一个.proto文件所在的具体目录。如果忽略该值,则使用当前目录。如果有多个目录则可以对--proto_path写多次,它们将会顺序的被访问并执行导入。-I=IMPORT_PATH是它的简化形式。
当然也可以提供一个或多个输出路径:
作为一种额外的约定,如果DST_DIR是以.zip或.jar结尾的,编译器将输出结果打包成一个zip格式的归档文件。.jar将会输出一个JavaJAR声明必须的manifest文件。注:如果该输出归档文件已经存在,它将会被重写,编译器并没有做到足够的智能来为已经存在的归档文件添加新的文件。
你必须提供一个或多个.proto文件作为输入。多个.proto文件能够一次全部声明。虽然这些文件是相对于当前目录来命名的,每个文件必须在一个IMPORT_PATH中,只有如此编译器才可以决定它的标准名称。
二、protobuf的优缺点2.1特点1)跨语言,跨平台
Protobuf和语言,平台无关,定义好pb文件之后,对于不同的语言使用不同的语言的编译器对pb文件进行编译即可,编译完成之后就会提供对应语言能够使用的接口,通过这些接口就可以访问在pb文件中定义好的内容了。
2)性能优越
Protobuf十分高效,无论是在数据存储还是通信性能都非常好,序列化的体积很小,序列化的速度也很快,关于这一点会在后面第3节序列化原理章节中做详细的介绍。
3)兼容性好
Protobuf的兼容性特别好,当我们更新数据的时候不会影响原有的程序,例如int32和int64是两种不同的类型,存储的数据占用的字节数也不同,但是如果现在需要存储一个负数,采用Varints编码时,它们都会占用固定的十个字节,这是为了防止用户在将int64改为int32时会影响原有的程序。关于这方面的内容,在第3节也会做详细的介绍。
2.2JSONXML的对比Protobuf和JSON,XML既有相似点又有不同点,从数据结构化和数据序列化两个维度去进行比较可能会更直观一些。
数据结构化主要面向开发和业务层面,数据序列化主要面向通信和存储层面。当然数据序列化也需要结构和格式,所以这两者的区别主要在于应用领域和场景不同,因此要求和侧重点也会有所不同。
数据结构化更加侧重于人类的可读性,强调语义表达能力,而数据序列化侧重效率和压缩。
接下来从这两个维度出发,我们进行一些简单的分析。
XML作为一种可扩展标记语言,JSON作为源于JS的数据格式,都具有数据结构化的能力。
例如XML可以衍生出HTML(虽然HTNL早于XML,但从概念上讲,HTML只是预定义标签的XML),HTML的作用是标记和表达万维网中资源的结构,以便浏览器更好地展示万维网资源,同时也要尽可能保证其人类可读以便开发人员进行开发,这是面向业务或开发层面的数据结构化。
再如XML还可衍生出RDF/RDFS,进一步表达语义网中资源的关系和语义,同样它强调数据结构化的能力和人类可读。
JSON也是同理,在很多场景下更多的是体现了数据结构化的能力,例如作为交互接口的数据结构的表达。
当然,JSON和XML同样也可以直接被用来数据序列化,实际上很多时候它们也是被这么使用的,例如直接采用JSON,XML进行网络通信传输,此时XML和JSON就成了一种序列化格式,发挥了数据序列化的能力。
但是我们平时开发的时候经常会这么用并不代表就是合理的,或者说是最好的。实际上,将JSON和XML直接数据序列化进行网络传输通常并不是最优的选择。因为它们在速度、效率,占用空间上都并不是最优的。换句话说它们更适合数据结构化而不是数据序列化。但是如果从这两方面综合考虑或许我们平时的选择又是合理的。
Protobuf在数据结构化方面可能没有那么突出,但是在数据序列化方面,你会发现Protobuf具有明显的优势,效率,速度,空间几乎全面占优。
稍微做一个小的总结:
1)XML、JSON、Protobuf都具有数据结构化和序列化的能力;
3)Protobuf的应用场景更为明确,一般是在传输数据量较大,RPC服务数据数据传输,XML、JSON的应用场景更为丰富,传输数据量较小,在MongoDB中采用JSON作为查询语句,也是在发挥其数据结构化的能力。
三、protobuf应用场景传输数据量大网络环境不稳定的数据存储、RPC数据交换的需求场景
四、protobuf序列化原理protobuf数据存储采用Tag-Length-Value即标识-长度-字段值存储方式,以标识-长度-字段值表示单个字段,最终将数据拼接成一个字节流,从而实现数据存储的功能。
可以看到当采用T-L-V的存储结构时不需要分隔符就能分隔开字段,各字段存储地非常紧凑,存储空间利用率非常高。
此外如果某字段没有被设置字段值,那么该字段在序列化时是完全不存在的,即不需要编码,这个字段在解码时才会被设置默认值。
接下来重点介绍一下每个字段中都存在的Tag。
Tag由field_number和wire_type两部分组成,其中field_number是字段的标识号,wire_type是一个数值,根据它的数值可以确定该字段的字段值需要采用的编码类型。
//Tag的具体表达式如下Tag=(field_number3)|wire_type;//参数说明://field_number:对应于.proto文件中消息字段的标识号,表示这是消息里的第几个字段//原来的field_number需要左移三位再拼接上wire_type就会得出Tag,所以真正的field_number是将Tag右移三位后的值//field_number3:表示field_number=将Tag的二进制表示右移三位后的值//field_num左移3位不会导致数据丢失,因为表示范围还是足够大地去表示消息里的字段数目//wire_type:表示字段的数据类型//wire_type=Tag的二进制表示的最低三位值//wire_type的取值enumWireType{WIRETYPE_Varint=0,WIRETYPE_FIXED64=1,WIRETYPE_LENGTH_DELIMITED=2,WIRETYPE_START_GROUP=3,WIRETYPE_END_GROUP=4,WIRETYPE_FIXED32=5};//从上面可以看出,`wire_type`最多占用3位的内存空间(因为3位足以表示0-5的二进制)wire_type占3bit,最多可以表达8种编码类型,目前Protobuf已经定义了6种(Startgroup和group已经被废弃掉了),如下图所示。
每个字段根据不同的编码类型会有下面两种编码格式:
Tag-Length-Value:编码类型表中Type=2,即Length-delimited编码类型将使用这种结构
Tag-Value:编码类型表中Varint,64-bit,32-bit将使用这种结构
接下来就来详细地介绍一下各种编码类型。
4.1Varint编码Varint编码是一种变长的编码方式,用字节表示数字,值越小的数字,使用越少的字节数表示。它通过减少表示数字的字节数从而进行数据压缩。
类似的案例除了本文外,还可参考:
4.1.1Varint编码规则部分源码:
privatevoidwriteVarint32(intn){intidx=0;while(true){if((n~0x7F)==0){i32buf[idx++]=(byte)n;break;}else{i32buf[idx++]=(byte)((n0x7F)|0x80);//步骤1:取出字节串末7位//对于上述取出的7位:在最高位添加1构成一个字节//如果是最后一次取出,则在最高位添加0构成1个字节n=7;//步骤2:通过将字节串整体往右移7位,继续从字节串的末尾选取7位,直到取完为止。}}trans_.write(i32buf,0,idx);//步骤3:将上述形成的每个字节按序拼接成一个字节串//即该字节串就是经过Varint编码后的字节}从步骤1中可以看出,Varint编码中每个字节的最高位都有特殊的含义:
如果是1,表示后续的字节也是该数字的一部分,需要继续读取
如果是0,表示这是最后一个字节,且剩余7位都用来表示数字
所以,当使用Varint编码时,只要读取到最高位为0的字节时,就表示已经是Varint的最后一个字节了。
可以简单地将Varint的编码规则归结为以下三点:
1)在每个字节开头的bit设置了msb(mostsignificantbit),标识是否需要继续读取下一个字节
2)存储数字对应的二进制补码
3)补码的低位排在前面
补码的计算方法:
对于正数,原码和补码相同
对于负数,最高位符号位不变,其它位按位取反然后加1
4.1.2Varint编码示例接下来通过一个示例来说明一下Varint编码的过程
示例1
int32a=8;
原码:000000001000
补码:000000001000
根据Varint编码规则,从低位开始取7bit,0001000
当取出前7bit后,前面所有的位就都是0了,不需要继续读取了,因此设置msb位为0即可
所以最终Varint编码为00001000
可以看到在使用Varint编码后只使用一个字节就可以了,而正常的int32编码一般需要4个字节。
仔细体会上述的Varint编码,我们可以发现Varint编码本质实际上是每个字节都牺牲了一个bit位,来表示是否已经结束(是否需要继续读取下一个字节),msb实际上就起到了length的作用,正因为有了这个msb位,所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。
通过Varint编码对于比较小的数字可以用很少的字节进行表示,从而减小了序列化后的体积。
但是由于Varint编码每个字节都要拿出一位作为msb位,因此每个字节就少了一位来表示字段值。那这就意味着四个字节能表达的最大数字是为2^28而不是2^32了。
所以如果当数字大于2^28时,采用Varint编码将导致分配5个字节,原先明明只需要4个字节。此时Varint编码的效率不仅没有提高反而是下降了。
但是这并不影响Varint编码在实际应用时的高效,因为事实证明,在大多数情况下,数字在2^28~2^32出现的概率要远远小于0~2^28出现的概率。
示例2
这样看来Varint编码似乎很完美,但是有一种情况下,Varint编码的效率很低。上面的例子中只给出了正数的情况,思考如果是负数的情况呢。
我们知道负数的二进制表示中最高位是符号位1,这一点意味着负数都必须占用所有字节。
我们还是通过一个示例来体会一下。
int32a=-1
原码:100000000001
补码:111111111111
根据Varints编码规则,从低位开始取7bit,1111111,由于前面还有1需要读取,因此需要设置msb位为1,然后将这个字节放在Varint编码的高位。
依次类推,有9组(字节)都是1,这9组的msb均为1,最后一组只有1位是1,由于已经是最后一组了不需要再继续读取了,因此这组的msb位应该是0.
因此最终的Varint编码是1111111100000001(FFFFFFFFFFFFFFFFFF01)
可能大家会有疑问为什么会占用10个字节呢?
这是Protobuf基于兼容性考虑,例如当开发者将int64改为int32后应该不影响旧程序,所以将int32扩展为int64的八个字节。通常的说法即是64=9*7+1所以最多需要10字节来标识一个64字节无符号变长整数。
可能大家还会有疑问为什么对于正数的时候不需要进行类似的兼容处理呢?
实际上当要编码的是正数时,int32和int64是天然兼容的,他们两个的编码过程是完全一样的,利用msb位去控制最终的Varint编码长度即可。
所以目前的情况是我们定义了一个int32类型的变量,如果将变量的值设置为负数,如果直接采用Varint编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。那么我们应该如何去解决呢?
答案就是下面的Zigzag编码。
4.2Zigzag编码在Protobuf中Zigzag编码的出现主要是为了解决Varint编码负数效率低的问题。
基本原理就是将有符号正数映射成无符号整数,然后再使用Varint编码,这里所说的映射是通过移位的方式实现的并不是通过存储映射表。
4.2.1Zigzag编码规则部分源码:
publicintint_to_Zigzag(intn){//传入的参数n=传入字段值的二进制表示(此处以负数为例)//负数的二进制=符号位为1,剩余的位数为该数绝对值的原码按位取反;然后整个二进制数+1return(n1)^(n31);}//解码publicintZigzag_to_int(intn){return(n1)^-(n1);}根据上面的源码我们可以得出Zigzag的编码过程如下:
将补码左移1位,低位补0,得到result1
将补码右移31位,得到result2
首位是1的补码(有符号数)是算数右移,即右移后左边补1
首位是0的补码(无符号数)是逻辑右移,即右移后左边补0
将result1和result2异或
4.2.2Zigzag编码示例下面通过一个示例来演示一个Zigzag的编码过程
sint32a=-2
原码:10000010
补码:11111110
左移一位(算数右移)result1:11111100
右移31位result2:11111111
异或:00000011(3)
编码过程示意图如下:
可以看到-2经过Zigzag编码之后变成了正数3,这时再通过Varint编码就很高效了,在接收端先通过Varint解码得到数字3,然后再通过Zigzag解码就可以得到原始发送的数据-2了。
因此在定义字段时如果知道该字段的值有可能是负数的话,那么建议使用sint32/sint64这两种数据类型。
4.364-bit(32-bit)编码64-bit和32-bit的编码方式比较简单,64-bit编码后是固定的8个字节,32bit编码后是固定的4个字节。当数据类型是fixed64,sfixed64,double时将采用64-bit编码方式,当数据类型是fixd32,sfixed64,float时将采用32-bit编码方式。
注意这两种编码方式都是补码的高位放到编码后的低位。
它们都采用的是T-V的存储方式。
4.4length-delimited这是Protobuf中唯一一个采用T-L-V的存储方式。如下图所示,Tag和Length仍然采用Varint编码,对于字段值根据不同的数据类型采用不同的编码方式。
例如,对于string类型字段值采用的是utf-8编码,而对于嵌套消息数据类型会根据里面字段的类型选择不同的编码方式。
接下来重点说一下嵌套消息数据类型是如何进行编码的。
通过下面的示例来说明,在Test3这个Message对象中的c字段的类型是一个消息对象Test2,并且将Test2中字段str的值设置为testing,将字段id1的值设置为296.
messageTest2{requiredstringstr=1;requiredint32id1=2;}messageTest3{requiredTest2c=1}//将Test2中的字段str设置为:testing//将Test2中的字段id1设置为:296//编码后的字节为:10,12,18,7,116,101,115,116,105,110,103,16,-88,2那么编码后的存储方式如下:
4.5序列化过程Protobuf的性能非常优越主要体现在两点,其中一点就是序列化后的体积非常小,这一点在前面编解码的介绍中已经体现出来了。还有另外一点就是序列化速度非常快,接下来就简单地介绍一下为什么序列化的速度非常快。
Protobuf序列化的过程简单来说主要有下面两步
判断每个字段是否有设置值,有值才进行编码,
根据tag中的wire_type确定该字段采用什么类型的编码方案进行编码即可。
Protobuf反序列化过程简单来说也主要有下面两步:
调用消息类的parseFrom(input)解析从输入流读入的二进制字节数据流
将解析出来的数据按照指定的格式读取到相应语言的结构类型中
Protobuf的序列化过程中由于编码方式简单,只需要简单的数学运算位移即可,而且采用的是Protobuf框架代码和编译器共同完成,因此序列化的速度非常快。
可能这样并不能很直观地展现出Protobuf序列化过程非常快,接下来我们简单介绍一下XML的反序列化过程,通过对比我们就能清晰地认识到Protobuf序列化的速度是非常快的。
XML反序列化的过程大致如下:
从文件中读取出字符串
从字符串转换为XML文档对象模型
从XML文档对象结构模型中读取指定节点的字符串
将该字符串转换成指定类型的变量
从上述过程中,我们可以看到XML反序列化的过程比较繁琐,而且在第二步,将XML文件转换为文档对象模型的过程是需要词法分析的,这个过程是比较耗费时间的,因此通过对比我们就可以感受到Protobuf的序列化的速度是非常快的。
五、使用建议接下来结合上面所提到的一些知识,简单给出一些在使用Protobuf时的一些小建议。
1)如果有负数,那么尽量使用sint32/sint64,不要使用int32/int64,因为采用sin32/sin64数据类型表示负数时,根据前面的介绍可以知道会先采用Zigzag将负数通过移位的方式映射为正数,然后再使用Varint编码,这样就可以有效减少存储的字节数。
2)字段标识号的时候尽量只使用1~15,并且不要跳动使用。因为如果超过15,那么Tag在编码时就会占用更多的字节。如果将字段标识号定义为连续递增的数值,将会获得更好的编码性能和解码性能。
3)尽量多地使用optional或repeated修饰符(在proto3版本中默认是optional),因为使用这两个修饰符后如果不设置值,在序列化时是不进行编码的,默认值会在反序列化时自动添加。
六、总结七、参考文件[1][ProtoBuf]ProtoBuf原理
[2][译]Protobuf语法指南
[3]protobuf官网
[4]深入理解ProtoBuf原理与工程实践(概述)
[5]深入ProtoBuf-序列化源码解析
;utm_medium=_%7Edefault%7ECTRLIST%7_search_linkdepth_1-utm_source=_%7Edefault%7ECTRLIST%7_search_link
[6]深入ProtoBuf-反射原理解析
[7]google官方原理介绍
[8]Protobuf使用介绍及原理
[9]protobuf协议浅析
[10]Carson带你学序列化:这是一份很有诚意的ProtocolBuffer语法详解
[11]Protobuf数据格式原理剖析(解释了2的29次方如何得来)
[12]Protobuf底层存储原理
[13]zigzag算法详解
;utm_medium=_relevant_%7Edefault%7ECTRLIST%7_search_linkdepth_1-utm_source=_relevant_%7Edefault%7ECTRLIST%7_search_link
[14]Protobuf的研究理解
;utm_medium=_aggpage_search_~all~sobaiduweb~default-6-103167655spm=3001.4430
[15]Protobuf使用手册--中文版
;request_id=biz_id=102utm_term=protobuf%E4%BD%BF%E7%94%A8%E8%AF%A6%E8%A7%A3utm_medium=_search_~all~sobaiduweb~_rank_v2_pc_rank_v29spm=1018.2226.3001.4187
[16]【转载】Protobuf原理分析小结
;request_id=163488329716780264047837biz_id=0utm_medium=_search_~all~sobaidu~_rank_v2_pc_rank_v29utm_term=protobuf%E5%8E%9F%E7%90%86spm=1018.2226.3001.4187