赞
踩
在对 ProtoBuf
做了一些基本介绍之后,这篇开始进入正题,深入 ProtoBuf
的一些原理。
我们都知道Protocol Buffers
采取了二进制编码结构传输,但其二进制编码相比普通的二进制编码更加紧凑,旨在高效地序列化和传输结构化数据。这种编码结构与 XML
或 JSON
相比更加紧凑,因为它不包含标签名称和其他冗余信息,仅关注数据的有效表示。在将其数据转化为其二进制编码的过程中采取了多种编码方式协同,主要采用了TLV
、Varint
、Fixed32
、Fixed64
和 Length-Delimited
等编码方式。
所谓的 TLV
即 Tag - Length - Value
。
Tag
作为该字段的唯一标识,Length
代表 Value
数据域的长度,最后的 Value
便是数据本身。ProtoBuf
编码采用类似的结构,但与传统的 TLV
(Type-Length-Value
)编码有些不同。其编码结构可见下图:
如上图,每个message
进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,特别注意这里的 [Length]
是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式,在后面将详细介绍。
在Tag - [Length] - Value
的概念里,[Length] - Value
这两个信息从字面意思就能很好的理解其概念,但 Tag
是什么?有什么组成,下面我们来说说。
Tag
由 field_number
和 wire_type
两个部分组成,如下图:
field_number: message
定义字段时指定的字段编号;
wire_type: ProtoBuf
编码类型,根据这个类型选择不同的 Value
编码方案,wire_type
数据为3 bit
大小,最多可以表达 8
种编码类型,目前 ProtoBuf
已经定义了 6
种,如下表(编码类型表)所示:
类型 | 含义 | 原始数据类型 |
---|---|---|
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 |
注意其中的 Start group 和 End group 两种类型已被遗弃。而Tag
整体采用 Varints
编码。 Varints
编码后续会单独详细说明。
到这里,先给出一些结论和规则,一个 message
编码将由一个个的 field
组成,每个 field
根据类型将有如下两种格式:
Type = 2
即 Length-delimited
编码类型将使用这种结构;Varint
、64-bit
、32-bit
使用这种结构。前面多次提到 Varints
编码,现在我们来正式介绍这种编码方案。介绍该编码前,我们先了解下 Base 64
、Base 128
编码。
Base 64
是一种用于将二进制数据编码为可打印的 ASCII
字符串的编码方式。它常用于在文本协议中传输二进制数据,例如在电子邮件、URL
、JSON
、XML
等中传递图片、音频、视频等文件。Base 64
编码不是加密,而是一种数据表示方式,将原始数据转换成一串字符,使其适合在文本环境中传输。
先来看看 Base 64
是如何工作的:
3
个字节(24
位)的二进制数据分成 4
个 6
位的组。6
位的组转换成十进制数,然后通过查表将十进制数映射为一个字符,通常是使用 64
个字符的字符集(包括大小写字母、数字和特殊字符)。3
的倍数,会在末尾补 0
(零字节),然后添加一个或两个填充字符(通常是 =
)来表示补充的字节数。这么说,比较枯燥,举个栗子来说明吧。
先给出Base64
编码表:
【示例】 将单词 “hello”
转换为Base64
编码:
hello
的前3个字节 hel
,将其ASCII
码转换成为二进制: h
-> 104
->01101000
、e
-> 101
->01100101
、l
-> 108
->01101100
,将它们连成一个24
位的二进制字符串 011010000110010101101100
;24
位的二进制字符串,每6
个一组分成4组:011010
、000110
、010101
、101100
;00
,扩展成32
个二进制位,即四个字节:00011010
、00000110
、00010101
、00101100
。它们的十进制值分别是26
、6
、21
、44
;Base64
编码表,得到每个值对应Base64
编码,即a
、G
、V
、s
;hello
的剩余的2个字节 lo
的ASCII
码转换成为二进制:l
-> 108
->01101100
、o
-> 111
->01101111
, 将它们连成一个二进制字符串 0110110001101111
;16
位的二进制字符串,每6
个一组分组,剩余不足6个的组后面补0
:011011
、000110
、111100
;00
,即四个字节:00011011
、00000110
、00111100
,它们的十进制值分别是27
、6
、60
;Base64
编码表,得到每个值对应Base64
编码,即b
、G
、8
;=
即可, 最后得出 hello
的Base64
编码:aGVsbG8=
示例数据表图如下:
**Base 64
存在的问题就是:**编码后的每一个字节的最高两位总是 0
,在不考虑 pad
的情况下,有效 bit
只占 bit
总数的 75%,造成大量的空间浪费。
所以Base128
使用 7
位字节分组,然后低位补 0
,比 Base64
更高的位密度可能意味着更少的字符用于表示相同数量的数据。这在某些特定情况下可能会减少传输或存储的数据量。
但问题来了:Base 64
实际上用了 64+1
个 ascii
字符,按照这个思路 Base 128
需要使用 128+1
个 ascii
个字符,但是 ascii
字符一共只有 128
个。另外在 ASCII
字符集中虽然有 128 个字符,但并不是所有的字符都是可见的,例如 “NUT
”,“SOH
” 等,如果用 128
个字符在转换后也就会出现我们所看到的乱码。因此,Base 64
至今依然没有被 Base 128
替代。
Base 64
的规则因为上述限制不能完美地扩展到 Base 128
,所以现有基于 Base 64
扩展而来的编码方式大部分都属于变种:如 LEB128
(Little-Endian Base 128
)、 Base 85
(Ascii 85
),以及本文的主角:Base 128 Varints
。
Base128 Varints
(Variable-Length Integers
)是 Google
开发的序列化库 Protocol Buffers
所用的编码方式,用于编码整数的变长编码方案,用于在数据传输和存储中高效地表示整数值。它是一种压缩整数数据的方法,特别适用于那些数值分布范围广泛的情况,因为较小的整数使用较少的字节来编码,而较大的整数则使用更多的字节。
以下为 Protobuf 官方文档中对于 Varints 的解释:
Varints are a method of serializing integers using one or more bytes. Smaller numbers take a smaller number of bytes.
**即:**使用一个或多个字节对整数进行序列化,小的数字占用更少的字节。
简单来说,Base 128 Varints
编码原理就是尽量只储存整数的有效位,高位的 0
尽可能抛弃。
在Varints
编码中,它将每个字节的第一个bit
保留作为标识位,称为 msb(most significant bit ),标识是否需要继续读取下一个字节。msb
为 1 时,代表着后面还有数据;msb
为 0 时代表着当前字节是当前整数的最后一个字节。
来看下 Varints
编码流程,示例:假如数字 2023
,怎么转化为 Varints
编码呢?过程步骤如下图:
我们来验证下结果是否正确:
package main
import (
"fmt"
"github.com/golang/protobuf/proto"
)
func main() {
var i uint64 = 2023
fmt.Printf("%b", proto.EncodeVarint(i))
}
结果输出:
[11100111 1111]%
可以看出结果完全一致,来总结下Varints
编码流程:
7-bit
拆分分组,最后舍去多余的前导0
;7-bit
分组进行逆序排列(按组),即以7-bit
组为单位颠倒存放顺序;7-bit
分组的第一位添加 msb
,每个非结尾7-bit
片前面加上1
, 在最后一个7-bit
片前加上0
。解码的过程,跟编码反正来即可。
**需要注意的是:**无论是编码还是解码,逆序字节流这一步在机器处理中实际是不存在的,机器采用小端模式处理数据,此处逆序仅是为了符合人的阅读习惯而写出。至于大小端的问题,后续会单独文章讲述。
到目前为止,好像一切都很完美。但是当前的 Varints
编码却存在着明显缺陷。我们的例子好像只给出了正数,我们来看一下负数的 Varints
编码情况。
求 -1
的Varints
编码,按照流程走。
第一步是获取二进制:
原码二进制: 10000000 ...... 00000001 //8字节
补码: 1111111 ...... 11111111 //8字节
有人说,如果在int32
位下 -1
不应该是占用4
个字节吗?为什么要8
字节?
这里是 ProtoBuf
基于兼容性的考虑(比如开发者将 int64
的字段改成 int32
后应当不影响旧程序),而将 int32
扩展成 int64
的八个字节。
继续下一步,将补码按照7-bit
拆分分组,最后舍去多余的前导0
,问题就是出现了,因为负数必须在最高位(符号位)置 1
,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8
个字节,这就导致没法像正数那样去掉多余的高位,所有0
都将继续保存,往后步骤继续走,最终再加上 msb
,最终 Varints
编码的结果将固定在 10
个字节( (8字节 * 8bit) / 7 = 9组,还剩下 64-63=1bit位,将单独一组,所以位10组,占10字节 )。
负数的 Varints
编码结果将总是占用10
个字节,这显然跟我们压缩大小的初衷想违背,不是我们希望得到的结果。如何解决?
答案就是 ZigZag
编码。
为解决负数编码问题,ProtoBuf
为我们提供了 sint32
、sint64
两种类型,当你在使用这两种类型定义字段时,ProtoBuf
将使用 ZigZag
编码,而 ZigZag
编码将解决负数编码效率低的问题。
ZigZag
的原理和概念比我们想象的简单易懂,一句话就可概括介绍 ZigZag
编码:
ZigZag 编码:有符号整数映射到无符号整数,然后再使用 Varints 编码
其中使用了两个公式就搞定了,没有复杂的编码转换:
zigzag32(n) = (n << 1) ^ (n >> 31) //对于 sint32
zigzag64(n) = (n << 1) ^ (n >> 63) //对于 sint64
或者从数学意义来理解:
当 n >= 0 , 则ZigZag的编码后的值为: n * 2
当 n < 0 , 则ZigZag的编码后的值为: -n * 2 - 1
下表展示了部分 ZigZag
编码的例子:
原值 | ZigZag编码值 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
对于 ZigZag
编码的思维不难理解,既然负数的 Varints
编码效率很低,那么就将负数映射到正数,然后对映射后的正数进行 Varints
编码。解码时,解出正数之后再按映射关系映射回原来的负数。
这里的“映射”是以移位实现的,并非存储映射表。
例如我们设置 var i sint32 = -222
。映射得到 443
,那么对数字 443
进行 Varints
编码,将结果存储或发送出去。接收方接到数据后进行 Varints
解码,得到数字443
,再将443
映射回 -222
。
原码: 10000000 00000000 00000000 11011110
补码: 11111111 11111111 11111111 00010110
//不使用ZigZag编码
7-bit分组去0 : 0001111 1111111 1111111 1111110 0010110
Varints编码 : 10010110 11111110 11111111 11111111 00001111
//使用ZigZag编码
ZigZag编码 : 00000000 00000000 00000001 10111011
补码7-bit分组去0 : 0000011 0111011
Varints编码 : 10111011 00000011
对于 -222(sint32)
这个例子,编码成 varints
之前采用 ZigZag
编码,比直接编码成 varints
少用了 60%
的空间。
当然,ZigZag
编码也不是完美的方法。ZigZag
虽然能压缩部分负数的空间,但同时正数变得需要更多的空间来储存。因此,建议在业务场景允许的场景下尽量用无符号整型,有助于进一步压缩编码后的空间。
64-bit
和 32-bit
比较简单,与 Varints
一样其编码结构为 Tag-Value,不同的是不管数字大小,64-bit
存储 8
字节,32-bit
存储 4
字节。读取时同理,64-bit
直接读取 8
字节,32-bit
直接读取 4
字节。
为什么需要 64-bit
和 32-bit
?之前已经分析过了 Varints
编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。
如果现在有场景是存在大量的大数字,那么使用 Varints
就不太合适了,此时使用 64-bit
和 32-bit
更为合适。具体的,如果数值比 256
大的话,64-bit
这个类型比 uint64
高效,如果数值比 228
大的话,32-bit
这个类型比 uint32
高效。
前面都是 Tag-Value 的类型,而 Length-delimited
类型的编码结构为 Tag - Length - Value,主要用于 string
、bytes
、EmbeddedMessage
、repeated
等类型。
例如:
message Test {
string b = 200;
}
假设b
的值设置为“testing
”,首先我们来进行编码获取Tag
,Tag
由 field_number
和 wire_type
两个部分组成,编码过程如下:
从上图可以得知,最终Tag
的值为: C2 0C
,下面再来看看 b
的值“testing
”的情况下,LV
(Length-Value
)的编码过程:
最终根据Tag - [Length] - Value
的概念组成最终编码为: C2 0C 07 74 65 73 74 69 6E 67
。
在 proto2
中为我们提供了可选的设置 [packed = true
],而这一可选项在 proto3
中已成默认设置。packed
目前只能用于 primitive
类型。
packed = true
主要使让 ProtoBuf
为我们把 repeated primitive
的编码结果打包,从而进一步压缩空间,进一步提高效率、速度。这里打包的含义其实就是:原先的 repeated
字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value…,因为这些 Tag
都是相同的(同一字段),因此可以将这些字段的 Value
打包,即将编码结构变为 Tag-Length-Value-Value-Value…。
**对于嵌套消息:**首先你要将被嵌套的消息进行编码成字节流,然后你就可以像处理 UTF-8
编码的字符串一样处理这些字节流:在字节流前面加入使用 Base 128 Varints
编码的长度即可。
示例如下:
message Test{
int32 a = 1
}
message Test2{
Test b = 3
}
并且将Test
中 a
的值设置为150
,来看看嵌套消息的编码:
[1] 先看 Test
的编码:
a = 150
转为二进制: 10010110
按7-bit分组: 0000001 0010110
逆序组并添加msb: 10010110 00000001
获得Tag:field_number=1 wire_type=0 Tag为: 00001000
添加Tag: 00001000 10010110 00000001
makefile[2] 在继续编码 Test2
:
字段b编码 : 00001000 10010110 00000001
求长度: 150长度为3, 所以长度为 00000011
添加长度: 00000011 00001000 10010110 00000001
获得Tag: field_number=3 wire_type=2 Tag为: 00011010
添加Tag: 00011010 00000011 00001000 10010110 00000001
转为十六进制: 1A 03 08 96 01
编码后的内容是:
1A 03 08 96 01
参考资料:
https://blog.csdn.net/bingxuesiyang/article/details/119515248
http://www.52im.net/thread-4088-1-1.html
http://www.52im.net/forum.php?mod=viewthread&tid=4093
https://www.zhihu.com/question/585411183?utm_id=0
https://www.xjx100.cn/news/200968.html?action=onClick
http://www.52im.net/thread-3101-1-1.html
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。