0%

序列化常见开发问题

今天,我们就一起聊聊开发中序列化常见的一些坑吧。

序列化和反序列化需要确保算法一致

业务代码中涉及序列化时,很重要的一点是要确保序列化和反序列化的算法一致性。在开发中我们常使用 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
@Slf4j
@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")); // 输出 null
log.info("stringRedisTemplate get {}", stringRedisTemplate.opsForValue().get("redisTemplate")); // 输出 null
}

结果是,两次都无法读取到 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( // jdk 序列化
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()); // key 采用 String 序列化方式
setValueSerializer(RedisSerializer.string()); // Vlaue 采用 String 序列化方式
setHashKeySerializer(RedisSerializer.string()); // Hash key 采用 String 序列化方式
setHashValueSerializer(RedisSerializer.string());// Hash Vlaue 采用 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 {
//使用RedisTemplate获取Value,无需反序列化就可以拿到实际对象,虽然方便,但是Redis中保存的
User userFromRedisTemplate = (User) redisTemplate.opsForValue().get("redisTemplate");
log.info("redisTemplate get {}", userFromRedisTemplate);

//使用StringRedisTemplate,虽然Key正常,但是Value存取需要手动序列化成字符串
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()); // class 类型为 LinkedHashMap
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();
//把类型信息作为属性写入Value
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。

------ 本文结束------