最近在设计一个RPC框架,须要处理序列化的问题。有不少种序列化协议能够选择,好比Java原生的序列化协议,Protobuf, Thrift, Hessian, Kryo等等,这里说的序列化协议专指Java的基于二进制的协议,不是基于XML, JSON这种格式的协议。在实际开发中考虑了不少点,也遇到一些问题,拿出来讲说。java
抛开这些协议不说,结合实际的需求,一个理想的序列化协议至少考虑4个方面:算法
3. 是否支持被序列化对象新旧版本的兼容性问题。这个需求在实际开发中常常遇到,好比发布了一个服务,有不少客户端使用。当服务须要修改,新 添加1个参数时,不可能要求全部客户端都更新,那样牵扯的面太大,因此要作到新旧版本的兼容数组
4. 是否能够直接序列化对象,而不须要额外的辅助类,好比用IDL生成辅助的序列化类框架
前3个要求是衡量一个序列化协议好坏的重点,第4点是一个使用性的考虑,毕竟在不考虑跨平台调用的状况下,不须要使用IDL。使用IDL的开发方式通常是从IDL文件开始的,而不是直接从Java类开始。优化
序列化这件事说白了就是把一个对象变成一个二进制流,而后把二进制流再转化成对象的过程。前者好说,关键是后者,后者其实就是一个如何分帧(Frame)的问题,即从哪一个字节开始读几个字节来还原成数据的问题。常见的分帧方式有:this
1. 加结束符,好比http协议spa
2. 定长设计
3. 消息头+消息,消息头能够包含长度,类型信息code
对于Java序列化来讲,确定是第三种方式,可是如何设计这个分帧方式又有不少实现。下面说说上述的4个方面具体有哪些考虑和问题。对象
第一是序列化后的字节数大小。最优的序列化后的字节数大小确定是只有数据的二进制流,这样没有任何多余的分帧信息。若是要作到在二进制流里不加任何分帧信息来反序列化二进制流,有两个关键点:
我把这个双方约定分帧方式叫作契约。实际操做的时候只须要序列化方按照契约把对象的数据转成二进制流,反序列化方按照契约把二进制流转成对象数据。
若是二进制流里面不加任何的分帧信息,那么反序列化方只能按照字段的顺序来依次分帧。理解一下这句话,若是单纯拿到一个只有纯数据的二进制流,那么只能按照约定的顺序依次来读取,而且还得知道每一个字段的长度,这样才能知道读取几个字节来还原数据。在这里把顺序自己做为一个隐形的契约,双方按照顺序来读写。一旦顺序错了,就有可能发生反序列化的错误。
第二点,必须有个地方存放这个分帧方式信息,并且双方都能拿到这个信息。咱们很天然而然想到被序列化对象的Class对象是最天然的选择,并且它还包含了字段的信息,Class.getDeclaredFields()能够返回类的全部实例字段。若是getDeclaredFields()方法返回的字段在任意JVM上都是一样的顺序,那么咱们岂不就是能够指依靠序列化反序列化双方拿到被序列化的Class对象,而后利用反射机制拿到字段信息就能够实现最优的序列化后字节数大小吗?
可是通过个人调研发现,利用反射技术Class.getDeclared()方法返回的字段数组是没有排序也没有特定顺序的,好比按照声明的顺序。
/** * Returns an array of {@code Field} objects reflecting all the fields * declared by the class or interface represented by this * {@code Class} object. This includes public, protected, default * (package) access, and private fields, but excludes inherited fields. * <strong><span style="color:#FF0000;">The elements in the array returned are not sorted and are not in any * particular order</span></strong>. This method returns an array of length 0 if the class * or interface declares no fields, or if this {@code Class} object * represents a primitive type, an array class, or void. * * <p> See <em>The Java Language Specification</em>, sections 8.2 and 8.3. * * @return the array of {@code Field} objects representing all the * declared fields of this class * @exception SecurityException * If a security manager, <i>s</i>, is present and any of the * following conditions is met: * * <ul> * * <li> invocation of * {@link SecurityManager#checkMemberAccess * s.checkMemberAccess(this, Member.DECLARED)} denies * access to the declared fields within this class * * <li> the caller's class loader is not the same as or an * ancestor of the class loader for the current class and * invocation of {@link SecurityManager#checkPackageAccess * s.checkPackageAccess()} denies access to the package * of this class * * </ul> * * @since JDK1.1 */ @CallerSensitive public Field[] getDeclaredFields() throws SecurityException { // be very careful not to change the stack depth of this // checkMemberAccess call for security reasons // see java.lang.SecurityManager.checkMemberAccess checkMemberAccess(Member.DECLARED, Reflection.getCallerClass(), true); return copyFields(privateGetDeclaredFields(false)); }
那不能利用反射技术得到字段顺序,能不能利用字节码技术来得到这个类声明时存放的字段顺序呢?好比用ASM来直接读Class文件。可是我查阅了Java虚拟机规范,虚拟机规范只规定了Class文件中的元素,并无要求实际存储的Filed[]按照声明顺序存储。这也是对的,实际的虚拟机实现能够按照各自的算法来优化。
事实上目前没有哪一个协议作到最优的序列化后字节数,间接证实了只使用Class元数据来分帧是不能知足全部平台的,是不可靠的。
既然顺序这种弱契约关系不可靠,那么须要一种强契约关系,须要把一些分帧信息加入到二进制流,而后经过某种方式来获取这些分帧信息。加入哪些分帧信息和如何共享这些分帧信息有几种作法:
1. Java原生的序列化协议把字段类型信息用字符串格式写到了二进制流里面,这样反序列化方就能够根据字段信息来反序列化。可是Java原生的序列化协议最大的问题就是生成的字节流太大
2. Hessian, Kryo这些协议不须要借助中间文件,直接把分帧信息写入了二进制流,而且没有使用字符串来存放,而是定义了特定的格式来表示这些类型信息。Hessian, Kryo生成的字节流就优化了不少,尤为是Kryo,生成的字节流大小甚至能够优于Protobuf.
3. Protobuf和Thrift利用IDL来生成中间文件,这些中间文件包含了如何分帧的信息,好比Thrift给每一个字段生成了元数据,包含了顺序信息(加了id信息),和类型信息,实际写的二进制流里面包含了每一个字段id, 类型,长度等分帧信息。序列化方和反序列化方共享这些中间文件来进行序列化操做。
Hessian, Kryo, Protobuf, Thrift在生成的字节数都有了优化,而且能够只发送部分设置了值的字段信息来完成序列化,这样节省的字节数就更多了。可是还有些问题:
1. Hessian, Kryo不知足第三个方面,支持被序列化对象的新旧版本兼容,只依靠Class信息没有办法知道新旧Class的区别
2. Protobuf和Thrift已经很优化了,可是须要用IDL来生成静态的中间文件。
第二个方面考量序列化和反序列化效率,算法越简单固然效率就越高。实际的对比来讲,Kryo, Protobuf > Thrift > Hessian > Java原生序列化协议
第三方面是个重要考量,好比服务方给方法的参数新增长了一个字段,要能作到老的客户端还可使用这个新服务。这就要求序列化协议读取到不能识别的字段后可以处理异常。好比Thrift能够经过字段的id信息来知道是否支持这个字段,若是不支持读取,就跳过,从而作到新旧版本的兼容。而Kryo这种不依赖中间文件的协议很难作到这点,由于单纯的Class信息在不一样的平台下字段顺序是不肯定的,而且同一个Java文件在不一样平台下编译后的Class文件中,字段信息也是不肯定的。
第四方面,不依赖中间文件来序列化并同时知足前3点,从上面的分析来看很难作到。Protobuf和Thrift这种使用IDL来生产中间文件的协议,除了从跨平台调用的角度的须要,也包含了序列化的须要。
目前我尚未看到同时知足4个方面的序列化协议,上面的分析不少是本身的思考,可能有不对的地方,多交流。后面会陆续分析几种协议的实现。