protobuf序列化原理及序列化选型总结

一、protobuf的基本使用

使用protobuf 开发的一般步骤是

  1. 配置开发环境,安装protocol compiler 代码编译器
  2. 编写.proto 文件,定义序列化对象的数据结构
  3. 基于编写的.proto 文件,使用 protocol compiler 编译器生成对应的序列化/反序列化工具类
  4. 基于自动生成的代码,编写自己的序列化应用

1、下载 protobuf 工具
下载地址:https://github.com/google/protobuf/releases,我下载的是protoc-3.5.1-win32.zip

2、编写 proto 文件
protobuf是一个序列化的平台,它有自己的语法,这是它比较麻烦的地方。

syntax="proto2";
package com.kangping.protobuf; //包的路径

option	java_package	= "com.kangping.protobuf"; // 生成类的路径
option java_outer_classname="UserProtos";  // 生成类的名称

message User { // messsage 类修饰符
	// required 表示必填字段 (optional表示可选字段repeated 可重复,表示集合)
	required string name=1;
	// 数字 1,2 表示字段的循序(因为有顺序,序列化的时候只保存了value值,没有保存key值)
    required int32 age=2;  
}

具体的语法,大家可以去了解下
编写好以后,我们在下载的protoc-3.5.1-win32的bin目录下执行
.\protoc.exe --java_out=./ ./user.proto

.\protoc.exe 是使用的程序
–java_out 生成类的路径
./user.proto 使用的文件
执行成功以后会在当前目录生成一个目录,目录里面有一个java类

protobuf序列化原理及序列化选型总结_第1张图片
将这个类导入到工程中,这个类会报错,我们需要到入protobuf的依赖包

 		<dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java</artifactId>
            <version>3.7.0</version>
        </dependency>

我们来用这个类玩一下看看序列化和反序列化的结果

public class Test {

    public static void main(String[] args) throws InvalidProtocolBufferException {
        // 通过链式风格创建user对象
        UserProtos.User user =  UserProtos.User.newBuilder().setName("kp").setAge(300).build();
        byte[] bytes = user.toByteArray();
        // 序列化后的字节数组长度
        System.out.println(bytes.length);
        // 打印字节数组的值
        for (byte bytee: bytes){
            System.out.print(bytee+" ");
        }
        System.out.println("");
        // 反序列打印age
        UserProtos.User parse = UserProtos.User.parseFrom(bytes);
        System.out.println(parse.getAge());
    }
}

结果:
在这里插入图片描述
序列化的字节长度是7,中间的打印的字节数组值先不管,在原理分析时会说,年龄等于300也打印出来了,证明反序列也成功了。我们在这列可以对边一下java中的序列化

        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(user);
		// 序列化后的字节数组长度
        byte[] byte2 = byteArrayOutputStream.toByteArray();
        System.out.println(byte2.length);


        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byte2);
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        UserProtos.User object = (UserProtos.User) objectInputStream.readObject();
        // 反序列化打印对象信息
        System.out.println(object);

结果:
在这里插入图片描述
我们发现序列化后的字节数组的大小是472,对比protobuf,protobuf序列化后的结果小了将近7倍,这就是我们在分布式rpc框架通信时有的公司会选择使用protobuf的原因,因为小了7倍,在通信时减少了带宽,也提高了通信时的传输效率。

二、protobuf的原理分析

我们现在看看序列化的结果,我们现在分析一下这些值是怎么来的
在这里插入图片描述
protobuf 采用 T-L-V 作为存储方式
protobuf序列化原理及序列化选型总结_第2张图片
10 2 107 112 这个使我们存储的name 的值
10是tag 的值,至于为什么是10等下在说
2是length的值,107 112 显然长度是2
107 112 是表示value值,我们的name的值设置的是kp, 这里是用ASCII值存储的,k的ASCII值对应的是104,p对应的ASCII值是112

看下ASCII表:

16 -84 2 表示的age等于300的值
tag:16
length:在int中,length是省略的
value: -84 2

1.-84 2是怎么来的

正常来说,要达到最小的序列化结果,一定会用到压缩的技术,而 protobuf 里面用到了两种压缩算法,一种是varint,另一种是zigzag,其中zigzag 是存储负数时要用到的

age=300 这个数字是如何使用varint被压缩的
protobuf序列化原理及序列化选型总结_第3张图片
这两个字节字节分别的结果是:-84 、2
-84 怎么计算来的呢? 我们知道在二进制中表示负数的方法,高位设置为 1, 并且是对应数字的二进制取反以后再计算补码表示(补码是反码+1)
所以如果要反过来计算

  1. 【补码】10101100 -1 得到 10101011
  2. 【反码】01010100 得到的结果为 84. 由于高位是 1,表示负数所以结果为-84

大家肯定有个疑问,这里的结果为什么直接就是 ASCII 编码的值呢?怎么没有做压缩呢?
原因是,varint 是对字节码做压缩,但是如果这个数字的二进制只需要一个字节表示的时候(127及以下的数字), 其实最终编码出来的结果是不会变化的
例如:4 对应的二进制 是 0100 我们经过varint算法进行压缩,得出的字节还是0100。
127及以下的数字都是不需要压缩的,为什么是127?
因为超过127就要用到 就要用到1个字的第8个比特位。而varint是截取7位
例如,128 对应字节码是 1000 0000 通过 varint 压缩,就一定是2个字节 1000 0000 和0000 0001 对应的值就不是128了,所以超过127的都要被压缩

tag的计算方式
field_number(当前字段的编号) << 3 | wire_type
比如kp 的字段编号是 1(.proto文件中我们写的序号值) ,类型wire_type 的值为 2 所 以 : 1 <<3 | 2 =10 age=300 的字段编号是 2,类型wire_type 的值是 0, 所以 : 2<<3|0 =16

wire_type值是怎么来的,有一张对应表
protobuf序列化原理及序列化选型总结_第4张图片

2.负数怎么存储

在计算机中,负数会被表示为很大的整数,因为计算机定义负数符号位为数字的最高位,所以如果采用 varint 编码表示一个负数,那么一定需要 5 个字节。这句话怎么理解?
因为负数的高位是1,我们直接通过varint 压缩,一个int 是32 位,varint 是截取7位,4*7是28 还有4位需要多一个字节存储。所以在 protobuf 中通过sint32/sint64 类型来表示负数,负数的处理形式是先采用 zigzag 编码(把符号数转化为无符号数),在采用 varint 编码。

负数是首先是通过zigzag进行处理
sint32:(n << 1) ^ (n >> 31)
sint64:(n << 1) ^ (n >> 63)

比如: -300
原码:0001 0010 1100
取反:1110 1101 0011
加 1 :1110 1101 0100
n<<1: 整体左移一位,右边补 0 -> 1101 1010 1000
n>>31: 整体右移 31 位,左边补 1 -> 1111 1111 1111 n<<1 ^ n >>31
1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111
十进制: 0010 0101 0111 = 599
varint 算法: 从右往做,选取 7 位,高位补 1/0(取决于字节数) 得到两个字节
1101 0111 0000 0100
-41 、 4

总结
Protocol Buffer 的性能好,主要体现在 序列化后的数据体积小 & 序列化速度快,最终使得传输效率高,其原因如下:
序列化速度快的原因:
a. 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)
b. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成序列化后的数据量体积小(即数据压缩效果好)的原因:
a. 采用了独特的编码方式,如 Varint、Zigzag 编码方式等等
b. 采用T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑

三、序列化技术的选型

技术层面

  1. 序列化空间开销,也就是序列化产生的结果大小,这个影响到传输的性能

  2. 序列化过程中消耗的时长,序列化消耗时间过长影响到业务的响应时间

  3. 序列化协议是否支持跨平台,跨语言。因为现在的架构更加灵活,如果存在异构系统通信需求,那么这个是必须要考虑的

  4. 可扩展性/兼容性,在实际业务开发中,系统往往需要随着需求的快速迭代来实现快速更新, 这就要求我们采用的序列化协议基于良好的可扩展性/兼容性,比如在现有的序列化数据结构中新增一个业务字段,不会影响到现有的服务

  5. 技术的流行程度,越流行的技术意味着使用的公司多,那么很多坑都已经淌过并且得到了解决,技术解决方案也相对成熟

  6. 学习难度和易用性
    选型建议

  7. 对性能要求不高的场景,可以采用基于XML 的SOAP 协议

  8. 对性能和间接性有比较高要求的场景,那么Hessian、Protobuf、Thrift、Avro 都可以。

  9. 基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错

  10. Avro 设计理念偏于动态类型语言,那么这类的场景使用Avro 是可以的
    各个序列化技术的性能比较
    这个地址有针对不同序列化技术进行性能比较: https://github.com/eishay/jvm- serializers/wiki

你可能感兴趣的:(分布式基础,java)