深入理解Protobuf3协议原理
Posted Android进阶驿站
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解Protobuf3协议原理相关的知识,希望对你有一定的参考价值。
Protobuf是什么
全称为Protocol Buffers,Google推出的序列化框架,用于将自定义数据结构序列化成字节流,和将字节流反序列化为数据结构,该框架不依赖开发语言,也不依赖运行平台,扩展性好,目前支持的语言比较多,包括Java,C++,Python,Ruby等。
使用Protobuf
在这里使用Windows和Java进行实例演示:
先去https://github.com/protocolbuffers/protobuf/releases把protoc-3.11.0-win64.zip下载下来,然后解压,找到bin目录,cmd进去bin目录。
去https://repo1.maven.org/maven2/com/google/protobuf/protobuf-java/,找到最新版本,把jar下载下来。
新建Animal.proto文件,编写如下代码:
syntax="proto3";
// option java_outer_classname = "ProtoBufAnimal"
message Animal {
int32 age = 1;
string name = 2;
}这里定义了一个消息类型Animal,它有两个字段,字段的定义包括类型,名称和字段号,这个字段号必须是唯一的数字,数字越大,占用的字节数也就越多,如果是1~15,那么它就只占用一个字节,因此频繁使用的字段的字段号应该尽量取小一点。
bin目录下打开cmd,执行命令编译Animal.proto文件:
protoc Animal.proto --java_out .
编译后生成了AnimalOuterClass类,可以把这个AnimalOuterClass类理解为辅助类,为了让我们能够很方便地操作proto定义的消息类型,它提供API让我们可以设置或者读取数据,也可以序列化和反序列化。
打开Eclipse,新建Java项目,AnimalOuterClass类复制进来,第1步下载的jar也复制到libs目录下,然后add to build path。
编写测试代码,分别测试序列化和反序列化:
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的解码是这样的:
去掉最高位,得到每个字节的低7位,也就是0010100 0000011。
然后倒序,得到11 0010100。
转成十进制,也就是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。
下面开始分析:
首先message是通过tag-value存储的,所以这里其实就是两个tag-value。
第一个tag-value对应的是age字段,其tag按照公式(key << 3) | wire_type计算,key为1,wire_type为0,最终结果为08,而value就是12采用可变长编码的结果,占用一个字节,最终结果为0C,因此第一个tag-value对应的就是前两个字节080C。
第二个tag-value对应的是name字段,其tag同样按照公式计算,key为2,wire_type为2,最终结果为12,value按照string数据类型的编码格式可知,刚开始是长度,后面是内容,因此先看内容,haha的十六进制表示为68616861,占用4个字节,所有长度就是4,也就是04,整个value表示为0468616861。
最后把两个tag-value拼接起来,就是080C120468616861,跟运行程序生成的一致。
可以看到,protobuf最终生成的二进制数据有个特点就是紧凑,几乎不包含冗余数据,因此数据也会比较小。
Protobuf优缺点
优点
小:生成的字节流采用了各种压缩方式,相对xml和json这类文件更小。
快:编解码基本都是位运算,也没有复杂的嵌套关系,速度快。
安全:这里的安全,是指protobuf没有把字段名写入到字节流里,只是写入了字段号信息。另外,相对于xml和json来说,因为被编码成二进制,破解成本增大。
向后兼容:解析器遇到无法解析的字段,会自动跳过,不影响其它字段的解析,因此新增字段生成的字节流还是可以用旧版本的proto文件代码解析。
语言无关和平台无关:把proto文件转成字节流,可以采用不同的语言,比如后台用C++,客户端用Java或者Python等,字节流转具体对象可以根据需要自行选择语言。也就是说整个编解码过程完全不依赖某种语言的特性。
缺点
可读性差:因为最终是转成二进制流,不像xml和json能够直接查看明文。
Protobuf使用场景
最重要的,技术最后还是得应用到具体的场景中,对于protobuf的使用场景,简单来说,业务要求命中其优点越多,缺点越少,就更能够使用Protobuf,比如说在某些场景对消息大小很敏感,或者传输的数据量不大,比如说APP登录场景,那么可以考虑使用Protobuf。
以上是关于深入理解Protobuf3协议原理的主要内容,如果未能解决你的问题,请参考以下文章