今天,我们就一起聊聊开发中序列化常见的一些坑吧。
序列化和反序列化需要确保算法一致 业务代码中涉及序列化时,很重要的一点是要确保序列化和反序列化的算法一致性。在开发中我们常使用 RedisTemplate 来操作 Redis 进行数据缓存。数据(包含 Key 和 Value)要保存到 Redis,需要经过序列化算法来序列化成字符串。虽然 Redis 支持多种数据结构,比如 Hash,但其每一个 field 的 Value 还是字符串。如果 Value 本身也是字符串的话,能否有便捷的方式来使用 RedisTemplate,而无需考虑序列化呢?
其实是有的,那就是 StringRedisTemplate。那 StringRedisTemplate 和 RedisTemplate 的区别是什么呢?带着这个问题让我们来研究一下吧。
写一段测试代码,在应用初始化完成后向 Redis 设置两组数据,第一次使用 RedisTemplate 设置 Key 为 redisTemplate、Value 为 User 对象,第二次使用 StringRedisTemplate 设置 Key 为 stringRedisTemplate、Value 为 JSON 序列化后的 User 对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf 4j@Component public class RedisSerializeInit { @Autowired private RedisTemplate redisTemplate; @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private ObjectMapper objectMapper; @PostConstruct public void init () throws JsonProcessingException { redisTemplate.opsForValue().set("redisTemplate" , new User("zhuye" , 36 )); stringRedisTemplate.opsForValue().set("stringRedisTemplate" , objectMapper.writeValueAsString(new User("zhuye" , 36 ))); } }
如果你认为,StringRedisTemplate 和 RedisTemplate 的区别,无非是读取的 Value 是 String 和 Object,那就大错特错了,因为使用这两种方式存取的数据完全无法通用。
我们做个小实验,通过 RedisTemplate 读取 Key 为 stringRedisTemplate 的 Value,使 用 StringRedisTemplate 读取 Key 为 redisTemplate 的 Value:
1 2 3 4 5 @RequestMapping ("testReadRedis" )public void testReadRedis () { log.info("redisTemplate get {}" , redisTemplate.opsForValue().get("stringRedisTemplate" )); log.info("stringRedisTemplate get {}" , stringRedisTemplate.opsForValue().get("redisTemplate" )); }
结果是,两次都无法读取到 Value。
查看 RedisTemplate 的源码发现,默认情况下 RedisTemplate 针对 Key 和 Value 使用了 JDK 序列化:
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 public void afterPropertiesSet () { if (defaultSerializer == null ) { defaultSerializer = new JdkSerializationRedisSerializer( classLoader != null ? classLoader : this .getClass().getClassLoader()); } if (enableDefaultSerializer) { if (keySerializer == null ) { keySerializer = defaultSerializer; defaultUsed = true ; } if (valueSerializer == null ) { valueSerializer = defaultSerializer; defaultUsed = true ; } if (hashKeySerializer == null ) { hashKeySerializer = defaultSerializer; defaultUsed = true ; } if (hashValueSerializer == null ) { hashValueSerializer = defaultSerializer; defaultUsed = true ; } } }
redis-cli 看到的类似一串乱码的”\xac\xed\x00\x05t\x00\rredisTemplate”字符串, 其实就是字符串 redisTemplate 经过 JDK 序列化后的结果。而 RedisTemplate 尝试读取 Key 为 stringRedisTemplate 数据时,也会对这个字 符串进行 JDK 序列化处理,所以同样无法读取到数据。
而 StringRedisTemplate 对于 Key 和 Value,使用的是 String 序列化方式,Key 和 Value 只能是 String:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class StringRedisTemplate extends RedisTemplate <String , String > { public StringRedisTemplate () { setKeySerializer(RedisSerializer.string()); setValueSerializer(RedisSerializer.string()); setHashKeySerializer(RedisSerializer.string()); setHashValueSerializer(RedisSerializer.string()); } } public class StringRedisSerializer implements RedisSerializer <String > { @Override public String deserialize (@Nullable byte [] bytes) { return (bytes == null ? null : new String(bytes, charset)); } @Override public byte [] serialize(@Nullable String string) { return (string == null ? null : string.getBytes(charset)); } }
看到这里,我们应该知道 RedisTemplate 和 StringRedisTemplate 保存的数据无法通用。修复方式就是,让它们读取自己存的数据:
使用 RedisTemplate 读出的数据,由于是 Object 类型的,使用时可以先强制转换为 User 类型; 使用 StringRedisTemplate 读取出的字符串,需要手动将 JSON 反序列化为 User 类 型。
1 2 3 4 5 6 7 8 9 10 11 @RequestMapping ("testReadRedisRight" )public void testReadRedisRight () throws JsonProcessingException { User userFromRedisTemplate = (User) redisTemplate.opsForValue().get("redisTemplate" ); log.info("redisTemplate get {}" , userFromRedisTemplate); User userFromStringRedisTemplate = objectMapper .readValue(stringRedisTemplate.opsForValue().get("stringRedisTemplate" ), User.class); log.info("stringRedisTemplate get {}" , userFromStringRedisTemplate); }
看到这里你可能会说,使用 RedisTemplate 获取 Value 虽然方便,但是 Key 和 Value 不易读;而使用 StringRedisTemplate 虽然 Key 是普通字符串,但是 Value 存取需要手动序列化成字符串,有没有两全其美的方式呢?
当然有,自己定义 RedisTemplate 的 Key 和 Value 的序列化方式即可:Key 的序列化使用 RedisSerializer.string()(也就是 StringRedisSerializer 方式)实现字符串序列化,而 Value 的序列化使用 Jackson2JsonRedisSerializer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Configuration public class RedisConfig { @Bean public <T> RedisTemplate<String, T> redisTemplate (RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, T> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } }
写代码测试一下存取,直接注入类型为 RedisTemplate 的 userRedisTemplate 字段,然后在 testReadRedisRight2 方法中,使用注入的 userRedisTemplate 存入一个 User 对象,再分别使用 userRedisTemplate 和 StringRedisTemplate 取出这个对象:
1 2 3 4 5 6 7 8 @RequestMapping ("testReadRedisRight2" )public void testReadRedisRight2 () { User user = new User("zhuye" , 36 ); userRedisTemplate.opsForValue().set(user.getName(), user); Object userFromRedis = userRedisTemplate.opsForValue().get(user.getName()); log.info("userRedisTemplate get {} {}" , userFromRedis, userFromRedis.getClass()); log.info("stringRedisTemplate get {}" , stringRedisTemplate.opsForValue().get(user.getName())); }
但值得注意的是,这里有一个坑。第一行的日志输出显示,userRedisTemplate 获取到的 Value,是 LinkedHashMap 类型的,完全不是泛型的 RedisTemplate 设置的 User 类型。
如果我们把代码里从 Redis 中获取到的 Value 变量类型由 Object 强转为 User,编译不会出现问题,但会出现 ClassCastException:
1 java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.shoto.error.serialize.User
修复方式是,修改自定义 RestTemplate 的代码,把 new 出来的 Jackson2JsonRedisSerializer 设置一个自定义的 ObjectMapper,启用 activateDefaultTyping 方法把类型信息作为属性写入序列化后的数据中(当然了,你也可以调整 JsonTypeInfo.As 枚举以其他形式保存类型信息):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Configuration public class RedisConfig { @Bean public <T> RedisTemplate<String, T> redisTemplate (RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, T> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); jackson2JsonRedisSerializer.setObjectMapper(objectMapper); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } }
或者,直接使用 RedisSerializer.json() 快捷方法,它内部使用的 GenericJackson2JsonRedisSerializer 直接设置了把类型作为属性保存到 Value 中:
1 2 3 4 redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setHashValueSerializer(RedisSerializer.json());
因此,反序列化时可以直接得到 User 类型的 Value。通过对 RedisTemplate 组件的分析,可以看到,当数据需要序列化后保存时,读写数据使用一致的序列化算法的必要性,否则就像对牛弹琴。
这里,我再总结下 Spring 提供的 4 种 RedisSerializer(Redis 序列化器):
默认情况下,RedisTemplate 使用 JdkSerializationRedisSerializer,也就是 JDK 序列化,容易产生 Redis 中保存了乱码的错觉。
通常考虑到易读性,可以设置 Key 的序列化器为 StringRedisSerializer。但直接使用 RedisSerializer.string(),相当于使用了 UTF_8 编码的 StringRedisSerializer,需要注意字符集问题。
如果希望 Value 也是使用 JSON 序列化的话,可以把 Value 序列化器设置为 Jackson2JsonRedisSerializer。默认情况下,不会把类型信息保存在 Value 中,即使我们定义 RedisTemplate 的 Value 泛型为实际类型,查询出的 Value 也只能是 LinkedHashMap 类型。如果希望直接获取真实的数据类型,你可以启用 Jackson ObjectMapper 的 activateDefaultTyping 方法,把类型信息一起序列化保存在 Value 中。
如果希望 Value 以 JSON 保存并带上类型信息,更简单的方式是,直接使用 RedisSerializer.json() 快捷方法来获取序列化器。
反序列化时要小心类的构造方法 使用 Jackson 反序列化时,要小心类的构造方法。 我们看一个实际的踩坑案例吧。
有一个 APIResult 类包装了 REST 接口的返回体(作为 Web 控制器的出参),其中 boolean 类型的 success 字段代表是否处理成功、int 类型的 code 字段代表处理状态码。
开始时,在返回 APIResult 的时候每次都根据 code 来设置 success。如果 code 是 2000,那么 success 是 true,否则是 false。后来为了减少重复代码,把这个逻辑放到了 APIResult 类的构造方法中处理:
1 2 3 4 5 6 7 8 9 10 11 12 @Data public class APIResult { private boolean success; private int code; public APIResult () { } public APIResult (int code) { this .code = code; if (code == 2000 ) success = true ; else success = false ; } }
经过改动后发现,即使 code 为 2000,返回 APIResult 的 success 也是 false。比如,我 们反序列化两次 APIResult,一次使用 code==1234,一次使用 code==2000:
1 2 3 4 5 6 7 8 @Autowired ObjectMapper objectMapper; @GetMapping ("wrong" )public void wrong () throws JsonProcessingException { log.info("result :{}" , objectMapper.readValue("{\"code\":1234}" , APIResult.class)); log.info("result :{}" , objectMapper.readValue("{\"code\":2000}" , APIResult.class)); }
日志输出如下:
1 2 2022 -08 -27 21 :38 :36.730 INFO 23132 --- [nio-8080 -exec-2 ] c.shoto.error.serialize.RedisController : result :APIResult(success=false , code=1234 )2022 -08 -27 21 :38 :36.731 INFO 23132 --- [nio-8080 -exec-2 ] c.shoto.error.serialize.RedisController : result :APIResult(success=false , code=2000 )
可以看到,两次的 APIResult 的 success 字段都是 false。 出现这个问题的原因是,默认情况下,在反序列化的时候,Jackson 框架只会调用无参构造方法创建对象。如果走自定义的构造方法创建对象,需要通过 @JsonCreator 来指定构 造方法,并通过 @JsonProperty 设置构造方法中参数对应的 JSON 属性名:
1 2 3 4 5 6 7 8 9 10 @Data public class APIResultRight { @JsonCreator public APIResultRight (@JsonProperty("code" ) int code) { this .code = code; if (code == 2000 ) success = true ; else success = false ; } }
重新运行程序,可以得到正确输出:
1 2 2022 -08 -27 21 :40 :27.892 INFO 14916 --- [nio-8080 -exec-1 ] c.shoto.error.serialize.RedisController : result :APIResult(success=false , code=1234 )2022 -08 -27 21 :40 :27.892 INFO 14916 --- [nio-8080 -exec-1 ] c.shoto.error.serialize.RedisController : result :APIResult(success=true , code=2000 )
可以看到,这次传入 code==2000 时,success 可以设置为 true。