一 、背景介绍
1.1 选择序列化框架需要考虑哪个方面?
序列化与反序列化是我们日常数据持久化和网络传输中经常使用的技术,但是目前各种序列化框架让人眼花缭乱,不清楚什么场景到底采用哪种序列化框架。本文会将业界开源的序列化框架进行对比测试,分别从通用性、易用性、可扩展性、性能和数据类型与Java语法支持五方面给出对比测试。
- 通用性:通用性是指序列化框架是否支持跨语言、跨平台。
- 易用性:易用性是指序列化框架是否便于使用、调试,会影响开发效率。
- 可扩展性:随着业务的发展,传输实体可能会发生变化,但是旧实体有可能还会被使用。这时候就需要考虑所选择的序列化框架是否具有良好的扩展性。
- 性能:序列化性能主要包括时间开销和空间开销。序列化的数据通常用于持久化或网络传输,所以其大小是一个重要的指标。而编解码时间同样是影响序列化协议选择的重要指标,因为如今的系统都在追求高性能。
- Java数据类型和语法支持:不同序列化框架所能够支持的数据类型以及语法结构是不同的。这里我们要对Java的数据类型和语法特性进行测试,来看看不同序列化框架对Java数据类型和语法结构的支持度。
1.2实际场景
1.3 序列化协议位于TCP/IP 4层模型哪一层?
二、序列化框架
2.1 JDK Serializable
JDK Serializable
是Java自带的序列化框架,我们只需要实现java.io.Serializable
或java.io.Externalizable接口,就可以使用Java自带的序列化机制。实现序列化接口只是表示该类能够被序列化/反序列化,我们还需要借助I/O操作的ObjectInputStream
和ObjectOutputStream
对对象进行序列化和反序列化。
下面是使用JDK 序列化框架进行编解码的Demo:
/**
* 编码
*/
public static byte[] encoder(Object ob) throws Exception{
//用于缓冲字节数字
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
//序列化对象
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(ob);
byte[] result = byteArrayOutputStream.toByteArray();
//关闭流
objectOutputStream.close();
byteArrayOutputStream.close();
return result;
}
/**
* 解码
*/
public static <T> T decoder(byte[] bytes) throws Exception {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
T object = (T) objectInputStream.readObject();
objectInputStream.close();
byteArrayInputStream.close();
return object;
}
2.1.1 通用性
由于是Java内置序列化框架,所以本身是不支持跨语言序列化与反序列化。
2.1.2易用性
作为Java内置序列化框架,无序引用任何外部依赖即可完成序列化任务。但是JDK Serializable在使用上相比开源框架难用许多,可以看到上面的编解码使用非常生硬,需要借助ByteArrayOutputStream和ByteArrayInputStream才可以完整字节的转换。
2.1.3可扩展性
JDK Serializable中通过serialVersionUID
控制序列化类的版本,如果序列化与反序列化版本不一致,则会抛出java.io.InvalidClassException异常信息,提示序列化与反序列化SUID不一致。
java.io.InvalidClassException
: com.yjz.serialization.java.UserInfo; local class incompatible: stream classdesc serialVersionUID = -5548195544707231683, local class serialVersionUID = -5194320341014913710
上面这种情况,是由于我们没有定义serialVersionUID,而是由JDK自动hash生成的,所以序列化与反序列化前后结果不一致。
但是我们可以通过自定义serialVersionUID
方式来规避掉这种情况(序列化前后都是使用定义的serialVersionUID
),这样JDK Serializable就可以支持字段扩展了。
private static final long serialVersionUID = 1L;
具体例子如下:
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
public class RpcRequest implements Serializable {
private static final long serialVersionUID = 1905122041950251207L;
private String requestId;
private String interfaceName;
private String methodName;
private Object[] parameters;
private Class<?>[] paramTypes;
private RpcMessageTypeEnum rpcMessageTypeEnum;
}
2.1.4性能
JDK Serializable是Java自带的序列化框架,但是在性能上其实一点不像亲生的。下面测试用例是我们贯穿全文的一个测试实体。
public class MessageInfo implements Serializable {
private String username;
private String password;
private int age;
private HashMap<String,Object> params;
...
public static MessageInfo buildMessage() {
MessageInfo messageInfo = new MessageInfo();
messageInfo.setUsername("abcdefg");
messageInfo.setPassword("123456789");
messageInfo.setAge(27);
Map<String,Object> map = new HashMap<>();
for(int i = 0; i< 20; i++) {
map.put(String.valueOf(i),"a");
}
return messageInfo;
}
}
使用JDK序列化后字节大小为:432。光看这组数字也许不会感觉到什么,之后我们会拿这个数据和其它序列化框架进行对比。
我们对该测试用例进行1000万次序列化,然后计算时间总和:
同样我们之后会同其它序列化框架进行对比。
2.1.5 数据类型和语法结构支持性
由于JDK Serializable是Java语法原生序列化框架,所以基本都能够支持Java数据类型和语法。
2.2 Kyro框架
Kryo一个快速有效的Java二进制序列化框架,它依赖底层ASM库用于字节码生成,因此有比较好的运行速度。Kryo的目标就是提供一个序列化速度快、结果体积小、API简单易用的序列化框架。Kryo支持自动深/浅拷贝,它是直接通过对象->对象的深度拷贝,而不是对象->字节->对象的过程。
下面是使用Kryo进行序列化的Demo:
PS: 因为kryo不是线程安全的, 所以需要使用ThreadLocal来存储kryo对象.
private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.setRegistrationRequired(false);//不需要提前预注册类
return kryo;
});
public static byte[] encoder(Object object) {
Output output = new Output();
kryoLocal.get().writeObject(output,object);
output.flush();
return output.toBytes();
}
public static <T> T decoder(byte[] bytes) {
Input input = new Input(bytes);
Object ob = kryoLocal.get().readClassAndObject(input);
return (T) ob;
}
需要注意的是使用Output.writeXxx时候一定要用对应的Input.readxxx,比如Output.writeClassAndObject()要与Input.readClassAndObject()。
2.2.1 通用性
首先Kryo官网说自己是一款Java二进制序列化框架,其次在网上搜了一遍没有看到Kryo的跨语言使用,只是一些文章提及了跨语言使用非常复杂,但是没有找到其它语言的相关实现。
2.2.2易用性
在使用方式上Kryo提供的API也是非常简洁易用,Input和Output封装了你几乎能够想到的所有流操作。Kryo提供了丰富的灵活配置,比如自定义序列化器、设置默认序列化器等等,这些配置使用起来还是比较费劲的。
2.2.3可扩展性
Kryo默认序列化器FiledSerializer是不支持字段扩展的,如果想要使用扩展序列化器则需要配置其它默认序列化器。
比如:
private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.setRegistrationRequired(false);
kryo.setDefaultSerializer(TaggedFieldSerializer.class);
return kryo;
});
2.2.4 性能
使用Kryo测试上面的测试用例,Kryo序列化后的字节大小为172 ,和FST未经优化的大小一致。时间开销如下:
我们同样关闭循环引用配置和预注册序列化类,序列化后的字节大小为120,因为这时候类序列化的标识是使用的数字,而不是类全名。使用的是时间开销如下:
2.2.5 数据类型和语法结构支持性
Kryo对于序列化类的基本要求就是需要含有无参构造函数,因为反序列化过程中需要使用无参构造函数创建对象。
2.3 Protocol buffer
Protocol buffer是一种语言中立、平台无关、可扩展的序列化框架。Protocol buffer相较于前面几种序列化框架而言,它是需要预先定义Schema的。
下面是使用Protobuf的Demo:
(1)编写proto描述文件:
syntax = "proto3";
option java_package = "com.yjz.serialization.protobuf3";
message MessageInfo
{
string username = 1;
string password = 2;
int32 age = 3;
map<string,string> params = 4;
}
(2)生成Java代码:
protoc --java_out=./src/main/java message.proto
(3)生成的Java代码,已经自带了编解码方法:
//编码
byte[] bytes = MessageInfo.toByteArray()
//解码
MessageInfo messageInfo = Message.MessageInfo.parseFrom(bytes);
2.3.1通用性
protobuf设计之初的目标就是能够设计一款与语言无关的序列化框架,它目前支持了Java、Python、C++、Go、C#等,并且很多其它语言都提供了第三方包。所以在通用性上,protobuf是非常给力的。
2.3.2 易用性
protobuf需要使用IDL来定义Schema描述文件,定义完描述文件后,我们可以直接使用protoc来直接生成序列化与反序列化代码。所以,在使用上只需要简单编写描述文件,就可以使用protobuf了。
2.3.3 可扩展性
可扩展性同样是protobuf设计之初的目标之一,我们可以非常轻松的在.proto文件进行修改。
新增字段:对于新增字段,我们一定要保证新增字段要有对应的默认值,这样才能够与旧代码交互。相应的新协议生成的消息,可以被旧协议解析。
删除字段:删除字段需要注意的是,对应的字段、标签不能够在后续更新中使用。为了避免错误,我们可以通过reserved规避带哦。
message userinfo{
reserved 3,7; //在保留标签中,添加删除的字段标签
reserved "age","sex" //在保留字段中,添加删除的字段
}
protobuf在数据兼容性上也非常友好,int32、unit32、int64、unit64、bool是完全兼容的,所以我们可以根据需要修改其类型。
通过上面来看,protobuf在扩展性上做了很多,能够很友好的支持协议扩展。
2.3.4 性能
我们同样使用上面的实例来进行性能测试,使用protobuf序列化后的字节大小为 192,下面是对应的时间开销。
可以看出protobuf的反序列化性能要比FST、Kryo差一些。
2.3.5 数据类型和语法结构支持性
Protobuf使用IDL定义Schema所以不支持定义Java方法,下面序列化变量的测试:
注:List、Set、Queue通过protobuf repeated定义测试的。只要实现Iterable接口的类都可以使用repeated列表。
2.4 Hessian序列化框架
Hessian是caucho公司开发的轻量级RPC(Remote Procedure Call)框架,它使用HTTP协议传输,使用Hessian二进制序列化。
Hessian由于其支持跨语言、高效的二进制序列化协议,被经常用于序列化框架使用。Hessian序列化协议分为Hessian1.0和Hessian2.0,Hessian2.0协议对序列化过程进行了优化(优化内容待看),在性能上相较Hessian1.0有明显提升。
使用Hessian序列化非常简单,只需要通过HessianInput
和HessianOutput
即可完成对象的序列化,下面是Hessian序列化的Demo:
public static <T> byte[] encoder2(T obj) throws Exception{
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(bos);
hessian2Output.writeObject(obj);
return bos.toByteArray();
}
public static <T> T decoder2(byte[] bytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
Hessian2Input hessian2Input = new Hessian2Input(bis);
Object obj = hessian2Input.readObject();
return (T) obj;
}
2.4.1 通用性
Hessian与Protobuf、Thrift一样,支持跨语言RPC通信。Hessian相比其它跨语言PRC框架的一个主要优势在于,它不是采用IDL来定义数据和服务,而是通过自描述来完成服务的定义。目前Hessian已经实现了语言包括:Java、Flash/Flex、Python、C++、.Net/C#、D、Erlang、PHP、Ruby、Object-C。
2.4.2易用性
相较于Protobuf和Thrift,由于Hessian不需要通过IDL来定义数据和服务,对于序列化的数据只需要实现Serializable接口即可,所以使用上相比Protobuf和Thrift更加容易。
2.4.3可扩展性
Hession序列化类虽然需要实现Serializable接口,但是它并不受serialVersionUID影响,能够轻松支持字段扩展。
- 修改字段名称:反序列化后新字段名称为null或0(受类型影响)。
- 新增字段:反序列化后新增字段为null或0(受类型影响)。
- 删除字段:能够正常反序列化。
- 修改字段类型:如果字段类型兼容能够正常反序列化,如果不兼容则直接抛出异常。
2.4.4 性能
使用Hessian1.0协议序列化上面的测试用例,序列化结果大小为277。使用Hessian2.0序列化协议,序列化结果大小为178。
序列化化与反序列化的时间开销如下:
可以看到Hessian1.0的无论在序列化后体积大小,还是在序列化、反序列化时间上都比Hessian2.0相差很远。
2.4.5 数据类型和语法结构支持
由于Hession使用Java自描述序列化类,所以Java原生数据类型、集合类、自定义类、枚举等基本都能够支持(SynchronousQueue不支持),Java语法结构也能够很好的支持。
三、参考资料
- 云烨。微信公众号:阿里开发者 几种Java常用序列化框架的选型与对比
- JavaGuide 从零开始手把手教你实现一个简单的RPC框架。