目录
一、前言
二、共识与协议
三、信息的编解码
1、纯文本形式
2、XML
3、protobuf
四、Protobuf的编码原理
1、变长编码
1.1 有符号数的表示
2、字段名称与字段类型
2.1 字段类型
2.2 字段名称
五、 Protobuf编码结构
五、总结
上篇文章中介绍了protobuf的使用,下面我们深入了解以下protobuf的原理,它是怎么实现高效的序列与反序列化的。首先我们需要先回答一个问题:对于运行在不同的机器上的客户端和服务端,我们该怎么表示它们之间的数据交换呢?
对于运行在不同的机器上的客户端和服务端,机器的操作系统会有差异,且还有可能是由不同的编程语言编写的,那么服务端要怎么才能识别出来客户端发送的是什么数据呢?
例如客户端向服务端发送了以下的数据
0101000100100001
那么服务端要怎么才能在正确地”解读“这段数据呢?所以在发送数据之前必须首先达成关于怎么解读数据段的共识,这就是所谓的协议。
这里的协议可以是这样的:“将每8个比特为一个单位解释为无符号数字”,如果协议是这样的,那么服务端接收到这串二进制后就会将其解析为81(01010001)与33(00100001)。协议也可以是这样的:“将每8个比特为一个单位解释为ASCII字符”,那么server接收到这串二进制后就将其解析为“Q!”。
可见,同样一串二进制在不同的“上下文/协议”下有完全不一样的解读,这也是为什么计算机明明只认知0和1但是却能处理非常复杂任务的根本原因,因为一切都可以编码为0和1,同样的我们也可以从0和1中解析出我们想要的信息,这就是所谓的编解码技术。
我们知道客户端和服务端可能是使用不同的语言编写的,所以我们的编码方案不能和语言绑定。还有就是我们需要考虑到编解码方法的性能问题。
我们首先考虑到的就是以纯文本的形式来表示,其是一种对于我们(人类)来说很友好的载体,因为我们可以直接看懂。
如下:
{
"widget": {
"window": {
"title": "Sample Konfabulator Widget",
"name": "main_window",
"width": 500,
"height": 500
},
"image": {
"src": "Images/Sun.png",
"name": "sun1",
"hOffset": 250,
"vOffset": 250,
},
}
}
只要我们实现约定好文本的结构(也就是语法),那么客户端和服务端就能利用这种文本进行信息的编码以及解码,不管客户端和服务端是运行在x86还是Arm、是32位的还是64位的、运行在Linux上还是windows上、是大端还是小端,都可以无障碍交流。在这里文本的语法就是一种协议。
上面的那段纯文本的语言就是所谓的JSON,但是我们要清楚JSON (JavaScript Object Notation) 主要用于数据交换和存储,而不是用于编写程序逻辑或定义行为。
除了JSON,还有一种也是利用文本存储数据的方式就是XML,如下
Tove
Jani
Reminder
Don't forget me this weekend!
相较于JSON,它对于我们来说可读性就不太好了,Json出现后在web领域逐渐取代了XML。当两段数据量很少的时候如浏览器和服务端的交互,Json可以工作的非常好。
但对于后端服务之间的交互来说就不一样了,后端服务之间的RPC调用可能会传输大量数据,如果全部用纯文本的形式来表示数据那么不管是网络带宽还是性能可能都会差强人意。
在这种场景下,JSON并不是最好的选项,主要原因之一就在于性能以及数据的体积。
我们知道,文本表示对人类是最友好的,对机器来说则不是这样,对机器来说最好的还是01二进制。
接下来就轮到我们的主角登场了——protobuf,它是二进制的编码方法,这就是当前互联网后端中流行的protobuf,Google公司开源项目。
接下来我们就以一个例子来对比这三种的编码方式。假设我们现在想要从客户端向服务端发送一个信息:id,且它的值是43
对于XML来说是这样表示的
43
//4 (for ) + 2 (for 43) + 5 (for ) =11 字节
对于JSON是这样表示的
{"id":43}
//1+1+2+1+1+2+1 = **9字节**
用protobuf是这样表示的
// 消息定义
message Msg {
optional int32 id = 1;
}
// 实例化
Msg msg;
msg.set_id(43);
其中Msg的定义看上去比Json和XML更加复杂了,但这些只是给人看的,这些还会被protbuf进一步处理,最终被编码为: 082b,也就是 0x08 与 0x2b,这占据了多少字节呢?答案是2字节。
数据量的减少意味着:
那么protobuf是怎么实现的呢?它的原理是什么呢?
首先我们考虑该如何表示数字,我们当然可以想使用一个固定长度的位数来表示数字,比如8个字节(64个比特位),这种方法可行是可行,但是存在着一个问题就是对于很小的数就会产生很多的无效的数据量。显然,我们还需要找到更好的办法,比如抛弃固定长度,改用变长来表示。就像自适应一样,如果数字很大,那么它就可以使用较多的比特位来表示,相反,如果数字较小,也应该使用较少的比特位来表示,以减少数据量的发送。
我们规定:对于每一个字节来说,第一个比特位如果是1那么表示接下来的一个比特依然要用来解释为一个数字,如果第一个比特为0,那么说明接下来的一个字节不是用来表示该数字的。
也就是说对于每个8个比特(1字节)来说,它的有效载荷是7个比特,第一个比特仅仅用来标记是否还应该把接下来的一个字节解析为数字。
我们假设有这样一段二进制数据段如下
1010110000000010
首先取出第一个字节,也就是:
10101100
此时我们发现第一个比特位是1,因此我们知道接下来的一个字节也属于该数字,将当前字节的1去掉就是:
0101100
然后我们看下一个字节
00000010
我们发现第一个bit为0,因此我们知道下一个字节不属于该数字了。
接下来我们将解析到的0101100(第一个字节去掉第一个比特位)以及第二个字节0000010(第二个字节去掉第一个比特位)翻转之后拼接到一起,这里之所以翻转是因为我们规定数字的高位在后。
这个过程就是:
1010110000000010
-> 10101100 | 00000010 // 解析得到两个字节
_ _
-> 0101100 | 0000010 // 各自去掉最高位
-> 0000010 | 0101100 // 两个字节翻转顺序
0000010 + 0101100
-> 100101100 // 拼接
最后我们得到了100101100,这一串二进制表示数字300。这种数字的变长表示方法在protobuf中被称之为varint。
但是上面的方法只能表示无符号数字,那么有符号数字该怎么表示呢?比如-2该怎么表示?
我们知道在计算机世界中负数使用补码表示的,也就是说最高位(最左侧的比特位)一定是1,假设我们使用64位来表示数字,那么如果我们依然用补码来表示数字的话那么无论这个负数有多大还是多小都需要占据固定长度10个字节的空间,这就失去了变长编码的优势。
因为如果我们直接将一个 负数(如 -1、-2、-2147483646) 按照 Varint 的方式编码,它会被当作一个非常大的无符号整数处理(因为符号位是1),从而导致占用大量字节。那么如何解决这个问题呢?
既然无符号数字可以方便的进行变长编码,那么我们将有符号数字映射称为无符号数字不就可以了,这就是所谓的ZigZag编码。为了克服负数使用补码带来的问题,Protocol Buffers 引入了 ZigZag 编码(zigzag encoding)
原始信息 编码后
0 0
-1 1
1 2
-2 3
2 4
-3 5
3 6
... ...
2147483647 4294967294
-2147483648 4294967295
这样,无论是 -1
还是 1
,它们在 ZigZag + Varint 编码后都只需要 1 个字节!
我们在上一篇文章中已经见识到了一个标准的protobuf结构化数据message,它包含这几部分
刚才我们用varint以及ZigZag编码解决了字段值表示的问题,那么该怎样表示字段名称和字段类型呢?
首先,对于字段类型还比较简单,因为字段类型就那么多,protobuf中定义了6种字段类型
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
对于6种字段类型我们使用3个比特位来表示就足够了。 因为 3个比特位可以表示 23=823=8 种不同的值(0 ~ 7),而我们只有6种字段类型。
那么字段名称该怎么表示呢?如下
int long_long_name = 100;
那么我们难道真的需要把“long_long_name”这么多字符通过网络传递给对端吗?这是不可能的。
既然通信双方需要协议,那么“long_long_name”这字段其实是客户端和服务端都知道的,它们唯一不知道的就是“哪些值属于哪些字段”。
为解决这个问题,我们给每个字段都进行编号,比如通信双方都知道“long_long_name”这个字段的编号是2,那么对于上面的数据我们之需要传递:
所以我们可以看到,无论你用多么复杂的字段名称也不会影响编码后占据的空间,字段名称根本就不会出现在编码后的信息。
我们先来介绍一下一个常用的编码格式---TLV格式
TLV格式:
即 Tag-Length-Value,Tag作为该字段的唯一标识,Length代表了Value数据域的长度,Value代表的就是数据本身。
Protobuf 采用的正是类似 于 TLV(Tag-Length-Value) 的格式,但 Protobuf 的具体实现和标准的 TLV 格式有一些差异,编码结构如下图所示:
首先,每一个message进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,如下图所示:
特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式, 如果变成这样的形式,没有了 Length 我们 使用Varint 编码确定 Value 的边界。
继续深入 Tag ,Tag 由 field_number 和 wire_type 两个部分组成:
Tag 结构如下图所示:
我们上面说到过,对于字段类型(wire_type)我们只需要使用三个比特位表示就行;而对于字段名称,只需要存储对应的编号(字段编号field_number),就可以像如下这样编码:
(字段编号 << 3) | 字段类型
字段编号 << 3
:将字段编号左移3位,为后面加上3位的字段类型腾出空间。| 字段类型
:使用按位或操作符将字段类型添加到左移后的字段编号后面。例如
tag = (3 << 3) | 5;
// 计算过程:
// 3 << 3 = 24 (二进制: 11000)
// 24 | 5 = 29 (二进制: 11101)
// 十六进制表示: 0x1D
现在我们可以理解一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:
- Tag - Length - Value:编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构,
- Tag - Value:编码类型表中 Varint、64-bit、32-bit 使用这种结构。
嵌套类型:
与JSON和XML类似,protobuf中也支持嵌套消息。
其实也比较简单,这依然遵循被编码后形成一系列的tag-length-value,只不过对于嵌套类型的tag来说,其value是由子消息的tag-length-value组成。
我们知道protobuf兼具了文本的可读性以及二进制的高效。它会将可读性较好的消息编码为二进制从而可以在网络中进行传播,而对端也可以将其解码回来。
感谢阅读!