一次反序列化内部类导致的问题排查过程

2,788 阅读8分钟

前文

  本文是笔者首次发文,也许表达和排版上有些许的不妥,希望读者见谅。发文的目的也是为了和大家一起探讨,并且做一些记录,希望大家在阅读过程中发现错误之处可以麻烦评论帮忙指正,非常感谢~

本文相关超简易demo的git地址:git@github.com:Kwin1113/redis_abstract.git

正文

  在后端项目中,缓存应该是老生常谈的一个东西,说到提高接口并发、优化项目性能,估计第一个想起的就是缓存。不管是面试题或者平时开发,都是经常遇到。而在最近的开发中,我碰到了一个问题。

  场景是这样子的,一个简单的查询接口,相关业务逻辑是从数据库中查询,然后将数据进行简单的处理之后分页返回,并且service层返回结果使用redis做了缓存。因为业务原因,数据分页并不是通过dao层直接进行分页操作,而是将数据查询出来之后在业务层做的分页,相关的分页工具类是自己写的一个抽象类。

  问题就出在这个工具类上,当时使用这个工具类时,没有通过子类继承的方式而是通过匿名内部类的双花括号方式进行实例化。部分代码如下,其中无关的相关代码已隐藏。

@Data
public abstract class PageEntity<T> {
    /** 分页相关字段 */
    private Integer currentPage, pageSize, pageNum, total, index;
    /** 排序相关字段 */
    private String orderBy;
    private OrderType order;
    /** 数据 */
    private List<T> data;

    public abstract Class<T> findTClass();

    /**
     * 排序
     * @param orderByProperty 排序字段
     */
    private void sort(String orderByProperty) {
        // 通过反射获取类中排序字段对应的get方法进行排序...
        // 其中通过{@code #findTClass()}方法来获取该分页工具操作的具体类类型,{@code #findTClass()}方法委托给具体子类实现 (模板方法)
    }

    /** 排序类型枚举类 */
    public enum OrderType {
        ASC,
        DESC,;
    }
}

  其中相关业务代码简化如下:

@Service
public class EventService {

    private final RedisTemplate<String, Object> redisTemplate;

    public EventService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public PageEntity<Event> getEvent() {
        String key = "pageEntity";
        PageEntity<Event> result = (PageEntity<Event>) redisTemplate.opsForValue().get(key);
        if (null == result) {
            result = new PageEntity<Event>() {
                @Override
                public Class<Event> findTClass() {
                    return Event.class;
                }
            };
            // 查询数据 - 简单处理
            // result.setData(datas);
            redisTemplate.opsForValue().set(key, result);
        }
        return result;
    }

}

  整一个简单的测试类来测试一下:

@SpringBootTest
@RunWith(SpringRunner.class)
public class EventServiceTest {

    @Resource
    private EventService eventService;

    @Test
    public void abstractServiceMethod() {
        PageEntity<Event> event = eventService.getEvent();
        Assert.assertEquals(event.toString(), Event.class.getCanonicalName());
    }

}

  这个流程应该还是好理解的,就是最简单的查询数据接口。执行下单测,显然是能成功的。

  第一次执行该逻辑,并且将相关的pageEntity类缓存到redis中,理所应当没问题,那么我们再执行一次单测。

  然而这次失败了,我们来看看异常信息。

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean
 at [Source: (byte[])"["xyz.kwin.redisabstract.service.EventService$1",{"currentPage":null,"pageSize":null,"pageNum":null,"total":null,"index":null,"orderBy":null,"order":null,"data":null}]"; line: 1, column: 50]; nested exception is com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean
 ...
 ...
Caused by: com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean
 at [Source: (byte[])"["xyz.kwin.redisabstract.service.EventService$1",{"currentPage":null,"pageSize":null,"pageNum":null,"total":null,"index":null,"orderBy":null,"order":null,"data":null}]"; line: 1, column: 50]
 ...
 ...
Caused by: java.lang.IllegalArgumentException: Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean
 ...
 ...

  很明显,从redis中读取到相关json字符串反序列化成java对象时报错了(RedisTemplate的valueSerializer为jackson2JsonRedisSerializer),报了Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean。

  我们来分析一下,这个反序列化的类型按照逻辑来说,我们缓存下来的是PageEntity类,应该是xyz.kwin.redisabstract.util.PageEntity类型才对,为什么是xyz.kwin.redisabstract.service.EventService$1这个类型呢(redis中保存的相关缓存如图)。

  分析过程中,因为之前没有说通过这种方式实例化一个抽象类并做缓存的,笔者就当作是抽象类的原因暂时先通过具体子类继承来解决了这个问题。当晚回家之后和小伙伴一起讨论了一下这个问题,恰巧小伙伴前几天刚看了内部类相关的知识点,印象非常深刻(就是这么恰巧),一眼识破天机——EventService$1,这个类不就是EventService类中的第一个匿名内部类吗。这时我回头一看相关异常信息(of type local/anonymous) ,敢情抛出异常已经给我明明白白写着异常原因了,我就当没看见...很明显,这种实例化抽象类的方法产生了一个匿名内部类,而这个相关的匿名内部类名称即为EventService$1。但是当我在编译好的class文件中寻找该匿名内部类的class文件时,却并不能找到该类的相关类信息,那么此时问题的原因想当然的被我认为是反序列化时无法找到具体类型。

  至此这件事情又稍稍有了些进展,但我又开始好奇,为什么这个内部类会没有呢。于是我写了个最简单的demo来验证。

public class AnonymousInnerClass {

    public void test() {
        Thread t = new Thread() {
            @Override
            public void run() {
                super.run();
            }
        };
    }
    
}

  这就是一个最典型的匿名内部类的使用方法,使用IDEA的Build Project之后我们来看一下该类输出的目录。

  非常凑巧,在编译输出class的路径下,果然无法找到AnonymousInnerClass$1.class文件,那么这个问题似乎已经找到答案了?但当我通过命令行使用javac直接对AnonymousInnerClass.java文件进行编译时,情况又不同了!

  编译后在该java文件所在的目录下成功生成了外部类和内部类的class文件。于是这个问题又稍稍往前推进了一小步,又卡住了。接下来的一天中,因为抱着这个疑问,在上班的摸鱼时间中,忍不住在各大博客论坛上查看相关的文章,其中有一篇关于匿名内部类文章的某个评论吸引了我(具体是哪篇文章已经消失在历史记录中了...),该评论写到他在IDE工具中查看匿名内部类编译后的class文件,只能找到其外部类的class对象,但当他在资源管理器中查看该项目结构时,却发现了带有$1字样的匿名内部类class文件。心里偷偷把这个评论内容记下来之后,回头就把这回事忘了。但回到家之后重新看回这个问题,突然想起这个评论,于是直接打开访达,刷刷刷找到demo输出的target目录往下找。

  果不其然,匿名内部类的class文件是存在的,甚至EventService$1.class也是好端端的躺着。至于为何IDEA的target中无法显示,应该是IDE有他自己的想法(如果有巨巨知道的话麻烦评论区告诉我一下~)。

  这样一来,反序列化时无法找到匿名内部类class文件的说法也被推翻了。

  那么,很自然的,我的目光就转向了redis的序列化方式。

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // 设置key序列化方式
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        // 设置value序列化方式
  redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

}

  使用的RedisTemplate配置如上,最常规的配置,将value的序列化设置为Jackson2JsonRedisSerializer。

  对这方面的知识我也不是非常了解,于是面向google编程,在stack overflow上找到了相关信息。按该问题描述来看,这位哥们应该也是碰到了和我一样的问题。问题中提到Jackson可以将匿名内部类序列化,但无法进行反序列化,并且有人提出了较为合理的解释。

内部类实例需要其外部类实例对象来进行实例化,而Jackson在反序列化时无法创建其外部类实例对象

  我们把该匿名内部类的class文件放到IDE工具中打开。

  匿名内部类的class文件中可以看到,其只有一个构造器,并且入参是其外部类。那么当Jackson反序列化以上json时,自然是无法将其成功反序列化的(毕竟json字符串中没有保存外部类的类型)。

  至此,这个问题大概也有了一个结论:Jackson在将json字符串反序列化成java对象时,无法通过匿名内部类所提供的构造器来完成对象实例化。所以在反序列化时抛出了Cannot deserialize Class xyz.kwin.redisabstract.service.EventService$1 (of type local/anonymous) as a Bean异常信息。

  其实再仔细的看看,抛出异常中可以看到,除了anonymous类,local类也是不行的,也就是局部内部类,因为局部内部类也持有其外部类的引用,

  那么当然,普通内部类也是不行的咯,它也是持有一个外部类的引用。

  这么说来,静态内部类应该可以吧?它没有持有外部类的引用。

  确实,静态内部类只有一个默认实现的无参构造器。当然,嘴上说说肯定不算数,还是通过单测来验证一下。

  验证demo这次写一个InnerClassService,这次我们把demo写得越简单越好,毕竟已经找到了原因所在。相关代码如下。

@Service
public class InnerClassService {

    private final RedisTemplate<String, Object> redisTemplate;

    public InnerClassService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Object anonymousInner() {
        String key = "anonymousInner";
        Object result = redisTemplate.opsForValue().get(key);
        if (null == result) {
            result = new PageEntity<Event>() {
                @Override
                public Class<Event> findTClass() {
                    return Event.class;
                }
            };
            redisTemplate.opsForValue().set(key, result);
        }
        return result;
    }

    public Object localInner() {
        @Data
        @AllArgsConstructor
        @NoArgsConstructor
        class A {
            private String name;
        }


        String key = "localInner";
        Object result = redisTemplate.opsForValue().get(key);
        if (null == result) {
            result = new A("localInner");
            redisTemplate.opsForValue().set(key, result);
        }
        return result;
    }

    public Object normalInner() {
        String key = "normalInner";
        Object result = redisTemplate.opsForValue().get(key);
        if (null == result) {
            result = new B("normalInner");
            redisTemplate.opsForValue().set(key, result);
        }
        return result;
    }

    public Object staticInner() {
        String key = "staticInner";
        Object result = redisTemplate.opsForValue().get(key);
        if (null == result) {
            result = new C("staticInner");
            redisTemplate.opsForValue().set(key, result);
        }
        return result;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class B {
        private String name;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class C {
        private String name;
    }

}

  接下来我们执行一下单测,毫无疑问,第一次请求全部成功。

  Redis中的数据正常缓存。接下来再执行一次单测,这次就是返回反序列化json字符串的结果了。如我们所料,4种内部类的反序列化只有静态内部类成功了。并且查看了错误信息,其他3个内部类的反序列化都是抛出与上面相同的异常。

  单测的结果证明我们的结论是正确的,Jackson在反序列化内部类时,无法成功反序列化内部类、局部内部类和匿名内部类,具体原因是因其持有外部类的引用而无法通过构造函数创建。而静态内部类因不持有外部类的引用可以正常反序列化(静态内部类只是把普通的类“隐藏”在外部类里罢了)。从编译后的class文件可以发现,3个无法被Jackson反序列化的内部类构造函数参数中会自动加上外部类的实例,感兴趣的小伙伴可以自行验证。

总结

  通过这次的问题排查,略微复习了一下java基础知识。

  四种内部类各自的特性:

  • (成员)内部类:持有外部类的一个引用,并且可以通过这个引用访问外部类实例对象的属性;并且该内部类必须通过外部类实例进行访问。
  • 局部内部类:存在方法中;持有外部类的一个引用。
  • 匿名内部类:通过双花括号的方式进行实例化;持有外部类对象的一个引用。
  • 静态内部类:不持有外部类对象的引用;相当于将普通的类隐藏于外部类中;可以直接通过Outer.Inner的方式访问。

扩展

  在stack overflow的问题中,题主和答主说到了Jackson、Kyro和XStream三种序列化方式,并且提出Kyro和XStream是可以正常序列化和反序列化内部类的(Kyro并不行)。

Kyro

  我们自定义一个KyroRedisSerializer(代码来自百度)。并且在RedisConfig中将RedisTemplate的valueSerializer替换成KyroRedisSerializer,再执行一下单测看看结果。

public class KyroRedisSerializer<T> implements RedisSerializer<T> {
    private static final Logger logger = LoggerFactory.getLogger(KyroRedisSerializer.class);

    public static final byte[] EMPTY_BYTE_ARRAY = new byte[0];

    private static final ThreadLocal<Kryo> kryos = ThreadLocal.withInitial(Kryo::new);

    private Class<T> clazz;

    public KyroRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException {
        if (t == null) {
            return EMPTY_BYTE_ARRAY;
        }

        Kryo kryo = kryos.get();
        kryo.setReferences(false);
        kryo.register(clazz);

        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
             Output output = new Output(baos)) {
            kryo.writeClassAndObject(output, t);
            output.flush();
            return baos.toByteArray();
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }

        return EMPTY_BYTE_ARRAY;
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (bytes == null || bytes.length <= 0) {
            return null;
        }

        Kryo kryo = kryos.get();
        kryo.setReferences(false);
        kryo.register(clazz);

        try (Input input = new Input(bytes)) {
            return (T) kryo.readClassAndObject(input);
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }

        return null;
    }
}

  第一次执行结果当然是全部都能通过。

  如下是第二次单测的执行结果,看上去像是都通过了,但点进单独的测试项目,仍然是抛出了相应的异常,但异常信息与Jackson不大相同。

  打断点查看反序列化结果,反序列化正常,这个异常似乎并不影响反序列化。

XStream

  貌似网上对该序列化方式的资料较少,笔者对该序列化方式也是完全不了解,甚至是第一次听说,偷个懒也就不费劲去尝试了,如果有读者有所了解可以帮忙在评论区留言补充~