SpringRedisCache异常:InvalidTypeIdException

在springboot项目中,使用jackson作为redis序列化工具后,读取值可能会出现这个异常:InvalidTypeIdException: Missing type id when ...missing type id property '@class'。搞了一下午,发现是对kotlinjackson不熟导致的坑。

环境说明

我项目基于springboot搭建,用的是gradle构建工具,之前是纯java项目,后面看着kotlin挺方便的,又加上了kotlin,现在大部分代码为kotlin,准备用redis作为底层实现Cache功能。根据官网和网上的资料,配置好跑起来后,调用@CacheableCache注解的方法后,只能存,不能取,一取就报错。

初版配置

因为我这里使用jackson序列化代替默认的JDK序列化,所以需要配置一个RedisTemplate,最终发现问题就出在这个配置中,最开始代码如下(java代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@EnableCaching
@Configuration
public class RedisConfig {
//先配置一个redisTemplate
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

//使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值(默认使用JDK的序列化方式)
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
objectMapper.registerModule(javaTimeModule);
objectMapper.registerModule(new KotlinModule());
objectMapper.registerModule(new Jdk8Module());
//禁用注解支持,防止一些@ignore的字段被忽略
objectMapper.configure(MapperFeature.USE_ANNOTATIONS, false);
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

serializer.setObjectMapper(objectMapper);
template.setValueSerializer(serializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}

//再配置redisCache
@Bean
public RedisCacheConfiguration redisCacheConfiguration(RedisTemplate redisTemplate) {

RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();
//设置15分钟过期,值序列化为上面配置的jackson序列化
return redisCacheConfiguration.entryTtl(Duration.ofMinutes(15))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getValueSerializer()));
}
}

配置好了就使用Cache(kotlin代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TestUserKt{
var id:Int?=null
var name:String?=null
var pass:String?=null
}

@Service
class MyService{

@Cacheable(value = ["test-config"])
fun getUserById(id:Int):TestUserKt{
//...从数据库加载TestUserKt
return TestUserKt(id,"user:"+id,"pass:"+id)
}
}

在第二次调用MyService.getUserById(1)的时候,就出错了(第一次调用不会走Cache,因为Cache中还没有内容

运行结果

1
class java.util.LinkedHashMap cannot be cast to class xxx.TestUserKt

原因

网上搜索了一番资料,发现是因为springredis读取到指定key内容后,反序列化的时候,因为没有提供任何要反序列化对象的元信息,json里的object就默认反序列化为LinkedHashMap了。而在 RedisCache实现中,会强制转换成期望的对象(TestUserKt),所以导致报错。修复方式非常简单,即开启RedisTemplate.objectMapperDefaultTyping

二次配置

既然如此,那就开启DefaultTyping,配置如下(java代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
//....省略
ObjectMapper objectMapper = new ObjectMapper();
//...省略
//开启默认命名
//已过时,下面是新用法: objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance , ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
template.setValueSerializer(serializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}

DefaultTyping的功能是,在把对象序列化json时,把对象的类名称(包括包名)写入到json中,这样下次读取的时候,就可以根据类名称就反序列化成对象了,而不是Map

需要注意的是,老版本的objectMapper.objectMapper.enableDefaultTyping()方法已经过时,官方推荐用objectMapper.activateDefaultTyping()替代。

运行结果

讲道理,到这一步应该不会出什么幺蛾子了吧,万万没想到,跑起来又抛来个异常!!

1
2
3
org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Missing type id when trying to resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: (byte[])"{"id":1,"name":"user:1","pass":"pass:1"}"; line: 1, column: 40]; nested exception is com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Missing type id when trying to resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
at [Source: (byte[])"{"id":1,"name":"user:1","pass":"pass:1"}"; line: 1, column: 40]

原因

找这一次的原因的花了点时间,打开Redis Desktop Manager,找到程序存储的路径,发现Kotlin编写的类对象已经序列化了,但是没有包括对象的类信息:

1
2
3
4
5
{
"id": 1,
"name": "user:1",
"pass": "pass:1"
}

而Cache的普通java类对象也已经序列化,而且包括了类信息

1
2
3
4
5
{
"@class": "xxx.TestUser",
"name": "javaName",
"passwrod": "javaPass"
}

之所以会有这个差异,是因为默认kotlin的类最终会转成finaljava类。如class MyKotlinUser会转成public final class MyKotlinUser。还记得上面开启DefaultTyping的参数为ObjectMapper.DefaultTyping.NON_FINAL,这个参数的意思是,final类不会写出类信息。因为没有类信息,而配置的RedisCache又一定需要类信息才能完成反序列化,所以就导致了上面的报错。

避坑总结

知道原因后,就好办了,可以从以下几个方面避坑

使用默认JDK序列化

这种方式的好处是能避免很多json序列化导致的问题,但是占用的redis空间比json序列化大

使用open class

对于自己写的将要被Cachekotlin类,加上open修饰符,这样生成的java就不会是final类了。不好的地方在于,无法控制别人写的类,也就是无法直接反序列化别人的普通kotlin

传入DefaultTyping.EVERYTHING参数(推荐)

传入DefaultTyping.EVERYTHING的意思是:所有的类型(包括引用类型、原生类型)类信息都会写入json中,这样保证了不会出现只能序列化,不能反序列化的问题。一点小问题是,带来的小问题是,有时比普通json大一点:
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance , ObjectMapper.DefaultTyping.EVERYTHING, JsonTypeInfo.As.PROPERTY);


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!