ProtoBuf 在Android的使用与原理解析

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的方式

猜你喜欢

转载自blog.csdn.net/lixiong0713/article/details/107934792