百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 热门文章 > 正文

比json快20-100倍!protobuf原理深入剖析

bigegpt 2024-11-24 12:00 5 浏览

一、protobuf语法指南

1.1 定义一个消息类型

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

1.1.5 添加注释

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

1.1.6 从.proto文件生成了什么?

当用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

Uses variable-length encoding.

uint64

long[1]

uint64

Uses variable-length encoding.

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-bit ASCII编码的文本。

bytes

ByteString

string

可能包含任意顺序的字节数据。

1.1.8 Optional的字段和默认值

如上所述,消息描述中的一个元素可以被标记为“可选的”(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_alias option 为 true, 否则 protocol编译器会产生错误信息。

1.2 使用其他消息类型

你可以将其他消息类型用作字段类型。例如,假设在每一个SearchResponse消息中包含Result消息,此时可以在相同的.proto文件中定义一个Result消息类型,然后在SearchResponse消息中指定一个Result类型的字段,如:

1.2.1 导入定义

在上面的例子中,Result消息类型与SearchResponse是定义在同一文件中的。如果想要使用的消息类型已经在其他.proto文件中已经定义过了呢?
你可以通过导入(importing)其他.proto文件中的定义来使用它们。要导入其他.proto文件的定义,你需要在你的文件中添加一个导入声明,如:

默认情况下你只能使用直接导入的.proto文件中的定义. 然而, 有时候你需要移动一个.proto文件到一个新的位置, 可以不直接移动.proto文件, 只需放入一个dummy .proto 文件在老的位置, 然后使用import转向新的位置:

protocol编译器就会在一系列目录中查找需要被导入的文件,这些目录通过protocol编译器的命令行参数-I/–import_path指定。如果不提供参数,编译器就在其调用目录下查找。

1.2.2 嵌套类型

你可以在其他消息类型中定义、使用消息类型,在下面的例子中,Result消息就定义在SearchResponse消息内,如:

如果你想在它的父消息类型的外部重用这个消息类型,你需要以Parent.Type的形式使用它,如:

当然,你也可以将消息嵌套任意多层,如:

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的optional int32字段。

当用户的Foo消息被编码的时候,数据的传输格式与用户在Foo里定义新字段的效果是完全一样的。
然而,要在程序代码中访问扩展字段的方法与访问普通的字段稍有不同——生成的数据访问代码为扩展准备了特殊的访问函数来访问它。例如,下面是如何在C++中设置bar的值:

类似地,Foo类也定义了模板函数 HasExtension(),ClearExtension(),GetExtension(),MutableExtension(),以及 AddExtension()。这些函数的语义都与对应的普通字段的访问函数相符。要查看更多使用扩展的信息,请参考相应语言的代码生成指南。注:扩展可以是任何字段类型,包括消息类型。

1.2.5 嵌套的扩展

可以在另一个类型的范围内声明扩展,如:

在此例中,访问此扩展的C++代码如下:

In other words, the only effect is that bar is defined within the scope of Baz.

This is a common source of confusion: Declaring an extend block nested inside a message type does not imply any relationship between the outer type and the extended type. In particular, the above example does not mean that Baz is any sort of subclass of Foo. All it means is that the symbol bar is declared inside the scope of Baz; it's simply a static member.

一个通常的设计模式就是:在扩展的字段类型的范围内定义该扩展——例如,下面是一个Foo的扩展(该扩展是Baz类型的),其中,扩展被定义为了Baz的一部分:

然而,并没有强制要求一个消息类型的扩展一定要定义在那个消息中。也可以这样做:

事实上,这种语法格式更能防止引起混淆。正如上面所提到的,嵌套的语法通常被错误地认为有子类化的关系——尤其是对那些还不熟悉扩展的用户来说。

1.2.6 选择可扩展的标量符号

在同一个消息类型中一定要确保两个用户不会扩展新增相同的标识号,否则可能会导致数据的不一致。可以通过为新项目定义一个可扩展标识号规则来防止该情况的发生。

如果标识号需要很大的数量时,可以将该可扩展标符号的范围扩大至max,其中max是229 - 1, 或536,870,911。如下所示:

max 是 2^29 - 1, 或者 536,870,911.

通常情况下在选择标符号时,标识号产生的规则中应该避开[19000-19999]之间的数字,因为这些已经被Protocol Buffers实现中预留了。

1.3 Oneof

如果你的消息中有很多可选字段, 并且同时至多一个字段会被设置, 你可以加强这个行为,使用oneof特性节省内存.

Oneof字段就像可选字段, 除了它们会共享内存, 至多一个字段会被设置。 设置其中一个字段会清除其它oneof字段。 你可以使用case()或者WhichOneof() 方法检查哪个oneof字段被设置, 看你使用什么语言了.

1.3.1 使用Oneof

为了在.proto定义Oneof字段, 你需要在名字前面加上oneof关键字, 比如下面例子的test_oneof:

然后你可以增加oneof字段到 oneof 定义中. 你可以增加任意类型的字段, 但是不能使用 required, optional, repeated 关键字.

在产生的代码中, oneof字段拥有同样的 getters 和setters, 就像正常的可选字段一样. 也有一个特殊的方法来检查到底那个字段被设置. 你可以在相应的语言API中找到oneof API介绍.

Oneof 特性:

  • 设置oneof会自动清楚其它oneof字段的值. 所以设置多次后,只有最后一次设置的字段有值.
  • If the parser encounters multiple members of the same oneof on the wire, only the last member seen is used in the parsed message.
  • oneof不支持扩展.
  • oneof不能 repeated.
  • 反射API对oneof 字段有效.
  • 如果使用C++,需确保代码不会导致内存泄漏. 下面的代码会崩溃, 因为sub_message 已经通过set_name()删除了.
  • Again in C++, if you Swap() two messages with oneofs, each message will end up with the other’s oneof case: in the example below, msg1 will have a sub_message and msg2 will have a name.

1.3.1 向后兼容性问题

当增加或者删除oneof字段时一定要小心. 如果检查oneof的值返回None/NOT_SET, 它意味着oneof字段没有被赋值或者在一个不同的版本中赋值了。 你不会知道是哪种情况。

Tag 重用问题

  • Move optional fields into or out of a oneof: You may lose some of your information (some fields will be cleared) after the message is serialized and parsed.
  • Delete a oneof field and add it back: This may clear your currently set oneof field after the message is serialized and parsed.
  • Split or merge oneof: This has similar issues to moving regular optional fields.

1.4 包(Package)

当然可以为.proto文件新增一个可选的package声明符,用来防止不同的消息类型有命名冲突。如:


在其他的消息格式定义中可以使用包名+消息名的方式来定义域的类型,如:


包的声明符会根据使用语言的不同影响生成的代码。

  • 对于C++,产生的类会被包装在C++的命名空间中,如上例中的Open会被封装在 foo::bar空间中;
  • 对于Java,包声明符会变为java的一个包,除非在.proto文件中提供了一个明确有java_package;
  • 对于 Python,这个包声明符是被忽略的,因为Python模块是按照其在文件系统中的位置进行组织的。

1.4.1 包及名称的解析

Protocol buffer语言中类型名称的解析与C++是一致的:首先从最内部开始查找,依次向外进行,每个包会被看作是其父类包的内部类。当然对于 (foo.bar.Baz)这样以“.”分隔的意味着是从最外围开始的。ProtocolBuffer编译器会解析.proto文件中定义的所有类型名。 对于不同语言的代码生成器会知道如何来指向每个具体的类型,即使它们使用了不同的规则。

1.5 定义服务(Service)

如果想要将消息类型用在RPC(远程方法调用)系统中,可以在.proto文件中定义一个RPC服务接口,protocol buffer编译器将会根据所选择的不同语言生成服务接口代码及存根。如,想要定义一个RPC服务并具有一个方法,该方法能够接收 SearchRequest并返回一个SearchResponse,此时可以在.proto文件中进行如下定义:

protocol编译器将产生一个抽象接口SearchService以及一个相应的存根实现。存根将所有的调用指向RpcChannel,它是一 个抽象接口,必须在RPC系统中对该接口进行实现。如,可以实现RpcChannel以完成序列化消息并通过HTTP方式来发送到一个服务器。换句话说, 产生的存根提供了一个类型安全的接口用来完成基于protocolbuffer的RPC调用,而不是将你限定在一个特定的RPC的实现中。C++中的代码 如下所示:

所有service类都必须实现Service接口,它提供了一种用来调用具体方法的方式,即在编译期不需要知道方法名及它的输入、输出类型。在服务器端,通过服务注册它可以被用来实现一个RPC Server。


1.6 选项(Options)

在定义.proto文件时能够标注一系列的options。Options并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。完整的可用选项可以在google/protobuf/descriptor.proto找到。

一些选项是文件级别的,意味着它可以作用于最外范围,不包含在任何消息内部、enum或服务定义中。一些选项是消息级别的,意味着它可以用在消息定 义的内部。当然有些选项可以作用在域、enum类型、enum值、服务类型及服务方法中。到目前为止,并没有一种有效的选项能作用于所有的类型。

如下就是一些常用的选择:

  • java_package (file option): 这个选项表明生成java类所在的包。如果在.proto文件中没有明确的声明java_package,就采用默认的包名。当然了,默认方式产生的 java包名并不是最好的方式,按照应用名称倒序方式进行排序的。如果不需要产生java代码,则该选项将不起任何作用。如:
  • java_outer_classname (file option): 该选项表明想要生成Java类的名称。如果在.proto文件中没有明确的java_outer_classname定义,生成的class名称将会根据.proto文件的名称采用驼峰式的命名方式进行生成。如(foo_bar.proto生成的java类名为FooBar.java),如果不生成java代码,则该选项不起任何作用。如:
  • optimize_for (fileoption): 可以被设置为 SPEED, CODE_SIZE,or LITE_RUNTIME。这些值将通过如下的方式影响C++及java代码的生成:
    • SPEED (default): protocol buffer编译器将通过在消息类型上执行序列化、语法分析及其他通用的操作。这种代码是最优的。
    • CODE_SIZE: protocol buffer编译器将会产生最少量的类,通过共享或基于反射的代码来实现序列化、语法分析及各种其它操作。采用该方式产生的代码将比SPEED要少得多, 但是操作要相对慢些。当然实现的类及其对外的API与SPEED模式都是一样的。这种方式经常用在一些包含大量的.proto文件而且并不盲目追求速度的 应用中。
    • LITE_RUNTIME: protocol buffer编译器依赖于运行时核心类库来生成代码(即采用libprotobuf-lite 替代libprotobuf)。这种核心类库由于忽略了一 些描述符及反射,要比全类库小得多。这种模式经常在移动手机平台应用多一些。编译器采用该模式产生的方法实现与SPEED模式不相上下,产生的类通过实现 MessageLite接口,但它仅仅是Messager接口的一个子集。
  • cc_generic_services, java_generic_services, py_generic_services (file options): 在C++、java、python中protocol buffer编译器是否应该基于服务定义产生抽象服务代码。由于历史遗留问题,该值默认是true。但是自2.3.0版本以来,它被认为通过提供代码生成 器插件来对RPC实现更可取,而不是依赖于“抽象”服务。
  • message_set_wire_format (message option): 如果该值被设置为true,该消息将使用一种不同的二进制格式来与Google内部的MessageSet的老格式相兼容。对于Google外部的用户来说,该选项将不会被用到。如下所示:
  • packed (field option): 如果该选项在一个整型基本类型上被设置为真,则采用更紧凑的编码方式。当然使用该值并不会对数值造成任何损失。在2.3.0版本之前,解析器将会忽略那些 非期望的包装值。因此,它不可能在不破坏现有框架的兼容性上而改变压缩格式。在2.3.0之后,这种改变将是安全的,解析器能够接受上述两种格式,但是在 处理protobuf老版本程序时,还是要多留意一下。
  • deprecated (field option): 如果该选项被设置为true,表明该字段已经被弃用了,在新代码中不建议使用。在多数语言中,这并没有实际的含义。在java中,它将会变成一个 @Deprecated注释。也许在将来,其它基于语言声明的代码在生成时也会如此使用,当使用该字段时,编译器将自动报警。

1.6.1 自定义选项

ProtocolBuffers允许自定义并使用选项。该功能应该属于一个高级特性,对于大部分人是用不到的。由于options是定在 google/protobuf/descriptor.proto中的,因此你可以在该文件中进行扩展,定义自己的选项。如:

在上述代码中,通过对MessageOptions进行扩展定义了一个新的消息级别的选项。当使用该选项时,选项的名称需要使用()包裹起来,以表明它是一个扩展。在C++代码中可以看出my_option是以如下方式被读取的。

正如上面的读取方式,定制选项对于Python并不支持。定制选项在protocol buffer语言中可用于任何结构。下面就是一些具体的例子:


最后一件事情需要注意:因为自定义选项是可扩展的,它必须象其它的域或扩展一样来定义标识号。正如上述示例,[50000-99999]已经被占 用,该范围内的值已经被内部所使用,当然了你可以在内部应用中随意使用。如果你想在一些公共应用中进行自定义选项,你必须确保它是全局唯一的。可以通过protobuf-global-extension-registry@google.com来获取全局唯一标识号。 只需提供你的项目名和项目网站. 通常你只需要一个扩展号。 你可以使用一个扩展号声明多个选项:

1.7 生成访问类

可以通过定义好的.proto文件来生成Java、Python、C++代码,需要基于.proto文件运行protocol buffer编译器protoc。运行的命令如下所示:

protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR path/to/file.proto
  • IMPORT_PATH声明了一个.proto文件所在的具体目录。如果忽略该值,则使用当前目录。如果有多个目录则可以 对--proto_path 写多次,它们将会顺序的被访问并执行导入。-I=IMPORT_PATH是它的简化形式。
  • 当然也可以提供一个或多个输出路径:
    • --cpp_out 在目标目录DST_DIR中产生C++代码,可以在 http://code.google.com/intl/zh-CN/apis/protocolbuffers/docs/reference /cpp-generated.html中查看更多。
    • --java_out 在目标目录DST_DIR中产生Java代码,可以在 http://code.google.com/intl/zh-CN/apis/protocolbuffers/docs/reference /java-generated.html中查看更多。
    • --python_out 在目标目录 DST_DIR 中产生Python代码,可以在http://code.google.com/intl/zh-CN/apis/protocolbuffers /docs/reference/python-generated.html中查看更多。
      作为一种额外的约定,如果DST_DIR 是以.zip或.jar结尾的,编译器将输出结果打包成一个zip格式的归档文件。.jar将会输出一个 Java JAR声明必须的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.2 JSON&XML的对比

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 都具有数据结构化和序列化的能力;

2)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_number << 3) | wire_type;
// 参数说明:
// field_number:对应于 .proto文件中消息字段的标识号,表示这是消息里的第几个字段
// 原来的field_number需要左移三位再拼接上wire_type就会得出Tag,所以真正的field_number是将Tag右移三位后的值
// field_number << 3:表示 field_number = 将 Tag的二进制表示右移三位后的值 
// field_num左移3位不会导致数据丢失,因为表示范围还是足够大地去表示消息里的字段数目

//  wire_type:表示 字段 的数据类型
//  wire_type = Tag的二进制表示 的最低三位值
//  wire_type 的取值
enum WireType { 
  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 占 3 bit,最多可以表达 8 种编码类型,目前 Protobuf 已经定义了 6 种(Start group 和 End group 已经被废弃掉了),如下图所示。

每个字段根据不同的编码类型会有下面两种编码格式

  • Tag - Length - Value: 编码类型表中 Type = 2,即 Length - delimited 编码类型将使用这种结构
  • Tag - Value: 编码类型表中 Varint,64-bit,32-bit 将使用这种结构

接下来就来详细地介绍一下各种编码类型。

4.1 Varint 编码

Varint 编码是一种变长的编码方式,用字节表示数字,值越小的数字,使用越少的字节数表示。它通过减少表示数字的字节数从而进行数据压缩。

类似的案例除了本文外,还可参考:https://www.163.com/dy/article/CCRTNI9G0514BE4T.html

4.1.1 Varint 编码规则

部分源码:

private void writeVarint32(int n) {                                                                                    
  int idx = 0;  
  while (true) {  
    if ((n & ~0x7F) == 0) {  
      i32buf[idx++] = (byte)n;  
      break;  
    } else {  
      i32buf[idx++] = (byte)((n & 0x7F) | 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(most significant bit),标识是否需要继续读取下一个字节

2)存储数字对应的二进制补码

3)补码的低位排在前面

补码的计算方法:

对于正数,原码和补码相同

对于负数,最高位符号位不变,其它位按位取反然后加 1

4.1.2 Varint 编码示例

接下来通过一个示例来说明一下 Varint 编码的过程

示例 1

int32 a = 8;

  • 原码:0000 ... 0000 1000
  • 补码:0000 ... 0000 1000
  • 根据 Varint 编码规则,从低位开始取 7 bit,000 1000
  • 当取出前 7 bit 后,前面所有的位就都是 0 了,不需要继续读取了,因此设置 msb 位为 0 即可
  • 所以最终 Varint 编码为 0000 1000

可以看到在使用 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,这一点意味着负数都必须占用所有字节。

我们还是通过一个示例来体会一下。

int32 a = -1

  • 原码:1000 ... 0000 0001
  • 补码:1111 ... 1111 1111
  • 根据 Varints 编码规则,从低位开始取 7 bit,111 1111,由于前面还有 1 需要读取,因此需要设置 msb 位为 1,然后将这个字节放在 Varint 编码的高位。
  • 依次类推,有 9 组(字节)都是 1,这 9 组的 msb 均为 1,最后一组只有 1 位是 1,由于已经是最后一组了不需要再继续读取了,因此这组的 msb 位应该是 0.
  • 因此最终的 Varint 编码是 1111 1111 ... 0000 0001(FF FF FF FF FF FF FF FF FF 01 )

可能大家会有疑问为什么会占用 10 个字节呢?

这是 Protobuf 基于兼容性考虑,例如当开发者将 int64 改为 int32 后应该不影响旧程序,所以将 int32 扩展为 int64 的八个字节。通常的说法即是 64=9*7+1 所以最多需要10字节来标识一个64字节无符号变长整数。

可能大家还会有疑问为什么对于正数的时候不需要进行类似的兼容处理呢

实际上当要编码的是正数时,int32 和 int64 是天然兼容的,他们两个的编码过程是完全一样的,利用 msb 位去控制最终的 Varint 编码长度即可。

所以目前的情况是我们定义了一个 int32 类型的变量,如果将变量的值设置为 负数,如果直接采用 Varint 编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。那么我们应该如何去解决呢?

答案就是下面的 Zigzag 编码。

4.2 Zigzag 编码

在 Protobuf 中 Zigzag 编码的出现主要是为了解决 Varint 编码负数效率低的问题。

基本原理就是将有符号正数映射成无符号整数,然后再使用 Varint 编码,这里所说的映射是通过移位的方式实现的并不是通过存储映射表。

4.2.1 Zigzag 编码规则

部分源码:

public int int_to_Zigzag(int n) {
// 传入的参数n = 传入字段值的二进制表示(此处以负数为例)
// 负数的二进制 = 符号位为1,剩余的位数为该数绝对值的原码按位取反;然后整个二进制数+1
  return (n <<1) ^ (n >>31);
}

// 解码
public int Zigzag_to_int(int n) {
  return (n >>> 1) ^ -(n & 1);
}

根据上面的源码我们可以得出 Zigzag 的编码过程如下:

  • 将补码左移 1 位,低位补 0,得到 result1
  • 将补码右移 31 位,得到 result2
    • 首位是 1 的补码(有符号数)是算数右移,即右移后左边补 1
    • 首位是 0 的补码(无符号数)是逻辑右移,即右移后左边补 0
  • 将 result1 和 result2 异或

4.2.2 Zigzag 编码示例

下面通过一个示例来演示一个 Zigzag 的编码过程

sint32 a = -2

  • 原码:1000 ... 0010
  • 补码:1111 ... 1110
  • 左移一位(算数右移)result1:1111 ... 1100
  • 右移31位result2:1111 ... 1111
  • 异或: 0000 ... 0011(3)

编码过程示意图如下:

可以看到 -2 经过 Zigzag 编码之后变成了正数 3,这时再通过 Varint 编码就很高效了,在接收端先通过 Varint 解码得到数字 3,然后再通过 Zigzag 解码就可以得到原始发送的数据 -2 了。

因此在定义字段时如果知道该字段的值有可能是负数的话,那么建议使用 sint32/sint64 这两种数据类型。

4.3 64-bit(32-bit)编码

64-bit 和 32-bit 的编码方式比较简单,64-bit 编码后是固定的 8 个字节,32 bit 编码后是固定的 4 个字节。当数据类型是 fixed64,sfixed64,double 时将采用 64-bit 编码方式,当数据类型是 fixd32,sfixed64,float 时将采用 32-bit 编码方式。

注意这两种编码方式都是补码的高位放到编码后的低位。

它们都采用的是 T - V 的存储方式。

4.4 length-delimited

这是 Protobuf 中唯一一个采用 T - L - V 的存储方式。如下图所示,Tag 和 Length 仍然采用 Varint 编码,对于字段值根据不同的数据类型采用不同的编码方式。

例如,对于 string 类型字段值采用的是 utf-8 编码,而对于嵌套消息数据类型会根据里面字段的类型选择不同的编码方式。

接下来重点说一下嵌套消息数据类型是如何进行编码的。

通过下面的示例来说明,在 Test3 这个 Message 对象中的 c 字段的类型是一个消息对象 Test2,并且将 Test2 中字段 str 的值设置为 testing,将字段 id1 的值设置为 296.

message Test2 {
  required string str = 1;
  required int32 id1   = 2;
}

message Test3 {
  required Test2 c = 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原理

https://blog.csdn.net/shimazhuge/article/details/77126700

[2][译]Protobuf 语法指南

https://colobu.com/2015/01/07/Protobuf-language-guide/

[3]protobuf官网

https://developers.google.com/protocol-buffers/docs/proto3?hl=zh-cn#generating

[4]深入理解 ProtoBuf 原理与工程实践(概述)

https://blog.csdn.net/g6U8W7p06dCO99fQ3/article/details/118686768

[5]深入 ProtoBuf - 序列化源码解析

https://blog.csdn.net/iopoint/article/details/118218671?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link

[6]深入 ProtoBuf - 反射原理解析

https://www.jianshu.com/p/ddc1aaca3691

[7]google官方原理介绍

https://gitee.com/chenjim/ProtoBuf#https://developers.google.com/protocol-buffers/docs/encoding

[8]Protobuf 使用介绍及原理

https://gitee.com/chenjim/ProtoBuf

[9]protobuf 协议浅析

https://www.cnblogs.com/zhangguicheng/p/14117962.html#321-varint-%E7%BC%96%E7%A0%81%E8%A7%84%E5%88%99

[10]Carson带你学序列化:这是一份很有诚意的 Protocol Buffer 语法详解

https://blog.csdn.net/carson_ho/article/details/70267574/

[11]Protobuf 数据格式原理剖析(解释了2的29次方如何得来)

https://www.163.com/dy/article/CCRTNI9G0514BE4T.html

[12]Protobuf底层存储原理

https://www.cnblogs.com/liangjf/p/10642230.html

[13]zigzag算法详解

https://blog.csdn.net/weixin_43708622/article/details/111397290?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1.no_search_link&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1.no_search_link

[14]Protobuf的研究理解

https://blog.csdn.net/romantic_jie/article/details/103167655?utm_term=protobuf%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0&utm_medium=distribute.pc_aggpage_search_result.none-task-blog-2~all~sobaiduweb~default-6-103167655&spm=3001.4430

[15]Protobuf使用手册--中文版

https://blog.csdn.net/qq_41204464/article/details/95631781?ops_request_misc=&request_id=&biz_id=102&utm_term=protobuf%E4%BD%BF%E7%94%A8%E8%AF%A6%E8%A7%A3&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-7-95631781.first_rank_v2_pc_rank_v29&spm=1018.2226.3001.4187

[16]【转载】Protobuf原理分析小结

https://blog.csdn.net/xmcy001122/article/details/104473731?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522163488329716780264047837%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=163488329716780264047837&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-104473731.first_rank_v2_pc_rank_v29&utm_term=protobuf%E5%8E%9F%E7%90%86&spm=1018.2226.3001.4187

相关推荐

悠悠万事,吃饭为大(悠悠万事吃饭为大,什么意思)

新媒体编辑:杜岷赵蕾初审:程秀娟审核:汤小俊审签:周星...

高铁扒门事件升级版!婚宴上‘冲喜’老人团:我们抢的是社会资源

凌晨两点改方案时,突然收到婚庆团队发来的视频——胶东某酒店宴会厅,三个穿大红棉袄的中年妇女跟敢死队似的往前冲,眼瞅着就要扑到新娘的高额钻石项链上。要不是门口小伙及时阻拦,这婚礼造型团队熬了三个月的方案...

微服务架构实战:商家管理后台与sso设计,SSO客户端设计

SSO客户端设计下面通过模块merchant-security对SSO客户端安全认证部分的实现进行封装,以便各个接入SSO的客户端应用进行引用。安全认证的项目管理配置SSO客户端安全认证的项目管理使...

还在为 Spring Boot 配置类加载机制困惑?一文为你彻底解惑

在当今微服务架构盛行、项目复杂度不断攀升的开发环境下,SpringBoot作为Java后端开发的主流框架,无疑是我们手中的得力武器。然而,当我们在享受其自动配置带来的便捷时,是否曾被配置类加载...

Seata源码—6.Seata AT模式的数据源代理二

大纲1.Seata的Resource资源接口源码2.Seata数据源连接池代理的实现源码3.Client向Server发起注册RM的源码4.Client向Server注册RM时的交互源码5.数据源连接...

30分钟了解K8S(30分钟了解微积分)

微服务演进方向o面向分布式设计(Distribution):容器、微服务、API驱动的开发;o面向配置设计(Configuration):一个镜像,多个环境配置;o面向韧性设计(Resista...

SpringBoot条件化配置(@Conditional)全面解析与实战指南

一、条件化配置基础概念1.1什么是条件化配置条件化配置是Spring框架提供的一种基于特定条件来决定是否注册Bean或加载配置的机制。在SpringBoot中,这一机制通过@Conditional...

一招解决所有依赖冲突(克服依赖)

背景介绍最近遇到了这样一个问题,我们有一个jar包common-tool,作为基础工具包,被各个项目在引用。突然某一天发现日志很多报错。一看是NoSuchMethodError,意思是Dis...

你读过Mybatis的源码?说说它用到了几种设计模式

学习设计模式时,很多人都有类似的困扰——明明概念背得滚瓜烂熟,一到写代码就完全想不起来怎么用。就像学了一堆游泳技巧,却从没下过水实践,很难真正掌握。其实理解一个知识点,就像看立体模型,单角度观察总...

golang对接阿里云私有Bucket上传图片、授权访问图片

1、为什么要设置私有bucket公共读写:互联网上任何用户都可以对该Bucket内的文件进行访问,并且向该Bucket写入数据。这有可能造成您数据的外泄以及费用激增,若被人恶意写入违法信息还可...

spring中的资源的加载(spring加载原理)

最近在网上看到有人问@ContextConfiguration("classpath:/bean.xml")中除了classpath这种还有其他的写法么,看他的意思是想从本地文件...

Android资源使用(android资源文件)

Android资源管理机制在Android的开发中,需要使用到各式各样的资源,这些资源往往是一些静态资源,比如位图,颜色,布局定义,用户界面使用到的字符串,动画等。这些资源统统放在项目的res/独立子...

如何深度理解mybatis?(如何深度理解康乐服务质量管理的5个维度)

深度自定义mybatis回顾mybatis的操作的核心步骤编写核心类SqlSessionFacotryBuild进行解析配置文件深度分析解析SqlSessionFacotryBuild干的核心工作编写...

@Autowired与@Resource原理知识点详解

springIOCAOP的不多做赘述了,说下IOC:SpringIOC解决的是对象管理和对象依赖的问题,IOC容器可以理解为一个对象工厂,我们都把该对象交给工厂,工厂管理这些对象的创建以及依赖关系...

java的redis连接工具篇(java redis client)

在Java里,有不少用于连接Redis的工具,下面为你介绍一些主流的工具及其特点:JedisJedis是Redis官方推荐的Java连接工具,它提供了全面的Redis命令支持,且...