论Hessian的各种坑爹骚操作

3,736 阅读28分钟

目前Java语言可选的序列化框架不要太多。但是真要选一个好用小巧跨语言性能还不算不差的,那么Hessian绝对是你的第一选择。

1.先看Hessian的几个坑

如果你觉得我推荐你使用Hessian,那么上来就一顿夸Hessian如何如何好,那么你就太天真了。这东西就像买房子,一个房子如何如何好,自然会有对应的销售告诉你,但是一个房子到底有什么坑,销售是绝对不会告诉的。

而,我要讲的那决定是在外面搜不到的干货!!!

2.jpg

不吹了先。

我们在决定使用一个框架的时候,最好先大概知道有哪些,然后再决定要不要用。不然很容易掉沟里。

1.jpg

先声明,下面关于Hessian的坑,是基于官方Hessian版本3.1.5的。其他官方版本或者其他开源维护版本(如sofa-hessian,dubbo-hessian-lite)可能也有,也可能已经修复。但这不重要了,主要我司用的是3.1.5,所以我们只看3.1.5的坑。

<dependency>
  <groupId>com.caucho</groupId>
  <artifactId>hessian</artifactId>
  <version>3.1.5</version>
</dependency>

我们先使用Hessian的API写一个通用的序列化和反序列化的通用类

public class HessianSerializer {

    SerializerFactory serializerFactory = new SerializerFactory();

    /**
     * 反序列化
     *
     * @param bytes
     * @return
     */
    public Object deserializeObject(byte[] bytes) {
        InputStream is = new ByteArrayInputStream(bytes);
        Hessian2Input h2in = new Hessian2Input(is);
        h2in.setSerializerFactory(serializerFactory);
        try {
            return h2in.readObject();
        } catch (Exception e) {
            // 简单异常处理,把CheckedException转换为RuntimeException
            throw new RuntimeException(e);
        } finally {
            try {
                h2in.close();
            } catch (IOException e) {
                // ignore
            }
        }
    }

    /**
     * 序列化
     * @param obj
     * @return
     */
    public byte[] serializeObject(Object obj) {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        Hessian2Output h2out = new Hessian2Output(os);
        h2out.setSerializerFactory(serializerFactory);
        try {
            h2out.writeObject(obj);
            h2out.flush();
        } catch (Exception e) {
            // 简单异常处理,把CheckedException转换为RuntimeException
            throw new RuntimeException(e);
        } finally {
            try {
                h2out.close();
            } catch (IOException e) {
                // ignore
            }
        }
        return os.toByteArray();
    }
}

1.1.基本类型反序列化错误导致ClassCastException

测试代码如下:

private static void testClassCast(){
    Byte b = Byte.MAX_VALUE;
    // 序列化Byte类型
    final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(b);
    final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
    System.out.println(newObject);
    // 反序列化后类型应该还是Byte,所以理论上这里可以反序列化
    Byte newB = (Byte)newObject;
}

实际运行

image.png

从报错信息上,newObject应该是Integer类型,在强制类型转换为Byte后报错,我们Debug确认一下

image.png

3.jpg

你是不是觉得很奇怪,这么简单的功能Hessian都能出错,这TM还能用吗?实际情况是,我司这个版本已经用了很久了。。。。

至于为什么,这里先不告诉你。嘿嘿

4.gif

1.2.Hessain支持枚举的序列化和反序列化吗?

关于枚举,阿里巴巴发布的《Java开发手册》中有这么一段关于枚举的强制要求

image.png

至于到底是为什么,可以参考一下孤尽大佬的回答

我们这里先不管是不是应该在在参数和返回值中使用枚举,我们先假定假设可以的话,枚举也没有变化的话,Hessian能正常序列化和反序列化枚举类型的值吗?

好了,直接上代码吧

// 先定义一个简单枚举类
public enum SimpleEnum {
    RPC, CONFIG
}
// 序列化后反序列化
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(SimpleEnum.CONFIG);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

结果显示是可以正常序列化和反序列化的

image.png

那是不是就可以说,Hessian是支持枚举类的呢?

继续上代码

//定义一个运算器的枚举类
public enum OperatorEnum {
    ADD("+") {
        @Override
        int calculate(int a, int b) {
            return a + b;
        }
    },
    SUBTRACT("-") {
        @Override
        int calculate(int a, int b) {
            return a - b;
        }
    };
    private String operator;
    OperatorEnum(String operator) {
        this.operator = operator;
    }
    // 定义个了抽象方法,用于每一个枚举去实现
    abstract int calculate(int a, int b);
}
final byte[] bytes2 = HESSIAN_SERIALIZER.serializeObject(OperatorEnum.ADD);
final Object newObject2 = HESSIAN_SERIALIZER.deserializeObject(bytes2);
System.out.println(newObject2);

运行看看呢

image.png

这到底是Why呢?

4.gif

1.3 继承情况下同名字段反序列化异常

下面,看一个继承情况下的奇怪问题

直接上代码,Child继承了Parent,且内部都有一个名字为id的String类型的字段

正常情况下,参数不应该这么定义。但是如果真的这么定义了,会出现很奇怪的情况。

// 先定义一个Parent父类,有一个字段id
public class Parent implements Serializable {
    private String id;
    public Parent(){
    }
    public Parent(String id){
        this.id = id;
    }
}
// 再定义一个子类继承Parent,同时有一个同名的字段id
public class Child extends Parent {
    private String id;
    public Child() {
    }
    public Child(String id) {
        super("parent-" + id);
        this.id = id;
    }
}
Child child = new Child("A");
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(child);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

看结果,神奇吗? image.png

被序列化的Child对象中,子类和父类的id字段都有值,但是反序列化后的Child对象只有子类有值,但是是错误的,父类中的id字段为空

4.gif

1.4 二维long数组竟然不能反序列化

直接上代码

// 定义了一个二维long数组
long[][] array = new long[][]{new long[]{1L,2L,3L},new long[]{4L,5L,6L}};
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(array);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

image.png

4.gif

1.5. Float反序列化后精度丢失,且类型不正确

直接上代码

Float f = 1.01f;
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(f);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

看一下执行结果

image.png

Float序列化后又反序列化后,不仅数值进度丢失,且类型反序列化为Double了

4.gif

不知道你阅读到这里是不是已经崩溃了,Hessian真的bug百出啊,这玩意能用吗?真实情况可能更为惨烈。因为上面的几个问题只是我列出的几个坑,其实还有很多,怕打击你们,这里先不说。

然而事实是,我司已经用了这个3.1.5版本的Hessian版本,用了很久很久了。你就说神奇不神奇

2.Hessian的基本原理是啥

要想知道Hessian的基本原理。那么我们首先得了解Hessian协议的定义以及Java实现的Hessian框架的原理。

2.1 Hessian协议

我们这里说的Hessian都是说的是Hessian2的协议Hessian1的协议我们这里不做讨论,早已经淘汰不用了。

注意:Hessian2的协议不是指Hessian客户端是2.x版本的,目前Java语言的Hessain官方版本3.x和4.x都支持Hessian2的协议Hessian1的协议。如果用Hessain2的协议,那么就使用Hessian2Input以及Hessian2Output;如果用Hessain1的协议,那么就使用HessianInput以及HessianOutput

对于Hessian而言,我觉得最大的特点就是自描述。而且,Hessian官网也把自描述作为Hessian协议的第一个设计目标了。

image.png

那啥叫自描述呢? 就是序列化和反序列化的时候不需要借助其他外部元数据。比如protobuf的proto定义文件,json反序列化的时候需要指定反序列化的类型定义。

题外话:Java自带的序列化方式其实也是自描述的。只是性能低,序列化后的字节流大,所以被淘汰了。

那Hessian是怎么做到自描述的呢?其实官网上都有,大家有兴趣可以瞅瞅Hessian2协议

对应的中文版Hessian2.0序列化协议翻译

其实,我懂的,你是不会打开的,就算打开,你也会立刻马上关掉的。 7.jpg

那我就用一句话来解释一下这个Hessian协议吧。你信吗?我知道的,你信。

整个Hessian协议的精髓就是下面这张码表

image.png

看到这,你是不是想说:这东西我要是能看懂还需要你!!!

那就举栗子吧

9.webp

我们来看一下两种类型的数据序列化后的二进制流(下面所有的二进制都用16进制来表示)

  1. 基本数据类型
  2. 对象类型

1.基本数据类型

如下图,类型为int值为11的数字序列化后的16进制码为x9B image.png 那为什么int类型的11序列化后为x9B(一个字节)?

这里就用到了我们上面提到的码表,我们发现x9B是在x80 - xbf这个范围内的。

x80 - xbf    	# one-octet compact int (-x10 to x3f, x90 is 0)

那反序列化的时候,真实的值就是x9B-x90 = B16进制 = 1110进制

是不是so easy呢?

10.webp

那为啥x90被当做0呢? 看一下Hessian对应官网,其实Hessian把整型分为4中类型:1字节,2字节,3字节,4字节。这样不同大小的整型就可以序列化为不同的字节数。这样也是为了节省空间。毕竟大多数整型其实都很小,没有必要都序列为4个字节。

image.png

所以,其实Hessian在序列化的时候是不区分Java中的int,byte,short类型的,这些类型对于Hessian来将其实都是整型。说到这,你是不是已经联想到了我们上面提到的ClassCastException的问题的根源了呢?

我们还是后面再聊这个问题。

  • 再看对象类型 直接看代码
// 定义个对象
public class Car implements Serializable {
    private Integer id;
    private String color;

    public Car(){

    }

    public Car(Integer id,String color){
        this.color = color;
        this.id = id;
    }

再序列化

image.png

我们截取一下序列化后的16进制表示

4F19636F6D2E6865737369616E2E746573742E706F6A6F2E4361729202696405636F6C6F726F909103726564

我就问你看一串你慌不慌!!!

11.jpg

不慌,我们直接上图。好了,你可以慢慢看了,让我先骄傲两分钟!!!

hessian协议详解.png

11.webp

我们看到一个普通对象序列化后,大体可以分为两个部分:

  1. 类型定义:包含类名,所有的字段名
  2. 对象中的值:每一个字段中的值

那我又不是机器,我是怎么知道这些二进制流的具体含义的呢?

其实很简单,基本操作就是:读取一个字节(就叫tag吧),然后查码表

比如,第一个字节是x4F(对应的可读字符是O),查码表得知是类型定义

x4f          # object instance ('O')

既然是类型定义,那么后面固定的程序:类名长度,类名,字段名长度,每一个字段的具体定义

这里定义各种长度,实际上是因为二进制流中没有分隔符,如果不定义长度的话,那么很难区分从哪到哪是对应的部分。如不定义类名长度的话,就不知道后面应该读多长的字节算是类名。这也算是编码中常用的编码技巧了。

处理完类型定义,后面就是对象中的具体的值。如第一个值x91,查码表得知是一字节整形,具体的值为x91-x90=x1=1

后面的一个是字符串,也是一样的,先读出长度,然后读取字节。

所以,识别这个二进制的具体含义(其实就是反序列化的过程),基本上就是一个查码表的过程。

这个里面还有一个比较特殊的东西:ref

ref有啥作用呢:

1.支持复杂的数据结构(递归,循环引用等数据结构)

2.通过消除重复字符串提高效率和缩小字节流

具体的ref可以分为下面三种类型:

  1. 值引用:包含map/object/list三种类型【支持复杂结构和消除重复对象】
  2. 类型定义(class definition)引用【消除重复字符串】
  3. 类型定义(class definition)引用【消除重复字符串】

其实上面Car对象序列化后的二进制流中已经有类型定义(class definition)的引用了,只是后面没有引用而已。

下面我们来看一个ref在值引用的作用。

直接上代码

public class OuterCar implements Serializable {
    private Car car1;
    private Car car2;

    public OuterCar(){}

    public OuterCar(Car car1,Car car2){
        this.car1 = car1;
        this.car2 = car2;
    }

然后序列化,比较特殊的是OuterCar中的car1和car2属性都是传的是同一个car

Car car = new Car(1,"red");
// 注意,这里的OuterCar中的car1和car2属性都是传的是同一个car
OuterCar outerCar = new OuterCar(car,car);
byte[] bytes = HESSIAN_SERIALIZER.serializeObject(outerCar);
String hexStr = byte2hex(bytes);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);

然后看一下序列化话后的字节流16进制表示

4F1E636F6D2E6865737369616E2E746573742E706F6A6F2E4F7574657243617292046361723104636172326F904F19636F6D2E6865737369616E2E746573742E706F6A6F2E4361729202696405636F6C6F726F9191037265644A01

我们具体看一下这个字节流的具体含义

hessian协议详解-ref.png

如上图:最左边的部分就是OuterCar的类型定义,右边的部分就是OuterCar中的两个属性car1car2的值。其中右边部分的左半边就是car1的值(也包含两部分:类型定义和属性值)。但是你会发现最右边的car2的值表示的字节流特别小,只有两个字节:x4A以及x01

我们查官方的码表,发现x4A竟然是日期类型。

image.png

3.jpg 但是我们查阅中文版的翻译发现,x4a是引用类型

image.png

1.jpg

所以,我猜测其实都是Hessian2协议,但是Hessain2协议肯定不是一成不变的,期间肯定演变了很多版本。这里估计还是老版本的协议。我们按照老协议看是对象应用类型,ref=1 所以OuterCarcar2属性直接从refmap中获取,key就是x01,也就是car1的值。

那其实你要是使用最新官方的版本4.0.66去序列化的话,就会发现最后两个字节就是x51,x91。也就是目前官方Hessian2协议定义的内容了。

image.png

这也提醒我们,虽然不同3.x和4.x版本都支持Hessain2协议,但是很可能两者是不兼容的。所以升级Hessian客户端版本一定要慎重。

题外话:其实Java语言Hessain 3.x客户端版本各不同的小版本之间也存在协议不兼容的情况。如3.1.5与3.1.3版本之间就存在不兼容的情况。

那总结一下Hessian序列化的套路:每一种要序列化的类型都有一个前置的byte(暂且称为tag吧)来表示该数据是什么类型的。有的tag就只表示类型(比如对象类型'O'),有的tag会把数据直接放在这个tag中(比如一字节的整数)。这个tag查Hessian的码表会唯一确定数据类型。这也是自描述的关键所在。然后又衍生了ref类型来支持复杂结构和消除重复字符串。

2.2 Java语言下Hessian客户端的实现原理

先看序列化再看反序列化

2.2.1 序列化

序列化比较简单:直接根据当前要序列化对象的Class类型找到对应的序列化器Serializer,然后就直接序列化就好。

image.png

序列化器按照类型可以分为三类:

  1. 基本类型的序列化器(如int,byte等对应的序列化器BasicSerializer
  2. JDK自带的类型序列化器(如BigDecimal对应的序列化器StringValueSerializer
  3. 用户自定义类型的序列化器JavaSerializer

而这3种序列化器,我们先着重了解一下JavaSerializer。简单看一下代码就知道,JavaSerializer构造函数中把当前类以及所有的父类中的所有字段以及序列化器储下来,然后实际序列化的时候,大体是先序列化类定义,然后调用每一个字段序列化器做序列化。当然这个里面还有ref相关的处理,我们先忽略。

2.2.2 反序列化

反序列化的基本套路就是先读取一个字节tag,然后查码表(在程序里面其实就是case when的条件分支语句),判断是什么类型,然后就继续读取具体的值。

这里我们重点看一下反序列化自定义对象的流程。

image.png 先读取类定义,然后根据类类名称来反射调用生成对应的Class对象,然后反射调用构造函数构造对应的Java对象,然后根据类定义,把对象中的所有属性一个个赋值。

也就会说O的case分支流程执行完,就有了类定义。然后开始执行o case分支中的流程,因为类定义已经放到ref中了,所以可以取到。然后就是根据类名获取对应的反序列化器。这里自定义对象的反序列器为JavaDeserializer

这里要注意一下ClassLoader的一些细节:从类名称到对应的Class对象用到的ClassLoader是从当前Thread中获取的。正常情况下,从当前线程中获取ClassLoader是没有上面问题的,但是如果应用中存在多ClassLoader,且深度定制ClassLoader的场景,需要特别注意:如果想使用特定的ClassLoader来生成Class对象就需要在线程上下文对象中设置对应的ClasLoader

image.png

当然其实Class.forName()调用只发生在第一次反序列化该类型的对象的时候,后面会有对应的缓存。当然有缓存是好的,但是我们要记住缓存中最为经典的问题就是缓存击穿,如果处理不好缓存击穿的问题,那么缓存就会失效。

那在这个场景下,我们的缓存啥时候会被击穿呢?

设想一下:我们在使用类名来生产Class的时候,如果我们的应用中没有对应的类定义,那我们这个缓存还生效吗?

看一下代码就知道了,实际上3.1.5版本的Hessian是没有处理好缓存击穿的问题的。如果类名对应的Class不存在(也就是1处抛出异常),那么2处的逻辑就不会执行,则经过3处的逻辑判断,4处的_cachedTypeDeserializerMap就不会把类名和类名对应的反序列化器缓存起来。那后面如果还是有对应的不存在的类名的时候就会反复调用Class.forName()方法,导致不必要的反射,且反射是相对比较耗费性能的,最终导致反序列化性能急剧下降。 image.png

这其实也算是Hessain中的一个坑吧,大家可以自行修复一下这个坑。其实还有很多多线程方面的坑,这里我就不聊了。太多了实在是!!!

我们继续聊自定义对象的反序列化器:JavaDeserializer

image.png

JavaDeserializer在内部存储了几个关键的东西:

  • 属性值名称为key,对应的反序列化器为Value的Map对象
  • readResolve()方法引用
  • 代价最小的构造函数(用来反射生产对应的对象)

其中这个readResolve()方法实际上是JDK中的自带的序列化协议相关的内容,看一下Serializable类的定义就能知道。这玩意实际上留给用户自定义反序列化用的。

image.png

那其实Hessian也对这个方法做了支持,就是如果用户自定义了readResolve()方法,那么就会调用readResolve()方法生产自定义的对象。那实际反序列化返回的就是readResolve()方法返回的对象了。

image.png

这个readResolve()方法后面我们会用来自定义序列化对象。

3.如何扩展Hessian的序列化和反序列化类型

在开始之前,我们先来问几个问题

1.JDK自带的BitSet类型可以用Hessian序列化和反序列化吗?

2.从JDK8开始支持的时间类型(如LocalDate,LocalDateTime,可以用Hessian序列化和反序列化吗?

3.google guava框架中支持的的ImmutableList可以用Hessian序列化和反序列化吗?

我们先看第一个问题,BitSet。直接上代码

private static void testBitSetCase(){
    BitSet bitSet = new BitSet();
    bitSet.set(1);
    bitSet.set(2);
    bitSet.set(3);
    final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(bitSet);

    final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);

    BitSet newBitSet = (BitSet) newObject;
    System.out.println(newBitSet.get(1));// actually false but expect true
    System.out.println(newBitSet.get(2));// actually false but expect true
    System.out.println(newBitSet.get(3));// actually false but expect true
}

image.png

从测试情况看,BitSet是可以正常序列化和反序列化的,但是反序列化后的值不正确。遵循fail-fast原则,快速失败要比错误的返回值更加有效。

那到底是为什么会出现这种情况呢? 我们debug看一下,发现序列化和反序列化前后的对象中的wordsInUse属性值是不一样的。 image.png

wordsInUse属性代表了BitSet中使用了几个long对象(因为BitSet底层的位信息实际是存储在属性为wordslong[]对象中,可能有扩容原因,实际使用的长度可能并没有words的实际长度大。也就是说可能需要一个字段来表明实际使用了多少long来表示位信息,也就是wordsInUse属性的作用)。反序列化后wordsInUse=0时,实际上BitSet,当wordsInUse=0时,实际上BitSet内部的状态是不一致的。实际上使用了1个long,但是wordsInUse=0呢?

那为什么wordsInUse=0呢?

看一下wordsInUse属性的定义,发现是transient类型的。 image.png

JDK在序列化的时候会自动忽略transient类型的字段的,那Hessian也沿用了这个规约。 看一下JavaSerializer,如果字段是transient类型的,该字段就不会序列化。 image.png

我再问一层,为什么JDK把wordsInUse属性定义为transient呢?

答:JDK就是这么设计的。

如果你要是这么回答的话,那只能说明你这装X的水平有待提高啊。

15.gif

其实答案其实就藏在谜题中。 image.png

BitSet类中定义了writeObject()方法,用在JDK序列化方式下,BitSet自定义序列化方式。其中BitSet认为它自己的所有状态数据只有words对象,其他的都不是必要的状态。

因为其他的状态都可以通过words对象计算出来。

image.png

如上图,BitSet还定义了readObject()方法,用在JDK反序列化的时候。其中你就会发现另外两个属性transient类型的wordsInUse以及sizeIsSticky是可以通过words对象计算出来的。那既然wordsInUse以及sizeIsSticky都可以通过words对象计算出来,那实际上就没有必要把wordsInUse以及sizeIsSticky作为状态对象了,这样还能减小二进制流,节省空间。

所以,虽然对象中有很多字段,但是实际上表示一个对象的状态不一定需要所有的字段来表示。有些字段是可以通过其他字段推导出来的。可能是为了性能,也可能是为了程序的简单化。总之,表示一个对象的状态,并不一定需要所有的字段的值。所以序列化的时候也不一定需要序列化所有的字段。

12.jpg

读到这,你是不是有点感觉走偏题了。牛逼你是吹了,但是还是没有解决BitSet如何在Hessian中序列化和反序列化。

我们简单分析一下:

  1. BitSet中真正需要序列化的字段只有long[]类型的words字段
  2. 如果序列化的时候只有long[]类型的words字段,那么反序列化的时候只能是long[]对象。那么如何从long[]对象映射为BitSet对象呢?也就是如何自定义反序列化方式呢?没错,就是readResolve方法。但是想使用readResolve方法,就必须得有一个壳子long[]对象方进来,然后readResolve方法利用long[]对象返回真实的BitSet对象。

具体如何做呢,直接参考dubbo-hessian-lite

如下:

image.png

image.png

那个壳子就是BitSetHandle。序列化的时候实际序列化的是BitSetHandle对象,其中有一个重要属性:long[]类型的words属性。但是反序列化的时候,由于BitSetHandle中定义了readResolve方法,所以反序列化的时候实际返回的就不是BitSetHandle对象了,而是readResolve方法返回的实际的BitSet对象了。

说白了,序列化的时候是BitSetHandle对象,但是反序列化的时候 ,利用套娃原理(readResolve方法),成功实现了BitSetHandle对象转为BitSet对象。

至于第二个问题,Java8时间类型,也可以利用套娃原理(readResolve方法)实现。至于具体怎么做,可以自己先想想看,然后也可以参考dubbo-hessian-lite

至于第三个问题,Guava中的ImmutableList可以用Hessian序列化和反序列化吗?这个就留着由你自己去探索了。不可以的话,是因为啥,怎样做才可以。可以的话是因为啥,有没有什么风险。

4.Hessian中的那些坑到底是因为啥?

好了,到这你是不是想骂娘了,尽知道吹牛逼,一开始的那些坑,你能不能先填上!!!

那就先一一安排上。

先介绍一下套路,先回滚一下,然后分析。

4.1 基本类型反序列化错误导致ClassCastException

private static void testClassCast(){
    Byte b = Byte.MAX_VALUE;
    // 序列化Byte类型
    final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(b);
    final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
    System.out.println(newObject);
    // 反序列化后类型应该还是Byte,所以理论上这里可以反序列化
    Byte newB = (Byte)newObject;
}

实际运行

image.png

下面来分析:序列化基本类型,我们知道用的是BasicSerializer,那这个类是如何序列化自己类型byte的呢?

直接上代码: image.png

发现了吗,TMD的直接不区分这个类型是int,byte还是short,全部按照int类型来序列化,那反序列化后当然也是int类型。所以才会导致ClassCastException

1.jpg

到这,你是不是想说,这个bug是不是太离谱了点啊。但是如何你仔细读一下Hessian的协议规范就知道了,其实这个bug主要是因为Hessian协议中只内置支持了8中原生类型。如下:

image.png

而Java中的byte,short是不在这8种类型之中的,所以Hessian框架在处理byte,short的时候就直接当做32-bit int这种原生类型来处理了。

那难道就没有办法修复这个bug了吗?

实际上是有办法的,就是套娃。大家自己想一下如何套娃。最新官方版本4.0.66是有对应的实现的,大家可以去瞅一下。

不过,对于基本类型,采用套娃的方式来解决,有点得不偿失。因为套娃的方式会把类型一也一起序列化。这增加了序列化后的字节流的大小。所以对于Hessian,建议直接使用int类型不要使用short以及byte。因为对于序列化场景,有可能序列化后的字节流不仅没有减小,更有可能增大不少。

4.2.Hessain支持枚举的序列化和反序列化吗?

从上面可知,Hessian在序列化和反序列化简单的枚举类时是没啥问题的,如下面这个简单的枚举类。

// 先定义一个简单枚举类
public enum SimpleEnum {
    RPC, CONFIG
}

但是在序列化和反序列化复杂的有抽象方法的枚举类的时候就会报错,如下就是有抽象方法的枚举类

//定义一个运算器的枚举类
public enum OperatorEnum {
    ADD("+") {
        @Override
        int calculate(int a, int b) {
            return a + b;
        }
    },
    SUBTRACT("-") {
        @Override
        int calculate(int a, int b) {
            return a - b;
        }
    };
    private String operator;
    OperatorEnum(String operator) {
        this.operator = operator;
    }
    // 定义个了抽象方法,用于每一个枚举去实现
    abstract int calculate(int a, int b);
}

实际反序列化的时候会报错,如下: image.png

想知道这个问题的原因,我们直接分析相对比较困难。我们直接看一下高版本的官方版本有没有这个问题。我们使用4.0.60这个版本,看一下,发现两种枚举都可以序列化和反序列化。那对于枚举,Hessian不同版本的差异在哪呢?

直接看源代码的对比,如下

image.png

使用对比工具看一下对于枚举类型的序列化和反序列化器的源代码的变化:

image.png

image.png

对比源代码发现,修改的地方都是跟枚举的继承有关系的。那我们复杂的枚举,之所以复杂其实就是因为用了抽象方法,也就是使用了继承的特性。

那具体是什么问题呢?简单debug对比看一下序列化时的不同点:

正常的版本(4.0.60image.png 异常的版本(4.0.60image.png

对比发现:有问题的版本序列化枚举类的时候用的子类的名字:com.hessian.test.enums.OperatorEnum$1,而正常版本在序列化的时候用的是自定义枚举父类的名字:com.hessian.test.enums.OperatorEnum

到这里,你可能会有疑问:我们明明序列化的是OperatorEnum.ADD对象,为什么获取的Class的名字是看上去是奇奇怪怪的com.hessian.test.enums.OperatorEnum$1。这个实际上就涉及到枚举的实现原理了。可以参考Java 枚举型为什么是静态的,以及是怎么实现的?

我们简单看下OperatorEnum类的Class文件,发下有3个Class文件

image.png

使用Javap命令简单看一下类结构:发现OperatorEnum继承的是java.lang.Enum的,而OperatorEnum$1继承的是OperatorEnum image.png

而反序列化的时候,高低版本的做法是一样的:找到序列化时用的Class,然后找到name属性,最后调用枚举基类(java.lang.Enum)中的valueOf(enumType,name)方法返回枚举类。

简单看一下java.lang.Enum#valueOf(Class,String)的实现,就是从一个名字词典中根据传入的名字来获取对应的枚举实例 image.png

下面,再看一下反序列化的逻辑: image.png

在反序列化的时候,实际上就是由于调用valueOf(enumType,name)方法传入的enumType不同造成了不同的结果。

image.png

从报错堆栈上看,出错的就是下面这一行。其实质就是枚举名字词典不正确。 image.png

所以,分析到这,就可以发现OperatorEnum中的枚举名字词典是正常的,而OperatorEnum$1中的枚举名字词典是空的。

那为什么OperatorEnum$1中的枚举名字词典是空的呢?而OperatorEnum中的枚举名字词典是正常的呢?

其实,答案都藏在字节码里面。

我们先看一下名字词典的实现:当第一次调用java.lang.Enum#valueOf(Class,String)方法时,会初始化名字词典。而初始化名字词典的方式实际上就是调用自定义枚举类Classvalues()方法 image.png

但是你查看远点,你会发现你自己没有定义values()方法,而且java.lang.Enum也没有定义values()方法。那这玩意到底是哪里来的。

我们直接查看字节码:OperatorEnum的字节码中是有values()方法的,直接返回的是名为$VALUES的数组clone()值。

image.png

再看$VALUES的数组是静态代码块中赋值的。 image.png

如果把这些字节码翻译为Java代码,大致如下:

public final class OperatorEnum extends Enum<OperatorEnum> {
	public static final OperatorEnum ADD;
	public static final OperatorEnum SUBTRACT;
	private static finalOperatorEnum[] $VALUES;
        
        static {
	    ADD = new OperatorEnum$1("+");
	    SUBTRACT = new OperatorEnum$2("-");
	    $VALUES = new OperatorEnum[]{ADD,SUBTRACT};
	}
    
        public static com.hessian.test.enums.OperatorEnum[] values() {
		return (OperatorEnum[])$VALUES.clone();
	}
    // 其他方法忽略...

}

OperatorEnum的子类OperatorEnum$1中是没有values() 方法的,且OperatorEnum$1类的Class在调用isEnum()方法的时候返回的是fasle,所以OperatorEnum$1类在JVM看来不是枚举,所以枚举名字词典直接返回null

image.png

OperatorEnum$1类在JVM看来之所以不是枚举类(isEnum()返回fasle),主要是因为其父类不是java.lang.Enum而是OperatorEnum

image.png

总结一下:低版本的Hessian序列化有继承关系的枚举的时候,序列化的Class名字就是子类枚举的Class名字,类似于xxxx$1。而反序列化的时候调用子类枚举的Class中的valueOf方法,在valueOf()方法中需要从枚举名字词典中根据枚举名称来获取对应的枚举实例,但是类似于xxxx$1Class中的枚举词典为空。

4.3 继承情况下同名字段反序列化异常

image.png 回顾一下这个问题:子类Child和父类Parent有同名属性id。反序列化之前Child中的id为"A",Parent中id为"parent-A"。序列化反序列化后,Child中的id为"parent-A",Parent中的idnull

15.gif

直接上代码:

序列化的时候,确实是有两个Filed要序列化的。 image.png

但是在写入类定义的时候,只写入了名称,且都是id,并且子类中的id写入到类定义,而父类中id写入到类定义中的。 image.png

在反序列化的时候,会为当前Class生产一个已属性名为key,序列化器为Value的Map。这个可以看到序列化器实际上是子类的序列化器。所以父类的id是永远不会有值的。也就是父类的id属性永远为null

image.png

为什么是子类的序列化器呢?如下,因为先从子类遍历属性,然后再遍历父类属性。当有同名属性的时候就跳过该循环。 image.png

那为什么子类的值编程了"Parent-A"呢?

如下:因为在字节流中实际上属性为id的有两个值,反序列化的是子类id的值,反序列化的是父类中的id的值 继承.png

总结一下,对于继承关系下的同名属性,Hessian在序列化的时候,类定义中属性名只有一个,但是属性值有两个。在反序列化的时候,反序列化器只会为同名的属性生成一个反序列化器,且是子类的反序列化器。所以类名的属性永远为null

题外话:这个问题需要修复吗?我觉得可以不修复,毕竟,相同的属性在值对象中没有必要重复定义。当然其实也是可以修复的,但是代价较大。

4.4 二维long数组竟然不能反序列化

直接上代码

// 定义了一个二维long数组
long[][] array = new long[][]{new long[]{1L,2L,3L},new long[]{4L,5L,6L}};
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(array);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

image.png

我们首先看一下高版本有没有这个问题,run一把,发现正常。那我们直接对比源代码看一下:

image.png

看到这,是不是想骂娘,这纯粹是写漏了啊!!! 6.gif

4.5. Float反序列化后精度丢失,且类型不正确

回滚一下坑:

Float f = 1.01f;
final byte[] bytes = HESSIAN_SERIALIZER.serializeObject(f);
final Object newObject = HESSIAN_SERIALIZER.deserializeObject(bytes);
System.out.println(newObject);

看一下执行结果

image.png

Float序列化后又反序列化后,不仅数值进度丢失,且类型反序列化为Double了。

直接看高版本有没有修复,发现已经修复,直接对比代码:

image.png

image.png

也是成功运用了套娃的技术修复了这个问题

5.总结

  1. Hessian是一种自描述的语言。字节流中的每部分的头一个字节就决定了其类型信息
  2. Hessian中最好不用shortbyte直接使用int,不使用float,直接使用double
  3. 如果想要自定义序列化器,掌握好套娃技术就好。
  4. Hessian低版本和高版本基本不兼容。低版本中的小版本也有可能不兼容。如果出现Bug,建议单独维护分支,而不是盲目升级版本。
  5. Hessian2的协议有过变更,低版本的Hessian2实现有可能是老的协议。