在 Spring Boot 中修改 Spring Cache(Redis)的序列化方式为 JSON

1、Spring Cache 的简介

Spring Cache 是 Spring 框架提供的一个缓存抽象层,用于简化应用中的缓存操作。它通过在方法执行期间将结果存储在缓存中,以便在相同的输入参数下,下一次调用时可以直接从缓存中获取结果,而不必执行相同的计算或查询操作。Spring Cache 支持多种缓存提供商,如 EhcacheRedisCaffeine 等,可以根据需求选择合适的缓存实现。通过使用 Spring Cache,开发人员可以轻松地添加缓存功能,提高应用的性能和响应速度。

Redis 是 Spring Cache 中最常用的缓存实现之一,有以下几个主要原因:

  1. 高性能:Redis 是一个基于内存的数据存储系统,具有非常高的读写性能。它将数据存储在内存中,可以快速地读取和写入数据,适合作为缓存存储。
  2. 数据结构丰富:Redis 支持多种数据结构,如字符串、哈希、列表、集合和有序集合等,这些数据结构的灵活性可以满足不同场景下的缓存需求。
  3. 持久化支持:Redis 提供了持久化机制,可以将数据存储到磁盘上,以防止数据丢失。这对于缓存数据的可靠性和持久性是非常重要的。
  4. 分布式支持:Redis 支持分布式部署,可以搭建多个 Redis 节点组成集群,实现数据的分片和负载均衡,提高了系统的扩展性和容错性。
  5. 生态系统丰富:Redis 有一个活跃的开源社区,提供了许多与 Spring 集成的库和工具,如 Spring Data RedisLettuceRedisson 等,这些工具可以方便地与 Spring Cache 集成,简化了开发和配置的过程。

综上所述,Redis 在性能、数据结构、持久化、分布式支持以及生态系统方面的优势,使其成为 Spring Cache 中最常用的缓存实现之一。

本文将会指导你如何在 Spring Boot 中整合 Spring Cache 和 Redis 来开发缓存应用,并且修改其序列化方式为 JSON。

本文中使用的软件版本:

  • spring boot:3.1.3
  • redis:7.0.5

2、整合 Spring Cache

你可以点击 start.springboot.io 快速地创建此演示项目。

项目的依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- 使用 Redis 作为缓存实现,需要添加此 starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

2.1、配置 application.properties

由于我们使用了 Redis 作为缓存实现,并且添加了 spring-boot-starter-data-redis,那么我们必须还要在 properties 文件中配置 redis 相关的属性。

你也可以把 properties 替换为 yaml,看个人喜好。

#---------------------------
# Spring Data Redis 基本配置
#---------------------------
# Redis 服务器地址
spring.redis.host=localhost
# Redis 服务器连接端口
spring.redis.port=6379  
# Redis 服务器连接密码(默认为空)
spring.redis.password= 
# Redis 数据库 
spring.redis.database=0

#---------------------------
# Spring Cache 配置
#---------------------------
# spring cache 实现使用 redis
spring.cache.type=redis
# 缓存有效的时间,默认永久有效
spring.cache.redis.time-to-live=5m
# 是否缓存 null 值,默认 true
spring.cache.redis.cache-null-values=true
# 缓存 key 的前缀
spring.cache.redis.key-pre-fix=SPRINGDOC::CACHE::
# 是否使用缓存 key 的前缀,默认 true
spring.cache.redis.use-key-prefix=true

关于 redisspring.cache 完整的配置信息及其详细的说明请参阅 官方文档

2.2、创建缓存对象

我们在 cn.springdoc.demo.model 包下创建一个 User 类,作为缓存的对象。它只有几个简单的属性!

package cn.springdoc.demo.model;

import java.io.Serializable;
import java.time.LocalDateTime;

public class User implements Serializable{ // 实现 Serializable 接口

    private static final long serialVersionUID = 7741588128834143466L;

    private Long id;
    private String name;
    private LocalDateTime createAt;
    
    // 省略 get/set 和 toString() 方法。
}

注意,默认情况下 spring cache 使用 Jdk 的序列化方式把 Java 对象序列化为二进制数据存储到 redis,也就是说缓存类必须要实现 Serializable 接口,否则在缓存对象的时候会抛出序列化异常 SerializationException

org.springframework.data.redis.serializer.SerializationException: Cannot serialize
    at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96)
    at org.springframework.data.redis.serializer.DefaultRedisElementWriter.write(DefaultRedisElementWriter.java:41)
    at org.springframework.data.redis.serializer.RedisSerializationContext$SerializationPair.write(RedisSerializationContext.java:290)
    at org.springframework.data.redis.cache.RedisCache.serializeCacheValue(RedisCache.java:276)
    at org.springframework.data.redis.cache.RedisCache.put(RedisCache.java:187)
    at org.springframework.cache.interceptor.AbstractCacheInvoker.doPut(AbstractCacheInvoker.java:87)
    at org.springframework.cache.interceptor.CacheAspectSupport$CachePutRequest.apply(CacheAspectSupport.java:837)
    at org.springframework.cache.interceptor.CacheAspectSupport.execute(CacheAspectSupport.java:430)
    at org.springframework.cache.interceptor.CacheAspectSupport.execute(CacheAspectSupport.java:345)
    at org.springframework.cache.interceptor.CacheInterceptor.invoke(CacheInterceptor.java:64)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:756)
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708)

2.3、创建缓存实现

我们在 cn.springdoc.demo.cache 包下创建 UserCache 缓存实现用于管理缓存对象:User

package cn.springdoc.demo.cache;

import java.time.LocalDateTime;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import cn.springdoc.demo.model.User;

@Component
public class UserCache {

    static final Logger logger = LoggerFactory.getLogger(UserCache.class);

    @Cacheable(cacheNames = "USER", key = "#id")
    public User get (Long id) {
        
        // 模拟根据id从数据库检索出来的 user 对象。
        User user = new User();
        user.setId(id);
        user.setName("SpringDoc");
        user.setCreateAt(LocalDateTime.now());
        
        logger.info("从DB检索到 id = {} 的用户", user);
        
        return user;
    }
}

我们只定义了一个 get (Long id) 方法,模拟根据 id 从数据库检索 User 对象。通过 @Cacheable 定义了这是一个缓存方法,spring cache 会先根据 id 去缓存中检索数据,如果命中则直接返回,避免检索数据库。如果未命中,则执行 get 方法并把结果缓存到 redis 后返回。

本文重点在于介绍如何整合 Spring Cache 和 Redis 以及修改其序列化方式为 JSON,对于 Spring Cache 的详细的使用方式你可以参阅 官方文档

2.4、注解驱动

最后,我们需要在启动类上添加 @EnableCaching 注解,以启用 Spring Cache。

package cn.springdoc.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;


@SpringBootApplication
@EnableCaching  // 启用 Spring Cache
public class SpringdocCacheApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringdocCacheApplication.class, args);
    }

}

2.5、测试

src/test/java 目录下创建测试类 cn.springdoc.demo.SpringdocCacheApplicationTests

package cn.springdoc.demo;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import cn.springdoc.demo.cache.UserCache;
import cn.springdoc.demo.model.User;

@SpringBootTest(classes = SpringdocCacheApplication.class)
class SpringdocCacheApplicationTests {

    static final Logger logger = LoggerFactory.getLogger(SpringdocCacheApplicationTests.class);

    @Autowired
    UserCache userCache;

    @Test
    void contextLoads() {
        // 用户id
        Long id = 10086L;
        
        // 第一次检索,未命中缓存,会模拟创建对象,并把结果存储到 Redis
        User user = userCache.get(id);
        logger.info("User = {}", user);
        
        // 第二次检索,命中缓存,直接返回
        user = userCache.get(id);
        logger.info("User = {}", user);
    }
}

我们在测试方法中执行了2次 get 方法(参数相同),按照缓存逻辑,第 1 次会 “从 DB 检索” 数据并且缓存在 Redis,第 2 次会命中 Redis 中的缓存数据。

执行测试,输出如下:

2023-09-05T09:32:58.261+08:00  INFO 2852 --- [           main] cn.springdoc.demo.cache.UserCache        : 从DB检索到 id = User [id=10086, name=SpringDoc, createAt=2023-09-05T09:32:58.261016200] 的用户
2023-09-05T09:32:58.279+08:00  INFO 2852 --- [           main] c.s.demo.SpringdocCacheApplicationTests  : User = User [id=10086, name=SpringDoc, createAt=2023-09-05T09:32:58.261016200]
2023-09-05T09:32:58.290+08:00  INFO 2852 --- [           main] c.s.demo.SpringdocCacheApplicationTests  : User = User [id=10086, name=SpringDoc, createAt=2023-09-05T09:32:58.261016200]

通过日志可以看出,只有第一次执行 get 的时候 “从DB 根据 id 检索了对象”,第二次执行 get 则是直接命中了缓存中的数据!

通过 Redis 客户端查看缓存在 Redis 中 User 对象。

缓存在 Redis 中的 User 对象数据

如上所述,spring cache 默认使用 Jdk 的序列化方式把 Java 对象序列化为 二进制 数据存储到 Redis中,所以通过 Redis 客户端查看,就是一堆“乱码”,人类不能直接阅读。

注意右上角的 TTL 时间(单位是秒),表示距离此缓存失效的时间,该时间是我们通过 spring.cache.redis.time-to-live 配置定义的。

3、修改序列化方式为 JSON

Spring cache 默认使用的序列化方式有一些弊端:

  1. 不适合人类阅读。
  2. 反序列化的时候可能存在 Jdk 版本兼容问题导致异常。
  3. 序列化后的二进制数据体积较大。

于是,我们可以通过配置修改其序列化方式为 JSON,好处如下:

  1. 人类可以阅读。
  2. 和语言无关,不存在兼容问题。
  3. 体积较小。

Spring Data Redis 基于 Jackson 提供给了一个通用的 RedisSerializer 实现: GenericJackson2JsonRedisSerializer。通过该实现,我们可以把 Spring Cache 的序列化方式改为 JSON。

Spring Boot 默认使用 Jackson 作为 JSON 序列化、反序列化框架,所以 Jackson 依赖不需要额外添加。

cn.springdoc.demo.configuration 包下新建配置类 CacheConfiguration

package cn.springdoc.demo.configuration;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.util.StringUtils;

import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;

@Configuration
public class CacheConfiguration {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();

        // 先载入配置文件中的配置信息
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();

        // 根据配置文件中的定义,初始化 Redis Cache 配
        if (redisProperties.getTimeToLive() != null) {
            redisCacheConfiguration = redisCacheConfiguration.entryTtl(redisProperties.getTimeToLive());
        }
        if (StringUtils.hasText(redisProperties.getKeyPrefix())) {
            redisCacheConfiguration = redisCacheConfiguration.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            redisCacheConfiguration = redisCacheConfiguration.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            redisCacheConfiguration = redisCacheConfiguration.disableKeyPrefix();
        }

        // 缓存对象中可能会有 LocalTime/LocalDate/LocalDateTime 等 java.time 段,所以需要通过 JavaTimeModule 定义其序列化、反序列化格式
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        
        javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS")));
        javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSS")));

        javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS")));
        javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSS")));

        // 基于 Jackson 的 RedisSerializer 实现:GenericJackson2JsonRedisSerializer
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        
        // 把 javaTimeModule 配置到 Serializer 中
        serializer = serializer.configure(config -> {
            config.registerModules(javaTimeModule);
        });

        // 设置 Value 的序列化方式
        return redisCacheConfiguration
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
    }
}

代码很简单,说明都在注释中了。

添加配置类后(不需要改动任何地方),再次执行 2.5 小节的测试方法,输出日志如下:

2023-09-05T10:29:47.863+08:00  INFO 8408 --- [           main] cn.springdoc.demo.cache.UserCache        : 从DB检索到 id = User [id=10086, name=SpringDoc, createAt=2023-09-05T10:29:47.863943300] 的用户
2023-09-05T10:29:47.915+08:00  INFO 8408 --- [           main] c.s.demo.SpringdocCacheApplicationTests  : User = User [id=10086, name=SpringDoc, createAt=2023-09-05T10:29:47.863943300]
2023-09-05T10:29:47.957+08:00  INFO 8408 --- [           main] c.s.demo.SpringdocCacheApplicationTests  : User = User [id=10086, name=SpringDoc, createAt=2023-09-05T10:29:47.863943300]

一切正常。再次通过 Redis 客户端查看缓存的 User 对象。

Redis 中缓存的 Json 格式的 User 对象

如你所见,User 对象在 Redis 中的缓存格式已经是 JSON 了,并且体积只有 114 字节,相比于默认序列化方式的 277 字节少了一半多。

使用 Json 序列化方式,缓存类 User 可以不用实现 Serializable 接口。

这里你需要注意一个地方,当你在此执行测试方法的时候。你需要先保证在 2.5 章节测时缓存在 Redis 中的 User 数据已经失效(你也可以手动删除)。因为此时我们已经配置了使用 JSON 来序列化、反序列化缓存数据,此时,必须要保证缓存的 User 数据是 JSON 格式,否则反序列化可能会异常。

3.1、使用 FastJson

Fastjson 也是一个 Java 开发的 Json 库,在国内比较流行。它也提供了一个通用的 RedisSerializer 实现:GenericFastJsonRedisSerializer。如果你喜欢,你可以使用它来代替 Jackson。

添加 fastjson 的依赖:

<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.40</version>
</dependency>

修改配置类:

package cn.springdoc.demo.configuration;

import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.util.StringUtils;

import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;

@Configuration
public class CacheConfiguration {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();

        // 先载入配置文件中的配置信息
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();

        // 根据配置文件中的定义,初始化 Redis Cache 配
        if (redisProperties.getTimeToLive() != null) {
            redisCacheConfiguration = redisCacheConfiguration.entryTtl(redisProperties.getTimeToLive());
        }
        if (StringUtils.hasText(redisProperties.getKeyPrefix())) {
            redisCacheConfiguration = redisCacheConfiguration.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            redisCacheConfiguration = redisCacheConfiguration.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            redisCacheConfiguration = redisCacheConfiguration.disableKeyPrefix();
        }

        // Fastjson 的通用 RedisSerializer 实现
        GenericFastJsonRedisSerializer serializer  = new GenericFastJsonRedisSerializer();
        
        // 设置 Value 的序列化方式
        return redisCacheConfiguration
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
    }
}

使用 Fastjson 的话,配置代码会更加简洁。

对于 Fastjson 的测试,这里就不进行了,你可以自己试一下。