Protocol Buffers编码

编码结构

在对 ProtoBuf 做了一些基本介绍之后,这篇开始进入正题,深入 ProtoBuf 的一些原理。

我们都知道Protocol Buffers采取了二进制编码结构传输,但其二进制编码相比普通的二进制编码更加紧凑,旨在高效地序列化和传输结构化数据。这种编码结构与 XMLJSON 相比更加紧凑,因为它不包含标签名称和其他冗余信息,仅关注数据的有效表示。在将其数据转化为其二进制编码的过程中采取了多种编码方式协同,主要采用了TLVVarintFixed32Fixed64Length-Delimited 等编码方式。

TLV编码格式

所谓的 TLVTag - Length - Value

Tag 作为该字段的唯一标识,Length 代表 Value 数据域的长度,最后的 Value 便是数据本身。ProtoBuf 编码采用类似的结构,但与传统的 TLVType-Length-Value)编码有些不同。其编码结构可见下图:

image-20230816151510256

如上图,每个message进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式,在后面将详细介绍。

Tag - [Length] - Value的概念里,[Length] - Value这两个信息从字面意思就能很好的理解其概念,但 Tag是什么?有什么组成,下面我们来说说。

Tagfield_numberwire_type 两个部分组成,如下图:

image-20230816152455315

  • field_number: message 定义字段时指定的字段编号;

  • wire_type: ProtoBuf 编码类型,根据这个类型选择不同的 Value 编码方案,wire_type数据为3 bit大小,最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下表(编码类型表)所示:

    类型含义原始数据类型
    0Varintint32, int64, uint32, uint64, sint32, sint64, bool, enum
    164-bitfixed64, sfixed64, double
    2Length-delimitedstring, bytes, embedded messages, packed repeated fields
    3Start groupgroups (deprecated)
    4End groupgroups (deprecated)
    532-bitfixed32, sfixed32, float

​ 注意其中的 Start groupEnd group 两种类型已被遗弃。而Tag 整体采用 Varints 编码。 Varints 编码后续会单独详细说明。

到这里,先给出一些结论和规则,一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:

  • Tag - Length - Value:编码类型表中 Type = 2Length-delimited 编码类型将使用这种结构;
  • Tag - Value:编码类型表中 Varint64-bit32-bit 使用这种结构。

Varints编码

前面多次提到 Varints 编码,现在我们来正式介绍这种编码方案。介绍该编码前,我们先了解下 Base 64Base 128 编码。

Base 64

Base 64 是一种用于将二进制数据编码为可打印的 ASCII 字符串的编码方式。它常用于在文本协议中传输二进制数据,例如在电子邮件、URLJSONXML 等中传递图片、音频、视频等文件。Base 64 编码不是加密,而是一种数据表示方式,将原始数据转换成一串字符,使其适合在文本环境中传输。

先来看看 Base 64 是如何工作的:

  1. 将每 3 个字节(24 位)的二进制数据分成 46 位的组。
  2. 将这些 6 位的组转换成十进制数,然后通过查表将十进制数映射为一个字符,通常是使用 64 个字符的字符集(包括大小写字母、数字和特殊字符)。
  3. 如果原始数据的长度不是 3 的倍数,会在末尾补 0(零字节),然后添加一个或两个填充字符(通常是 = )来表示补充的字节数。

这么说,比较枯燥,举个栗子来说明吧。

先给出Base64编码表:

image-20230816165515008

【示例】 将单词 “hello” 转换为Base64编码:

  1. 先选用 hello 的前3个字节 hel,将其ASCII码转换成为二进制: h-> 104 ->01101000e-> 101 ->01100101l-> 108 ->01101100,将它们连成一个24位的二进制字符串 011010000110010101101100;
  2. 将这个24位的二进制字符串,每6个一组分成4组:011010000110010101101100;
  3. 在每组前面加两个00,扩展成32个二进制位,即四个字节:00011010000001100001010100101100。它们的十进制值分别是2662144;
  4. 根据Base64编码表,得到每个值对应Base64编码,即aGVs
  5. 再将 hello 的剩余的2个字节 loASCII码转换成为二进制:l-> 108 ->01101100o-> 111 ->01101111, 将它们连成一个二进制字符串 0110110001101111;
  6. 将这个16位的二进制字符串,每6个一组分组,剩余不足6个的组后面补0011011000110111100;
  7. 在每组前面加两个00,即四个字节:000110110000011000111100,它们的十进制值分别是27660;
  8. 根据Base64编码表,得到每个值对应Base64编码,即bG8
  9. 然后还按照基础的方法转换,最后补一个=即可, 最后得出 helloBase64编码:aGVsbG8=

示例数据表图如下:

image-20230816173652461

Base128

**Base 64 存在的问题就是:**编码后的每一个字节的最高两位总是 0,在不考虑 pad 的情况下,有效 bit 只占 bit 总数的 75%,造成大量的空间浪费。

所以Base128 使用 7 位字节分组,然后低位补 0,比 Base64 更高的位密度可能意味着更少的字符用于表示相同数量的数据。这在某些特定情况下可能会减少传输或存储的数据量。

但问题来了:Base 64 实际上用了 64+1ascii 字符,按照这个思路 Base 128 需要使用 128+1ascii 个字符,但是 ascii 字符一共只有 128 个。另外在 ASCII 字符集中虽然有 128 个字符,但并不是所有的字符都是可见的,例如 “NUT”,“SOH” 等,如果用 128 个字符在转换后也就会出现我们所看到的乱码。因此,Base 64 至今依然没有被 Base 128 替代。

Base 64 的规则因为上述限制不能完美地扩展到 Base 128,所以现有基于 Base 64 扩展而来的编码方式大部分都属于变种:如 LEB128Little-Endian Base 128)、 Base 85Ascii 85),以及本文的主角:Base 128 Varints

Base128 Varints

Base128 VarintsVariable-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编码呢?过程步骤如下图:

image-20230818150412194

我们来验证下结果是否正确:

package main

import (
	"fmt"
	"github.com/golang/protobuf/proto"
)

func main() {
	var i uint64 = 2023
	fmt.Printf("%b", proto.EncodeVarint(i))
}

结果输出:

[11100111 1111]%  

可以看出结果完全一致,来总结下Varints编码流程:

  1. 将数据转为二进制编码(原码);
  2. 取原码的补码(正数的补码等于原码) 按照7-bit 拆分分组,最后舍去多余的前导0
  3. 将所有的7-bit 分组进行逆序排列(按组),即以7-bit 组为单位颠倒存放顺序;
  4. 在排序完的每组7-bit 分组的第一位添加 msb,每个非结尾7-bit片前面加上1, 在最后一个7-bit片前加上0

解码的过程,跟编码反正来即可。

**需要注意的是:**无论是编码还是解码,逆序字节流这一步在机器处理中实际是不存在的,机器采用小端模式处理数据,此处逆序仅是为了符合人的阅读习惯而写出。至于大小端的问题,后续会单独文章讲述。

到目前为止,好像一切都很完美。但是当前的 Varints 编码却存在着明显缺陷。我们的例子好像只给出了正数,我们来看一下负数的 Varints 编码情况。

-1Varints 编码,按照流程走。

第一步是获取二进制:

原码二进制: 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 编码。

ZigZag 编码

为解决负数编码问题,ProtoBuf 为我们提供了 sint32sint64 两种类型,当你在使用这两种类型定义字段时,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编码值
00
-11
12
-23
21474836474294967294
-21474836484294967295

对于 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 虽然能压缩部分负数的空间,但同时正数变得需要更多的空间来储存。因此,建议在业务场景允许的场景下尽量用无符号整型,有助于进一步压缩编码后的空间。

32-bit & 64-bit

64-bit32-bit 比较简单,与 Varints 一样其编码结构为 Tag-Value,不同的是不管数字大小,64-bit 存储 8 字节,32-bit 存储 4 字节。读取时同理,64-bit 直接读取 8 字节,32-bit 直接读取 4 字节。

为什么需要 64-bit32-bit?之前已经分析过了 Varints 编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。

如果现在有场景是存在大量的大数字,那么使用 Varints 就不太合适了,此时使用 64-bit32-bit 更为合适。具体的,如果数值比 256 大的话,64-bit 这个类型比 uint64 高效,如果数值比 228 大的话,32-bit 这个类型比 uint32 高效。

Length-delimited编码

前面都是 Tag-Value 的类型,而 Length-delimited 类型的编码结构为 Tag - Length - Value,主要用于 stringbytesEmbeddedMessagerepeated等类型。

例如:

message Test {
  string b = 200;
}

假设b的值设置为“testing”,首先我们来进行编码获取TagTagfield_numberwire_type 两个部分组成,编码过程如下:

image-20230823154420988

从上图可以得知,最终Tag的值为: C2 0C,下面再来看看 b的值“testing”的情况下,LVLength-Value)的编码过程:

image-20230823155650931

最终根据Tag - [Length] - Value的概念组成最终编码为: C2 0C 07 74 65 73 74 69 6E 67

packed 编码

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
}

并且将Testa 的值设置为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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值