ProtoBuf 在Android的使用与原理解析
ProtoBuf是Google的一个开源项目。它是一种灵活高效可序列化的数据协议,相于XML,具有更快、更简单、更轻量级等特性。支持多种语言,只需定义好数据结构,利用Protobuf框架生成源代码,就可很轻松地实现数据结构的序列化和反序列化。一旦需求有变,可以更新数据结构,而不会影响已部署程序。
1. 在Android中使用ProtoBuf
1. 在project的build.gradle配置如下
dependencies {
// 引入protobuf插件
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
}
2. 在app的build.gradle配置如下
apply plugin: 'com.google.protobuf'
dependencies {
implementation 'com.google.protobuf:protobuf-lite:3.0.0'
}
//构建task
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.0.0'
}
plugins {
javalite {
artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
}
}
generateProtoTasks {
all().each { task ->
task.builtins {
remove java
}
task.plugins {
javalite { }
}
}
}
}
apply plugin: 'com.google.protobuf'
是Protobuf的Gradle插件,帮助我们在编译时通过语义分析自动生成源码,提供数据结构的初始化、序列化以及反序列等接口。
3 定义数据结构,在app/src/mian下面建立一个proto文件夹,这个文件夹里面主要是用来存放.proto文件,我们新建一个User.proto文件,代码如下:
syntax = "proto2";
option java_package = "com.lx.protobuf";
option java_outer_classname="UserProto";
message User {
required int32 age = 2; //年龄
required string name = 1;//姓名
}
syntax = "proto2"; 声明proto协议版本,proto2和proto3在定义数据结构时有些差别。
option java_package = "com.lx.protobuf"; 存放的包名, 编译后源码生成在app/build/generated/source/proto目录中。
option java_outer_classname="UserProto"; 定义了Protobuf自动生成类的类名。
message 消息类似于一个类。
每个ProtoBuf 的 字段 都有一定的格式,具体格式如下
字段规则 | 字段类型 | 字段名称 | = | 字段标识符 | [字段默认值]
(1)指定字段类型
在上面的例子中,所有字段都是标量类型:一个整型(age),一个字符串类型(query)。当然,你也可以为字段指定其他的合成类型,包括枚举(enumerations)或其他消息类型。
proto中的type在不同编程语言中对应的类型
2)分配标识符
正如上述文件格式,在消息定义中,每个字段都有唯一的一个数字标识符。这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。注:[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。
最小的标识号可以从1开始,最大到2^29 - 1, or 536,870,911。不可以使用其中的[19000-19999]的标识号, Protobuf协议实现中对这些进行了预留。如果非要在.proto文件中使用这些预留标识号,编译时就会报警。
(3)指定字段规则
所指定的消息字段修饰符必须是如下之一:
1)required:一个格式良好的消息一定要含有1个这种字段。表示该值是必须要设置的;
2)optional:消息格式中该字段可以有0个或1个值(不超过1个);
3)repeated:在一个格式良好的消息中,这种字段可以重复任意多次(包括0次)。重复的值的顺序会被保留。表示该值可以重复,相当于java中的List。
由于一些历史原因,基本数值类型的repeated的字段并没有被尽可能地高效编码。在新的代码中,用户应该使用特殊选项[packed=true]来保证更高效的编码。如:
repeated int32 samples = 4 [packed=true];
required是永久性的:在将一个字段标识为required的时候,应该特别小心。如果在某些情况下不想写入或者发送一个required的字段,将原始该字段修饰符更改为optional可能会遇到问题——旧版本的使用者会认为不含该字段的消息是不完整的,从而可能会无目的的拒绝解析。在这种情况下,你应该考虑编写特别针对于应用程序的、自定义的消息校验函数。Google的一些工程师得出了一个结论:使用required弊多于利;他们更愿意使用optional和repeated而不是required。当然,这个观点并不具有普遍性。
4. 使用,在编写好.proto文件后编译项目会在app/build/generated/source/proto生成相应的java文件。
当相关java文件生成后我们就可以使用了,使用方法如下所示:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
UserProto.User.Builder builder = UserProto.User.newBuilder();
builder.setName("CSDN");
builder.setAge(366);
UserProto.User user = builder.build();
System.out.println("user.getName() = " + user.getName());
System.out.println("user.getAge() = " + user.getAge());
// 序列化
byte[] bytes = user.toByteArray();
StringBuffer stringBuffer = new StringBuffer();
for (byte b : bytes) {
stringBuffer.append(b + " ");
}
// 打印序列化结果
System.out.println("result = " + stringBuffer.toString());
}
}
运行日志如下:
2. ProtoBuf 序列化原理
Protobuf采用了T-L-V的存储格式存储数据,其中的T代表tag,即key,L则是length,代表当前存储的类型的数据长度,当是数值类型的时候L被忽略,V代表value,即存入的值,protobuf会将每一个key根据不同的类型对应的序列化算法进行序列化,然后按照key-value|key-value的格式存储,其中key的type类型与对应的压缩算法关系如下:
protobuf的key计算按照 (field_number << 3) | wire_type 方式计算,而这里的field_number是指定义的时候该字段的标识符,如:required string name=1;这里的name字段的域号为1,在protobuf中规定:
1)如果字段标识符在[1,15]范围内,会使用一个字节表示Key;
2)如果字段标识符在[16,2047]范围内,会使用两个字节表示Key;
key编码完成后,该字节的第一个比特位表示后一个字节是否与当前字节有关系,即:
1)如果第一个比特位为1,表示有关,即连续两个字节都是Key的编码;
2)如果第一个比特位为0,表示Key的编码只有当前一个字节,后面的字节是Length或者Value;
2.1 Length-delimi 编码
在Protobuf中存储字符串格式,使用的T-L-V存储方式,标识符Tag采用Varint编码,字节长度Length采用Varint编码,string类型字段值采用UTF-8编码方式存储,所以tag得值为1 <<3 | 2 =10,L的值存储为00000100,即为4,而V的存储,把每一个字符按照UTF-8的编码后的字节流数组,分别为67 83 68 78。所以字符串“CSDN”编码成二进制数据就是 10 4 67 83 68 78 。
2.2 Varint 与 Zigzag 编码
1. 正数存储
在Protobuf中存储数值类型格式时L被省略,上述案例中,使用了int32类型,对应的压缩算法为varint,接下来我们分析age = 366 这个值是如何序列化的。
1)将十进制转为二进制,int32 占用4个字节,高位全部补0, 366 -----> 00000000 00000000 00000001 01101110
2) 从低位到高位取7位,8位是一个字节,当前最高位为标志位,如果下一个字节内还有非0的数值则最高位补1,如果没有非0的值则最高位补0,当最高位为0后,压缩存储结束。
(1)00000000 00000000 00000001 01101110 --- 从低位取出7位则是 1101110 ,然后将
00000000 00000000 00000001 01101110 --- 右移7位 --- 00000000 00000000 00000000 00000010 ,发现后一个字节中还存在1 ,所以最高位补1,则为 11101110 。
(2) 00000000 00000000 00000000 00000010 --- 从低位取出7位则是 0000010 ,然后将
00000000 00000000 00000000 00000010 --- 右移7位 --- 00000000 00000000 00000000 00000000 ,发现后一个字节中补存在1 ,所以最高位补0,则为 0000010 。
(3)将(1)和(2)中取出字节合并则为 11101110 0000010。
通过上面的计算,可以知道 366 在protobuf 的T-V结构中的Value 为 11101110 0000010。
接下来我们分析366 在 protobuf 的 T-L结构中 Tag 的值如何计算?结算公示为:(field_number << 3) | wire_type,上面哟说明。通过.proto文件可以知道字段age的字段标志号为2,因此只用一个字节来存储key(Tag)的值,tag= 2 << 3 | 1 = 16。
由以上分析 366 在 protobuf 中存储的二进制位 00010000 11101110 0000010 ,也就是 16 -18 2。注意:负数在计算机中国存储的是补码。补码:11101110 ---> 反码: 11101101 ---> 原码 :10010010 ---> 十进制值:-18 。
结合上面字符串的二进制存储,则user序列化结果为 10 4 67 83 68 78 16 -18 2 与上面的输出是一样的。
2. 负数存储
在计算机中,负数会被表示为很大的整数,因为计算机定义负数符号位为二进制数字的最高位,所以在 protobuf 中通过sint32/sint64 类型来表示负数,负数的处理形式是先采用 zigzag 编码(把符号数转化为无符号数),再采用 varint 编码。
sint32:(n << 1) ^ (n >> 31)
sint64:(n << 1) ^ (n >> 63)
例如存储一个 -366 的值,对应的二进制原码为 10000000 00000000 00000001 01101110
取反得到反码为 11111111 11111111 11111110 10010001
加1得到补码为 11111111 11111111 11111110 10010010
n << 1: 整体左移一位,右边补0 ---> 11111111 11111111 11111101 00100100
n >>31:整体右移一位, 左边补1 ---> 11111111 11111111 11111111 11111111
(n << 1) ^ (n >> 31) ---- > 00000000 00000000 00000010 11011011
十进制: 00000000 00000000 00000010 11011011 = 731 ,然后在按照varint进行编码后的结果为 16 -37 5 。
3. 编码总结
其实varint和ZigZag编码,都可以编码正数和负数,那为什么protobuf怎么抉择的呢?
如果表示的都是正数,varint的方式编码会比ZigZag编码小很多;但如果表示的很多都是负数,由于负数的最高位为1,如果负数也使用varint编码就会出现一个问题,int32总是需要5个字节,int64总是需要10个字节。此时ZigZag的编码方式会更恰当。
为了统一两种方式,并效仿varint的压缩优势,减少ZigZag的字节数。最终,sint32被编码为(n<<1) ^ (n>>31)对应的varint,sint64被编码为(n<<1) ^ (n>>63)对应的varint,这样,绝对值较小的整数只需要较少的字节就可以表示。
因此,protobuf对于正数的编码采用varint,对于负数的编码采用ZigZag编码后的varint。
其实,这句话这样说是不恰当的。因为,protobuf也无法自动识别正数负数并做出不同的编码方式的选择。采用的做法是,在.proto结构定义文件中,如果是int32、int64、uint32、uint64采用varint的方式,如果是sint32、sint64采用ZigZag编码后的varint的方式。