从零实现RPC框架之 5.序列化

序列化:

通信协议结构

序列化以及编解码属于七层网络中的表示层

序列化和反序列化的选型却是系统设计或重构一个重要的环节,在分布式、大数据量系统设计里面更为显著。恰当的序列化协议不仅可以提高系统的通用性、强健性、安全性、优化系统性能,而且会让系统更加易于调试、便于扩展

典型的序列化和反序列化过程往往需要如下组件:

  • IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(Specifications)。为了建立一个与语言和平台无关的约定,这个约定需要采用与具体开发语言、平台无关的语言来进行描述。这种语言被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件。
  • IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库。
  • Stub/Skeleton Lib:负责序列化和反序列化的工作代码。Stub是一段部署在分布式系统客户端的代码,一方面接收应用层的参数,并对其序列化后通过底层协议栈发送到服务端,另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层;Skeleton部署在服务端,其功能与Stub相反,从传输层接收序列化参数,反序列化后交给服务端应用层,并将应用层的执行结果序列化后最终传送给客户端Stub。
  • Client/Server:指的是应用层程序代码,他们面对的是IDL所生存的特定语言的class或struct。
  • 底层协议栈和互联网:序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议转换成数字信号在互联网中传递。

img

序列化的考量:

分别从通用性、易用性、可扩展性、性能和数据类型与Java语法支持五方面给出对比测试。

  • 通用性:通用性是指序列化框架是否支持跨语言、跨平台
  • 易用性:易用性是指序列化框架是否便于使用、调试,会影响开发效率。
  • 可扩展性:随着业务的发展,传输实体可能会发生变化,但是旧实体有可能还会被使用。这时候就需要考虑所选择的序列化框架是否具有良好的扩展性。
  • 性能:序列化性能主要包括时间开销和空间开销。序列化的数据通常用于持久化或网络传输,所以其大小是一个重要的指标。而编解码时间同样是影响序列化协议选择的重要指标,因为如今的系统都在追求高性能。
  • Java数据类型和语法支持:不同序列化框架所能够支持的数据类型以及语法结构是不同的。这里我们要对Java的数据类型和语法特性进行测试,来看看不同序列化框架对Java数据类型和语法结构的支持度。

常见的序列化

下面提到的都是基于二进制的序列化协议,像 JSON 和 XML这种属于文本类序列化方式。虽然 JSON 和 XML可读性比较好,但是性能较差,一般不会选择。

市面上常见开源二进制序列化方式:

JDK Serializable、FST、Kryo、Protobuf、Thrift、Hession和Avro

阿里云已经对其进行过了测试,详见几种Java常用序列化框架的选型与对比

三 总结
1 通用性

下面是从通用性上对比各个序列化框架,可以看出Protobuf在通用上是最佳的,能够支持多种主流变成语言。

image.png

2 易用性

下面是从API使用的易用性上面来对比各个序列化框架,可以说除了JDK Serializer外的序列化框架都提供了不错API使用方式。

image.png

3 可扩展性

下面是各个序列化框架的可扩展性对比,可以看到Protobuf的可扩展性是最方便、自然的。其它序列化框架都需要一些配置、注解等操作。

image.png

4 性能

序列化大小对比

对比各个序列化框架序列化后的数据大小如下,可以看出kryo preregister(预先注册序列化类)和Avro序列化结果都很不错。所以,如果在序列化大小上有需求,可以选择Kryo或Avro。

image.png

序列化时间开销对比

下面是序列化与反序列化的时间开销,kryo preregister和fst preregister都能提供优异的性能,其中fst pre序列化时间就最佳,而kryo pre在序列化和反序列化时间开销上基本一致。所以,如果序列化时间是主要的考虑指标,可以选择Kryo或FST,都能提供不错的性能体验。

image.png

5 数据类型和语法结构支持

各序列化框架对Java数据类型支持的对比:

image.png

注:集合类型测试基本覆盖了所有对应的实现类。

List测试内容:ArrayList、LinkedList、Stack、CopyOnWriteArrayList、Vector。
Set测试内容:HashSet、LinkedHashSet、TreeSet、CopyOnWriteArraySet。
Map测试内容:HashMap、LinkedHashMap、TreeMap、WeakHashMap、ConcurrentHashMap、Hashtable。
Queue测试内容:PriorityQueue、ArrayBlockingQueue、LinkedBlockingQueue、ConcurrentLinkedQueue、SynchronousQueue、ArrayDeque、LinkedBlockingDeque和ConcurrentLinkedDeque。
下面根据测试总结了以上序列化框架所能支持的数据类型、语法。

注1:static内部类需要实现序列化接口。
注2:外部类需要实现序列化接口。
注3:需要在Lambda表达式前添加(IXxx & Serializable)。
由于Protobuf、Thrift是IDL定义类文件,然后使用各自的编译器生成Java代码。IDL没有提供定义staic内部类、非static内部类等语法,所以这些功能无法测试。

NettyRPC中的序列化

实现的两种序列化

从上面的比较结果来看kryo在序列化和反序列化的时间开销上占优,因此对于java栈来说如果追求极致的性能可以采用 kryo,但是kryo的通用性上并不佳,并不支持其他语言,没有定义语言描述接口IDL。同时扩展性也不佳。

如果需要通用性更好的 跨语言调用的可以采用protocol buffer。

Kryo序列化

@Slf4j
public class KryoSerializer implements Serializer {

    /**
     * Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
     */
    private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
        Kryo kryo = new Kryo();
        kryo.register(RpcResponse.class);
        kryo.register(RpcRequest.class);
        return kryo;
    });

    @Override
    public byte[] serialize(Object obj) {
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
             Output output = new Output(byteArrayOutputStream)) {
            Kryo kryo = kryoThreadLocal.get();
            // Object->byte:将对象序列化为byte数组
            kryo.writeObject(output, obj);
            kryoThreadLocal.remove();
            return output.toBytes();
        } catch (Exception e) {
            throw new SerializeException("Serialization failed");
        }
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> clazz) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
             Input input = new Input(byteArrayInputStream)) {
            Kryo kryo = kryoThreadLocal.get();
            // byte->Object:从byte数组中反序列化出对对象
            Object o = kryo.readObject(input, clazz);
            kryoThreadLocal.remove();
            return clazz.cast(o);
        } catch (Exception e) {
            throw new SerializeException("Deserialization failed");
        }
    }

}

ps: kyro不是线程安全的 所以使用 ThreadLocal进行包装

比起jdk原生序列化快在哪里呢?

jdk原生序列化需要实现Serializable接口,才能被jdk自己的序列化机制序列化,jdk序列化的时候,会将这个类和他的所有超类都元数据,类描述,属性,属性值等等信息都序列化出来,这样就导致序列化后的大小比较大,速度也会比较慢,但是包含的内容最全面。可以完全反序列化。

img

img

img

Kryo是怎么节约时间和空间开销的?

1.序列化的时候,会将对象的信息,对象属性值的信息等进行序列化,而且没有将类field的描述信息进行序列化,这样就比jdk自己的序列化出来的小多了,而且速度肯定更快,但是包含的信息没有jdk的全面。

2.可变长编码

类似下图

img

Kryo序列化原理

kryo快的原因详见源码分析kryo对象序列化实现原理

简单来说:

1、先序列化类型(Class实例),然后根据类型返回相应的序列化器(上一篇详细介绍了各种类型的序列化器)。

2、再序列化该类型的值。

3、如果自定义类型,例如(cn.uce.demo.Student),则返回的值序列化器为DefaultSerializers$FieldSerializer,然后一个字段一个字段的序列化,当然其序列化类型也是,先类型再值的模式,递归进行,最终完成。

4、引入了对象图的概念来消除循环依懒的序列化,已序列化的对象,在循环引用时,只是用一个int类型来表示该对象值,类似一种缓存的概念。

Hessian序列化

原理:序列化的时候,也是将对象的信息,属性值信息等进行序列化,也会比jdk自己的序列化后的小很多,但是没有kryo的小,速度也挺快,类似下图。

img

这两种的对比

1、Kryo序列化后比Hessian小很多。(kryo优于hessian)

2、由于Kryo没有将类field的描述信息序列化,所以Kryo需要以自己加载该类的filed。这意味着如果该类没有在kryo中注册,或者该类是第一次被kryo序列化时,kryo需要时间去加载该类(hessian优于kryo)

3、由于2的原因,如果该类已经被kryo加载过,那么kryo保存了其类的信息,就可以很快的将byte数组填入到类的field中,而hessian则需要解析序列化后的byte数组中的field信息,对于序列化过的类,kryo优于hessian。

4、hessian使用了固定长度存储int和long,而kryo则使用的变长,实际中,很大的数据不会经常出现。(kryo优于hessian)

5、hessian将序列化的字段长度写入来确定一段field的结束,而kryo对于String将其最后一位byte+x70用于标识结束(kryo优于hessian)

对于第二点 kryo也给出了解决方案:

那就是提供手动注册:

Kryo kryo = new Kryo();
kryo.register(SomeClass.class);
// ...
Output output = ...
SomeClass someObject = ...
kryo.writeObject(output, someObject);

这里,SomeClass 注册到了 Kryo,它将该类与一个 int 型的 ID 相关联。当 Kryo 写出 SomeClass 的一个实例时,它会写出这个 int ID。这比写出类名更有效。在反序列化期间,注册的类必须具有序列化期间相同的 ID 。上面展示的注册方法分配下一个可用的最小整数 ID,这意味着类被注册的顺序十分重要。注册时也可以明确指定特定 ID,这样的话注册顺序就不重要了:

  • Kryo 支持对注册行为,如 kryo.register(SomeClazz.class),这会赋予该 Class 一个从 0 开始的编号,但 Kryo 使用注册行为最大的问题在于,其不保证同一个 Class 每一次注册的号码相同,这与注册的顺序有关,也就意味着在不同的机器、同一个机器重启前后都有可能拥有不同的编号,这会导致序列化产生问题,所以在分布式项目中,一般关闭注册行为。或者指定该类的ID例如
Kryo kryo = new Kryo();
kryo.register(SomeClass.class, 10);
kryo.register(AnotherClass.class, 11);
kryo.register(YetAnotherClass.class, 12);

另一点是支持循环引用

kryo.setReferences(true);// 支持循环引用
  • 还有需要注意的就是 Kryo 对循环引用的支持。References即引用,对A对象序列化时,默认情况下 Kryo会在每个成员对象第一次序列化时写入一个数字,该数字逻辑上就代表了对该成员对象的引用,如果后续有引用指向该成员对象,则直接序列化之前存入的数字即可,而不需要再次序列化对象本身。

    而 “循环引用” 是指,假设有一个 JavaBean,假设是一个销售订单(SalesOrder),这个订单下面有很多子订单,比如 List ,而销售子订单中又有其中一个包括一个销售订单,那么这就构成了”循环引用”。Kryo 默认是支持循环引用的,当你确定不会有循环引用发生的时候,可以通过 kryo.setReferences(false); 关闭循环引用检测,从而提高一些性能。关闭后虽然序列化速度更快,但是遇到循环引用,就会报 “栈内存溢出” 错误。

    所以,我并不认为关闭它是一件好的选择,大多数情况下,请保持kryo.setReferences(true)

在学习dubbo的时候看到,dubbo默认的序列化方式是 hessian2序列化( hessian是一种跨语言的高效二进制序列化方式。但这里实际不是原生的hessian2序列化,而是阿里修改过的hessian lite,它是dubbo RPC默认启用的序列化方式),而再看dubbox的时候看到dubbox 引入Kryo和FST这两种高效Java序列化实现,来逐步取代hessian2。( 其中,Kryo是一种非常成熟的序列化实现,已经在Twitter、Groupon、Yahoo以及多个著名开源项目(如Hive、Storm)中广泛的使用。

ProtocalBuffer序列化

ProtocalBuffer为了类的扩展性,会先将类的变异成指定的IDL

message Person {
  string name = 1;
  int32 id = 2;
}

由于Protobuf的易用性,它的哥哥 Protostuff 诞生了。

protostuff 基于Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。

这里直接使用了java已有的工具类protostuff 不用去编写IDL

public class ProtostuffSerializer implements Serializer {

    /**
     * Avoid re applying buffer space every time serialization
     */
    private static final LinkedBuffer BUFFER = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);

    @Override
    public byte[] serialize(Object obj) {
        Class<?> clazz = obj.getClass();
        Schema schema = RuntimeSchema.getSchema(clazz);
        byte[] bytes;
        try {
            bytes = ProtostuffIOUtil.toByteArray(obj, schema, BUFFER);
        } finally {
            BUFFER.clear();
        }
        return bytes;
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> clazz) {
        Schema<T> schema = RuntimeSchema.getSchema(clazz);
        T obj = schema.newMessage();
        ProtostuffIOUtil.mergeFrom(bytes, obj, schema);
        return obj;
    }
}

ProtocalBuffer原理:

原理详见 Protocol Buffer原理解密

  1. 序列化的时候,不序列化key的name,只序列化key的编号
  2. 序列化的时候,没有赋值的key,不参与序列化,反序列化的时候直接使用默认值填充
  3. 可变长度编码,减小字节占用
  4. TLV编码,去除没有的符号,使数据更加紧凑
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值