Skip to content

Protocol Buffers原理解析 #159

@coconilu

Description

@coconilu

介绍

Protocol buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler.

Protocol buffers(简称:protobuf)是googol的语言中立,平台无关,可扩展的序列化数据格式,就像XML,但是比它更小,更快,更简单。

protobuf在数据交换频繁的场景下非常适用。一些大厂,比如腾讯、阿里内部都有孵化类似序列化数据格式。

对比一下XML和JSON,就可以知道protobuf为何这么受欢迎。XML和JSON本质上都是字符串,XML可以用任何字符编码格式,但是JSON规定必须使用utf-8。对于字符串来说,utf-8已经尽量把字符串压缩到尽可能短的二进制了。但是protobuf可以做得更好,它使用了特殊的编码方式和字节偏移量来把一个数据对象压缩到极致,下面会重点介绍怎么办到的。

不仅如此,对于XML和JSON,需要把二进制流转换成字符串,然后进行复杂的词法分析才能还原到原本的数据对象,而protobuf使用特殊的编码格式就可以把对象数据序列化和反序列,不仅体积更小,编码速度也很快(cpu占用更少)。唯一的缺点就是需要额外定义.proto文件,但是这个"缺点"可以被另一个需求覆盖,就是前后端在合作的时候,只需要通过.proto文件就可以确定交互的字段,可以省去很多沟通成本。

这篇文章围绕proto2,也会给出proto2和proto3的区别,末尾会捎上参考的文章。

语言中立和平台无关

平台无关很好理解,就是不论在Windows上还是MacOS、Linux上,protobuf都可以运行,我觉得这一点很好办到,因为只要使用跨平台的C语言,并且不使用平台相关的接口(或是使用代理模式屏蔽平台相关的接口)就可以做到,参考NodeJS的底层实现就可以解释的通。

语言中立指的是无论你用什么编程语言都可以正常序列化和反序列,在这里变化的是不同语言的对象定义格式,而不变的是序列化产出的二进制流。这里需要借助.proto文件来达成目的。比如C++的对象和Java的对象使用方式是不一样的,但是通过转换接口可以把C++对象和Java对象转换成符合.proto文件格式的对象,然后再把这个对象进行序列化就可以得到语言中立的二进制流。这里需要说明的是,转换接口是由protobuf提供的,也叫protobuf compiler,且proto3比proto2支持更多的编程语言。

可以通过链接:protobuf pre-built binary看到,protoc-x.x.x-platform.zip文件都是平台相关的编译器,负责把.proto文件转换成语言相关的文件,进而可以被指定的编程语言解析。比如:

  1. protoc --java_out=xxx就是输出java可解析文件
  2. protoc --js_out=xxx就是输出js可解析文件

而protobuf-language-x.x.x.zip文件都是编程语言运行时需要用到的功能文件。

用一张图来表示这个过程:

protobuf

特殊编码格式和字节偏移量

基于proto2来讲述序列化后的二进制流的格式。如果了解过MySql的InnoDB存储引擎的compact(紧凑)行格式,就很好理解下面的内容。

首先看看protobuf是怎么定义一个对象的:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

message就是一个标识,类似C语言里的struct

其次看看二进制流的基本储存单元。

protobuf单元

二进制流就是由一个个的T-L-V组成的,并且有时候是不需要L,也就是T-V,当表示的是数组的是会有多个T-L-V-V-V,可以参考下面的表:

protobuf类型

看到上面这张表,你可能会感到疑惑,wire type和数据类型有什么联系么?

有关系,但是wire type主要是用来区分编码方式,尽量缩短字节长度。

Value的编码方式

对于wire type为1和5,编码字节长度都是固定的,因为表示的是浮点数,而在IEEE里无论浮点数是float(一般是4个字节)还是double(一般是8个字节),它们的二进制表示方式是由符号、尾数、阶码组成,没有办法压缩,所以protobuf使用固定位来表示浮点数。下面是摘自网络上的一张float的二进制表示:

浮点数

而对于整型(wire type为0),protobuf假设使用小数范围的数据使用场景比较多,所以允许使用一个字节来表示小范围整型(0-127)。那么如果大于127的数值要怎么表示呢?在protobuf里使用Varint 编码方式去编码整型。

Varint 编码使用每个字节的第一位来表示这个字节是否是最后一个字节。比方说,小于127的数值是用一个字节来表示,且首位是0,而大于127小于16383的数值是用两个字节来表示的,第一个字节的首位是1,表示自己不是最后一个字节,而第二个字节的首位才是0。换句话说,Varint 编码里的字节只有7位可以表示数值,首位用于标识自己是否是最后一个字节。

Varint 编码其实是和哈夫曼编码的思想是一样的。

哈夫曼编码:出现概率高的字符使用较短的编码,反之出现概率低的则使用较长的编码,这便使编码之后的字符串的平均期望长度降低

但是由于Varint 编码使用字节的首位表示自己不是最后一个字节,所以没有办法再表示负数,所以在protobuf里,对于负数是先使用zigzag编码把负数转换成正数,然后再进行Varint 编码。

接下来看看wire type为3的编码方式,分别介绍一下string(字符串),bytes(字节流),embedded messages(嵌套消息类型),packed repeated fields(数组类型)。

对于字符串来说,很好理解,protobuf使用utf-8编码字符,所以Length里只要记录字节长度就好。

对于字节流来说,也很好理解,只要用Length记录字节长度就好。

对于嵌套消息类型,这里可以理解为一个对象里有一个字段是另一个对象,使用Length表示占用的字节数目,包括嵌套消息对象的所有字段,借助网上一张图来理解会更好一些:

嵌套消息

对于repeated类型(数组类型),当修饰的是varint编码的类型,那么可以加上[packed =true],把多个Tag合并在一起,因为同一个数组里的元素的wire type和字段标识号是一致的,这样就可以把对个T-V合并为一个T-L-V-V-V。当修饰的是字符串类型,不可以加[packed =true],所以字符串数组的tag是冗余的。

至此,protobuf就可以通过T-L-V模型确定每一个message对象的字段的边界了。

Tag的编码方式

Tag其实是由字段标识号和wire type组成的。前面展示了message Person的数据结构,其中required string name = 1;里的1就是字段标识号。如果Tag是用一个字节表示的话只能表示有16个字段以内的message对象,低三位是用来表示wire type的,因为wire type只有6个,而6 < 8(2^3),所以用3位就可以表示所有的wire type了。剩下的5位中,只有四位可以表示数值,首位是用Varint 编码的方式表示自己是否是最后一个字节。所以如果message对象的字段多于16个的话,就会使用两个字节表示,第一个字节的首位是1,第二个字节的首位是0,以此类推。

Length的编码方式

Length的编码方式就简单了,只是用于表示后面的几个字节归属于Tag,编码方式也是Varint 编码,当表示127个字节以内,只需要一个字节,大于127个字节就需要多个字节。

如何确定数据类型

上面只介绍了wire type是怎么规范二进制流边界的,没有提及确切的数据类型。其实数据类型是由protobuf运行时确定的。当protobuf接收完二进制字节流的时候,会根据上面提的方式去解析字节流,把每一个字段的标识号(在tag里)和内容(根据Length或者Varint的编码方式)找出来,然后根据.proto文件里定义的对象,找出对应的字段和字段类型,然后把内容编码还原。

proto2和proto3的区别

proto3和proto2的接口是不一样的,proto3支持更多的语言但是语法更简洁。

protobuf转json格式

proto3支持protocol buffer 和 JSON之间互相转换。如果 JSON 编码数据中缺少值或其值为空,则在解析为 protocol buffer 时,它将被解释为适当的默认值。

从网上摘一张图,里面展示转换的细节:

image

参考

Protocol Buffer 序列化原理大揭秘 - 为什么Protocol Buffer性能这么好?
浮点数表示
Protobuf 的 proto3 与 proto2 的区别
高效的数据压缩编码方式 Protobuf

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions