Protobuf协议实现原理

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Protobuf协议实现原理相关的知识,希望对你有一定的参考价值。

参考技术A

protobuf 是Google开源的一款支持跨平台、语言中立的结构化数据描述和高性能序列化协议,此协议完全基于二进制,所以性能要远远高于JSON/XML。由于出色的传输性能所以常见于微服务之间的通讯,其中最为著名的是Google开源的 gRPC 框架。

那么protobuf是如何实现高性能的,又是如何实现数据的编码和解码的呢?

基于128bits的数据存储方式(Base 128 Varints)

Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息

Varint 中的每个 byte 的最高位 bit 有特殊的含义,如果该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个 bit 都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,比如 300,会用两个字节来表示:1010 1100 0000 0010。

另外如果从数据大小角度来看,这种表示方式比实现的数据多了一个bit, 所以其实际传输大小就多14%(1/7 = 0.142857143)。

数字1表示方式:0000 0001

对于小的数据比较好理解,正常情况下1的二进制是 0000 0001,使用128bits表示的话,首位结束标识位也是0,所以两者结果是一样的 0 000 0001。

数字 300 表示方式:1010 1100 0000 0010

<figcaption>300</figcaption>

这个有点不太好理解了,这是因为原本用一个字节(8bit)就可以表示,但由于使用128bits表示方法,需要对每个字节的最高位添加一个结束标识位来表示,所以一个字节已经不够用了,需要占用两个字节来表示,其中两个字节最高位都是结束标识位。

如果正向推算的话,我们知道数字300的二进制值 1 0010 1100,用两个字节表示完整值则为
0000 0001 0010 1100 # 二进制
_000 0010 _010 1100 # 二进制每个字节的最高位向左移动一个位置,放入结束标识位
0 000 0010 1 010 1100 # 转换为128bits方式,1:结束,0:未结束
1 010 1100 0 000 0010 # 转换为 小端字节序 , 低字节在前,高字节在后

注意这里是先添加结束标识符,然后再转为小端字节序。

消息经过序列化后会成为一个二进制数据流,该流中的数据为一系列的 Key-Value 对。如下图所示:

采用这种 Key-Pair 结构无需使用分隔符来分割不同的 Field。 对于可选的 Field,如果消息中不存在该 field,那么在最终的 Message Buffer 中就没有该 field ,这些特性都有助于节约消息本身的大小。

Key 用来标识具体的 field,在解包的时候,客户端创建一个结构对象,Protocol Buffer 从数据流中读取并反序列化数据,并根据 Key 就可以知道相应的 Value 应该对应于结构体中的哪一个 field。

而Key也是由以下两部分组成

Key 的定义如下:

| 1 | (field_number << 3) | wire_type |

可以看到 Key 由两部分组成。第一部分是 field_number 。第二部分为 wire_type 。表示 Value 的传输类型。

一个字节的低3位表示数据类型,其它位则表示字段序号。

Wire Type 可能的类型如下表所示:

在我们的例子当中,field id 所采用的数据类型为 int32,因此对应的 wire type 为 0。细心的读者或许会看到在 Type 0 所能表示的数据类型中有 int32 和 sint32 这两个非常类似的数据类型。Google Protocol Buffer 区别它们的主要意图也是为了减少 encoding 后的字节数。

每个数据头同样采用128bits方式,一般1个字节就足够了,

本例中字段a 的 序号是1

如上创建了 Test1 的结构并且把 a 的值设为 2,序列化后的二进制数据为
0 000 1000 0 000 0010

Key 部分是 0000 1000
value 部分是 0000 0010, 其中字节最高位是结束标识位,即10进制的2,我们在转换的时候统一将符号位转为0即可。

协议规定数据头的低3位表示wire_type, 其它字段表示字段序号field_number,因此
0000 1000
_000 1000 # 去掉结束标识符位
_000 1000 # 000 表示数据类型, 这里是Varint
_000 1000 # 0001 这四位表示字段序号

https://www.ibm.com/developerworks/cn/linux/l-cn-gpb/

原文: https://blog.haohtml.com/archives/20215

深入理解Protobuf3协议原理

Protobuf是什么

全称为Protocol Buffers,Google推出的序列化框架,用于将自定义数据结构序列化成字节流,和将字节流反序列化为数据结构,该框架不依赖开发语言,也不依赖运行平台,扩展性好,目前支持的语言比较多,包括Java,C++,Python,Ruby等。

使用Protobuf

在这里使用Windows和Java进行实例演示:

  1. 先去https://github.com/protocolbuffers/protobuf/releases把protoc-3.11.0-win64.zip下载下来,然后解压,找到bin目录,cmd进去bin目录。

  2. 去https://repo1.maven.org/maven2/com/google/protobuf/protobuf-java/,找到最新版本,把jar下载下来。

  3. 新建Animal.proto文件,编写如下代码:

    syntax="proto3";
    // option java_outer_classname = "ProtoBufAnimal"
    message Animal {
    int32 age = 1;
    string name = 2;
    }

    这里定义了一个消息类型Animal,它有两个字段,字段的定义包括类型,名称和字段号,这个字段号必须是唯一的数字,数字越大,占用的字节数也就越多,如果是1~15,那么它就只占用一个字节,因此频繁使用的字段的字段号应该尽量取小一点。

  4. bin目录下打开cmd,执行命令编译Animal.proto文件:

    protoc Animal.proto --java_out .

    编译后生成了AnimalOuterClass类,可以把这个AnimalOuterClass类理解为辅助类,为了让我们能够很方便地操作proto定义的消息类型,它提供API让我们可以设置或者读取数据,也可以序列化和反序列化。

  5. 打开Eclipse,新建Java项目,AnimalOuterClass类复制进来,第1步下载的jar也复制到libs目录下,然后add to build path。

  6. 编写测试代码,分别测试序列化和反序列化:

    AnimalOuterClass.Animal.Builder builder = AnimalOuterClass.Animal.newBuilder();
    builder.setAge(12);
    builder.setName("haha");
    AnimalOuterClass.Animal animal = builder.build();
    // 序列化
    byte[] data = animal.toByteArray();
    // 十六进制形式打印
    for (byte b : data) {
    System.out.print(String.format("%02X", b & 0xFF));
    }
    System.out.println();
    // 反序列化
    AnimalOuterClass.Animal newAnimal = AnimalOuterClass.Animal.parseFrom(data);
    System.out.println("name:" + newAnimal.getName());
    System.out.println("age:" + newAnimal.getAge());

    输出结果为:

    080C120468616861
    name:haha
    age:12

    可以看到,序列化后得到的byte数组,只占用8个字节。

该demo项目源码:https://github.com/dolpphins/ProtoBuf3Test

Protobuf3语法

指定协议类型

syntax = "proto3";

如果不指定,就会默认使用版本proto2。Google在2001年创建了protobuf,用于内部使用,在2008年,Google把protobuf开源,定为protobuf2,到了2016年,又发布了protobuf3,目前还是有很多公司在使用protobuf2版本。

定义多个消息类型

syntax="proto3";
// option java_outer_classname = "ProtoBufAnimal"
message Animal {
int32 age = 1;
string name = 2;
}

message Cat {
int32 sex = 1;
}

最终都会编译到AnimalOuterClass类中,作为它的一个内部类。

注释

注释跟大部分编程语言一样,行注释// 和 块注释/** */ 都可以。

字段类型

int32:可变长编码,也就是不是固定占用4个字节,一般用于保存正整数。

uint32:可变长编码。

sin32:可变长编码,存储负数时建议用这个。

fixed32:固定占用4个字节。

bool:布尔类型

string:字符串

Protobuf原理

protobuf会对proto协议文件进行序列化,最终转换成二进制数据,在这个转换过程中,protobuf做了一些优化,使得转换出来的二进制数据尽可能的小,同时也具有安全,编解码快等特点。

消息类型编码

我们定义了一个Animal的消息类型,那protobuf是怎么把它编码成二进制的呢?其实它是把message转成一系列的key-value,key就是字段号,value就是字段值,大概这样子存:

[tag1][value1][tag2][value2][tag3][value3]...

解码时,会从左往右解析每一个key-value,假如遇到某个key-value无法解析了,那么就直接跳过,不会影响到其它key-value的解析,因此如果你加了新字段,生成字节流,然后用旧版本解析,这时它还是能够解析出旧版本的字段的,新字段只是被忽略而已,这就是protobuf的向后兼容。

另外注意到,实际上存储的是tag-value,而不是key-value,根据key转换成tag,也是有公式的:

tag = (key << 3) | wire_type

这里的wire_type,其实就是数据类型对应的整数值,它们是预定义好的:

而value也不是直接转成二进制就完事的,它会针对不同的数据类型做不过压缩编码,从而实现占用更少字节数的目的,实现方法下面会继续分析。

可变长编码类型

从上面的字段类型可以看到,像int32这些类型它并不是固定占用4个字节的,如果数值很小,那它可能只占一个字节,这就是可变长编码,很明显这样能够减少占用的空间。protobuf具体的实现方法,就是把每个字节的最高位做为标志位,1表示当前字节不是最末尾的字节,0表示当前字节是最末尾的字节,举个例子,protobuf将404表示为:

10010100  00000011

它只占用两个字节,第一个字节的最高位为1,表示还需要读取下个字节,而第二个字节的最高位为0,表示不需要再读取下个字节了,因此这两个字节就是某个数值,那么要怎么计算究竟是哪个数值呢?protobuf的解码是这样的:

  1. 去掉最高位,得到每个字节的低7位,也就是0010100 0000011。

  2. 然后倒序,得到11 0010100。

  3. 转成十进制,也就是404。

可以看到,可变长编码的核心,就是用字节的最高位做为标志位,用于确定是否需要读取下个字节,这样就可以节省一部分的字节占用了,比如404它就只占用了两个字节,不过,如果所要表示的数值很大,那通过这种方法,可能需要占用5个字节,比原本固定4个字节还多,不过大部分情况这种场景较少,如果真的有,也可以直接定义为固定字节的数据类型。

负数可变长编码类型

如果要表示的数是一个负数,采用上面所说的方式编码,就会占用很多字节,因为对于比较常用(数值比较大)的负数来说,转成补码后会有很多个1,也就是说占用的字节会比较多,这样如果还采用上面那种方式编码,就得不偿失了,因此Google又新增加了一种数据类型,叫sint,专门用来处理这些负数,其实现原理是采用zigzag编码,zigzag编码的映射函数为:

ZigZag(n) = (n << 1) ^ (n << k),k为31或者63

最终的效果就是把所有的整数映射为正整数,比如0->0, -1->1, 1->1, -2->3这样子,然后就可以用上面所说的编码方式进行编码了,解码时通过逆函数解析即可。关于ZigZag编码,可以参考https://www.cnblogs.com/en-heng/p/5570609.html这篇文章。

固定字节数类型

这种就很简单了,直接固定字节数就行,比如fixed32,它固定占用4个字节。而且可以看到,protobuf中float和double也是固定占用4个字节和8个字节,并没有实现压缩。

string类型

按照固定的格式编码,格式为:

value = length + content

其中length就是content占用的字节数,采用可变长编码,content就是string的具体内容。

Protobuf实例解析

接下来根据上面所说的编码原理,解析最开始的实例项目所生成的二进制数据,实例项目proto代码为:

syntax="proto3";
// option java_outer_classname = "ProtoBufAnimal"
message Animal {
int32 age = 1;
string name = 2;
}

在代码里,age的值被设置为12,name的值被设置为haha,最终生成的二进制数据为:080C120468616861。

下面开始分析:

  1. 首先message是通过tag-value存储的,所以这里其实就是两个tag-value。

  2. 第一个tag-value对应的是age字段,其tag按照公式(key << 3) | wire_type计算,key为1,wire_type为0,最终结果为08,而value就是12采用可变长编码的结果,占用一个字节,最终结果为0C,因此第一个tag-value对应的就是前两个字节080C。

  3. 第二个tag-value对应的是name字段,其tag同样按照公式计算,key为2,wire_type为2,最终结果为12,value按照string数据类型的编码格式可知,刚开始是长度,后面是内容,因此先看内容,haha的十六进制表示为68616861,占用4个字节,所有长度就是4,也就是04,整个value表示为0468616861。

  4. 最后把两个tag-value拼接起来,就是080C120468616861,跟运行程序生成的一致。

可以看到,protobuf最终生成的二进制数据有个特点就是紧凑,几乎不包含冗余数据,因此数据也会比较小。

Protobuf优缺点

优点

  • 小:生成的字节流采用了各种压缩方式,相对xml和json这类文件更小。

  • 快:编解码基本都是位运算,也没有复杂的嵌套关系,速度快。

  • 安全:这里的安全,是指protobuf没有把字段名写入到字节流里,只是写入了字段号信息。另外,相对于xml和json来说,因为被编码成二进制,破解成本增大。

  • 向后兼容:解析器遇到无法解析的字段,会自动跳过,不影响其它字段的解析,因此新增字段生成的字节流还是可以用旧版本的proto文件代码解析。

  • 语言无关和平台无关:把proto文件转成字节流,可以采用不同的语言,比如后台用C++,客户端用Java或者Python等,字节流转具体对象可以根据需要自行选择语言。也就是说整个编解码过程完全不依赖某种语言的特性。

缺点

  • 可读性差:因为最终是转成二进制流,不像xml和json能够直接查看明文。

Protobuf使用场景

最重要的,技术最后还是得应用到具体的场景中,对于protobuf的使用场景,简单来说,业务要求命中其优点越多,缺点越少,就更能够使用Protobuf,比如说在某些场景对消息大小很敏感,或者传输的数据量不大,比如说APP登录场景,那么可以考虑使用Protobuf。


以上是关于Protobuf协议实现原理的主要内容,如果未能解决你的问题,请参考以下文章

Go是如何实现protobuf的编解码的: 原理

常见的序列化框架及Protobuf原理

深入理解Protobuf3协议原理

IM通讯协议专题学习:金蝶随手记团队的Protobuf应用实践(原理篇)

gRPC:客户端创建和调用原理

微服务架构Protocol Buffer序列化原理解析