现在的位置: 首页 > 综合 > 正文

聊聊序列化(一)关于序列化协议的一些思考和问题

2017年12月19日 ⁄ 综合 ⁄ 共 4122字 ⁄ 字号 评论关闭

最近在设计一个RPC框架,需要处理序列化的问题。有很多种序列化协议可以选择,比如Java原生的序列化协议,Protobuf, Thrift, Hessian, Kryo等等,这里说的序列化协议专指Java的基于二进制的协议,不是基于XML, JSON这种格式的协议。在实际开发中考虑了很多点,也遇到一些问题,拿出来说说。

抛开这些协议不说,结合实际的需求,一个理想的序列化协议至少考虑4个方面:

1. 序列化后的字节数大小

2. 序列化和反序列化的效率

3. 是否支持被序列化对象新旧版本的兼容性问题。这个需求在实际开发中经常遇到,比如发布了一个服务,有很多客户端使用。当服务需要修改,新 添加1个参数时,不可能要求所有客户端都更新,那样牵扯的面太大,所以要做到新旧版本的兼容

4. 是否可以直接序列化对象,而不需要额外的辅助类,比如用IDL生成辅助的序列化类

前3个要求是衡量一个序列化协议好坏的重点,第4点是一个使用性的考虑,毕竟在不考虑跨平台调用的情况下,不需要使用IDL。使用IDL的开发方式一般是从IDL文件开始的,而不是直接从Java类开始。

序列化这件事说白了就是把一个对象变成一个二进制流,然后把二进制流再转化成对象的过程。前者好说,关键是后者,后者其实就是一个如何分帧(Frame)的问题,即从哪个字节开始读几个字节来还原成数据的问题。常见的分帧方式有:

1. 加结束符,比如http协议

2. 定长

3. 消息头+消息,消息头可以包含长度,类型信息

对于Java序列化来说,肯定是第三种方式,但是如何设计这个分帧方式又有很多实现。下面说说上述的4个方面具体有哪些考虑和问题。

第一是序列化后的字节数大小。最优的序列化后的字节数大小肯定是只有数据的二进制流,这样没有任何多余的分帧信息。如果要做到在二进制流里不加任何分帧信息来反序列化二进制流,有两个关键点:

1. 确定具体的分帧方式

2. 肯定要有个地方存放这个分帧方式,并且是序列化方和反序列化方都能拿到

我把这个双方约定分帧方式叫做契约。实际操作的时候只需要序列化方按照契约把对象的数据转成二进制流,反序列化方按照契约把二进制流转成对象数据

如果二进制流里面不加任何的分帧信息,那么反序列化方只能按照字段的顺序来依次分帧。理解一下这句话,如果单纯拿到一个只有纯数据的二进制流,那么只能按照约定的顺序依次来读取,并且还得知道每个字段的长度,这样才能知道读取几个字节来还原数据。在这里把顺序本身作为一个隐形的契约,双方按照顺序来读写。一旦顺序错了,就有可能发生反序列化的错误。

第二点,必须有个地方存放这个分帧方式信息,而且双方都能拿到这个信息。我们很自然而然想到被序列化对象的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个方面的序列化协议,上面的分析很多是自己的思考,可能有不对的地方,多交流。后面会陆续分析几种协议的实现。

抱歉!评论已关闭.