Springboot 整合 SpringCache 使用 Redis 作為快取

2022-12-02 18:01:28

一直以來對快取都是一知半解,從沒有正經的接觸並使用一次,今天騰出時間研究一下快取技術,開發環境為OpenJDK17SpringBoot2.7.5

原始碼下載地址:https://hanzhe.lanzoue.com/iK4AF0hjl3lc

SpringCache基礎概念

介面介紹

首先看看SpringCache中提供的兩個主要介面,第一個是CacheManager快取管理器介面,在介面名的位置按F4(IDEA Eclipse快捷鍵)可檢視介面的實現,其中最底下的ConcurrentMapCacheManager就是快取管理器預設實現,在不進行任何設定的情況下直接使用快取預設使用的就是基於Map集合的快取

ConcurrentMapCacheManager實現類中可以看到,該實現類主要維護Map型別的cacheMap屬性,Value為Cache型別的介面,點進該介面可以發現他同樣有基於Map的實現類ConcurrentMapCache,開啟Debug偵錯後簡單測試了一下,程式碼走到了這個位置也確定了使用的就是該類

Cache介面就是就是第二個要了解的介面,梳理一下,CacheManager為快取管理器並且管理著Cache物件,而被管理的Cache提供了操作快取資料的方法

註解介紹

上面介紹了SpringCache中兩個介面,這裡來了解一下快取需要的註解,開發中最常用的就是基於註解的快取

名稱 解釋
@Cacheable 將方法的返回結果進行快取,後續方法被呼叫直接返回快取中的資料不執行方法,適合查詢
@CachePut 將方法的返回結果進行快取,無論快取中是否有資料都會執行方法並快取結果,適合更新
@CacheEvict 刪除快取中的資料
@Caching 組合使用快取註解
@CacheConfig 統一設定本類的快取註解的屬性
@EnableCaching 用於啟動類或者快取設定類,表示該專案開啟快取功能

前三個註解用於對快取資料進行增刪改查操作,@Caching註解的作用就是將前三個註解組合使用,適用於有關聯關係的快取資料,@CacheConfig則是針對本類中的快取做一些通用的設定

使用SpringCache

在寫這篇文章之前也參考過一些其他博主的文章,好多文章都要求參照spring-boot-starter-cache啟動器,但據我測試不參照該啟動器也可以實現快取功能,相關的類和註解在spring-context包中就已經存在了,我不太清楚為什麼他們要參照spring-boot-starter-cache,如果您懂的話麻煩在評論區指點下

編寫測試環境

專案新增的依賴

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.10</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

實體類

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    private Integer id;
    private String name;

}

Mapper 並沒有查詢資料庫,而是模擬出來的假資料

@Repository
public class UserMapper {

    public final List<UserEntity> users = CollUtil.newArrayList();

    @PostConstruct
    public void init() {
        users.add(new UserEntity(1, "使用者" + 1));
        users.add(new UserEntity(2, "使用者" + 2));
        users.add(new UserEntity(3, "使用者" + 3));
        users.add(new UserEntity(4, "使用者" + 4));
    }

    public List<UserEntity> list() {
        return this.users;
    }

    public UserEntity getOne(Integer id) {
        return this.users.stream()
                .filter(user -> NumberUtil.equals(user.getId(), id))
                .findFirst()
                .orElse(null);
    }

    public void update(UserEntity entity) {
        this.delete(entity.getId());
        users.add(entity);
    }

    public void delete(Integer id) {
        UserEntity entity = this.getOne(id);
        if (ObjectUtil.isNotNull(entity)) {
            users.remove(entity);
        }
    }

}

Service

@Slf4j
@Service
public class UserService {

    @Autowired
    private UserMapper mapper;

    public List<UserEntity> selectList() {
        List<UserEntity> list = mapper.list();
        log.info("list:{}", list.size());
        return list;
    }

    public UserEntity getOne(Integer id) {
        log.info("getOne:{}", id);
        return mapper.getOne(id);
    }

    public UserEntity update(UserEntity entity) {
        log.info("update:{}", entity);
        mapper.update(entity);
        return entity;
    }

    public void delete(Integer id) {
        log.info("delete:{}", id);
        mapper.delete(id);
    }

    public void clear() {}

}

Controller

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService service;

    @GetMapping("selectList")
    public Object selectList() {
        return success(service.selectList());
    }

    @GetMapping("getOne")
    public Object getOne(Integer id) {
        return success(service.getOne(id));
    }

    @GetMapping("update")
    public Object update(UserEntity entity) {
        service.update(entity);
        return success();
    }

    @GetMapping("delete")
    public Object delete(Integer id) {
        service.delete(id);
        return success();
    }

    @GetMapping("clear")
    public Object clear() {
        service.clear();
        return success();
    }



    /* ---------工具方法 --------- */

    public Map<String, Object> success(Object obj) {
        Map<String, Object> result = success();
        result.put("data", obj);
        return result;
    }

    public Map<String, Object> success() {
        Map<String, Object> result = MapUtil.newHashMap();
        result.put("code", 200);
        result.put("msg", "success");
        return result;
    }

}

最後在啟動類使用 @EnableCache 註解啟用快取

@EnableCaching
@SpringBootApplication
public class BootCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(BootCacheApplication.class, args);
    }
}

測試環境的編寫到此位置,可以試著請求一下介面看看是否搭建成功,以及控制檯中是否有對應的紀錄檔列印

使用註解快取

註解快取主要是@Cacheable、@CachePut、@CacheEvict這三種,且引數都基本相同,用過一次的引數基本就不重複說了,接下來測試在Service層中新增快取註解

@Cacheable

該註解適用於查詢方法,將查詢返回的結果放到快取中,下次接收到請求直接返回快取中的資料,先將註解按照下面的寫法加到程式碼中,然後呼叫看看效果

// @Cacheable(cacheNames = "USERS", key = "#root.methodName")
@Cacheable(cacheNames = "USERS", key = "'selectList'")
public List<UserEntity> selectList() { ... }

// @Cacheable(cacheNames = "USERS", key = "#id")
@Cacheable(cacheNames = "USERS", key = "#root.args[0]")
public UserEntity getOne(Integer id) { ... }

註解加上後反覆請求這兩個介面,可以發現相同的請求Service紀錄檔只列印了一次,因為資料已經新增到快取不會在執行Service程式碼了

cacheNames

現在來看一下@Cacheable註解中的引數,首先來看cacheNames,該引數可以理解為一個組,cacheNames相同的快取會放到同一個Cache物件中進行管理,例如USERS中只維護與使用者相關的快取,DEPTHS中只維護部門相關的快取,就是ConcurrentMapCacheManager中cacheMap的結構

key與SpEL表示式

第二個引數key代表的是快取資料在該組中的唯一標識,通過觀察Cache物件可以看出來

需要注意的是key的引數需要使用SpEL表示式,如果想直接使用字串作為key的話需要用單引號括起來

  • #root.methodName:獲取方法名
  • #id:獲取參數列中的id屬性
  • #root.args[0]:獲取參數列中第一個引數
  • #result:返回結果物件
  • 更多用法詳見官方檔案

condition

除了上面用到的兩個引數之外,這裡在介紹一個bool型別引數condition,該引數的作用是做條件判斷,只有判斷結果為true註解才會生效,現在修改一下Service中的註解,新增condition條件

/**
 * 只有ID為1時才進行快取,其他資料直接執行Service程式碼
 */
@Cacheable(cacheNames = "USERS", key = "#root.args[0]", condition = "#id == 1")
public UserEntity getOne(Integer id) { ... }

unless

condition的作用是隻有判斷結果為true結果才生效,同時有個與他相對的unless註解,判斷結果為true時註解失效

/**
 * 只有ID為1時不執行快取,每次都會執行Service程式碼
 * 其他資料正常快取
 */
@Cacheable(cacheNames = "USERS", key = "#root.args[0]", unless = "#id == 1")
public UserEntity getOne(Integer id) { ... }

cacheManager

當系統中設定了多個快取實現的時候,可以在註解中傳入快取管理器的bean名稱來指定該快取使用哪個實現,如下圖所示,這裡就不演示了

sync 瞭解即可

在多執行緒的情況下快取資料可能會被重複操作多次,如果快取資料比較敏感可以使用sycn屬性將快取資料設定為多執行緒安全,不過一般很少有人會將敏感資料存放到快取中,所以sync預設為關閉狀態,也很少會有人開啟他,而且需要注意的是並不是所有快取實現類都可以實現該功能,目前可以肯定的是Spring官方給出的所有CacheMapper實現類都支援這個屬性,屬性並不常用圖省事兒這裡也就不演示了

keyGenerator 瞭解即可

修改快取key的生成規則,在註解中使用keyGenerator引數後就不能在使用key,快取key的生成規則由keyGenerator來決定,如果想對某個介面使用自定義規則key,需要向ioc容器中注入SimpleKeyGenerator型別的bean,然後將bean的名稱傳入keyGenerator即可,如下圖所示,這裡就不演示了

@CachePut

在使用該註解之前先來做一個小測試,將getOne註解上的condition和unless條件清除,讀取ID為1的使用者資料,然後修改該使用者的資料,修改過後在讀取一次該使用者的資料,看看效果如何

可以發現雖然我修改了ID為1的使用者資料,但是查詢該資料時返回的仍是舊資料,這時就需要使用@CachePut註解更新快取資料,該註解會用方法的返回結果更新掉快取中的舊資料

/**
 * 在getOne中快取資料的key為#root.args[0],代表的是參數列中第一位,也就是使用者ID
 * 那麼在update方法中用來更新快取資料的也應該是使用者ID,也就是返回結果中的id: #result.id
 */
@CachePut(cacheNames = "USERS", key = "#result.id")
public UserEntity update(UserEntity entity) { ... }

@CacheEvict

@CacheEvict註解的作用就是刪除快取中的舊資料,通過引數key來指定刪除的是哪一條,同時該註解還有allEntries引數,在不使用key的情況下設定allEntries為true可以清空該cacheNames下所有快取

@CacheEvict(cacheNames = "USERS", key = "#id")
public void delete(Integer id) { ... }

@CacheEvict(cacheNames = "USERS", allEntries = true)
public void clear() {}

這裡可以自行呼叫測試並檢視紀錄檔列印情況,我就不上傳截圖了

@Caching

該註解的作用是組合其他註解使用,例如刪除了該使用者後,還需要刪除該使用者的登入資訊,就可以使用到該註解

@Caching(evict= {
        @CacheEvict(cacheNames = "USERS", key = "#id"),
        @CacheEvict(cacheNames = "TOKENS", key = "#id")
})
public void delete(Integer id) { ... }

@CacheConfig

@CacheConfig可以針對當前類的所有快取註解進行統一設定,例如之前在每個註解上都使用了cacheNames屬性,在使用了@CacheConfig註解後只需要在該類上標註cacheNames那麼類中的註解就可以省去該引數了

@Slf4j
@Service
@CacheConfig(cacheNames = "USERS")
public class UserService {

    @Autowired
    private UserMapper mapper;

    @Cacheable(key = "'selectList'")
    public List<UserEntity> selectList() { ... }

    @Cacheable(key = "#root.args[0]")
    public UserEntity getOne(Integer id) { ... }

    @CachePut(key = "#result.id")
    public UserEntity update(UserEntity entity) { ... }

    @Caching(evict= {
            @CacheEvict(key = "#id"),
            @CacheEvict(cacheNames = "TOKENS", key = "#id")
    })
    public void delete(Integer id) { ... }

    @CacheEvict(allEntries = true)
    public void clear() {}

}

使用程式設計式快取

程式設計式快取,指在程式碼中操作快取資料,之前提到過SpringCache中有提供Cache介面,我們通過程式碼操作快取靠的就是這個介面,直接在Controller裡演示一下

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private CacheManager cacheManager;

    @GetMapping("/test")
    public Object test() {
        // 通過cacheManager獲取維護使用者快取的Cache物件
        Cache cache = cacheManager.getCache("USERS");
        // 向快取中新增資料
        cache.put(8, new UserEntity(8, "使用者8"));
        cache.put(9, new UserEntity(9, "使用者9"));
        // 列印測試
        System.out.println(cache.get(8, UserEntity.class));
        System.out.println(cache.get(9, UserEntity.class));
        // 移除其中一個快取資料
        cache.evict(8);
        // 列印測試
        System.out.println(cache.get(8, UserEntity.class));
        System.out.println(cache.get(9, UserEntity.class));
        // 清空快取資料
        cache.clear();
        // 列印測試
        System.out.println(cache.get(8, UserEntity.class));
        System.out.println(cache.get(9, UserEntity.class));

        return success();
    }

    // 省略多餘程式碼 .....

}

整合Redis為快取實現

整合Redis需要參照Redis的場景啟動器

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

新增Redis場景啟動器後重新整理Maven依賴,然後在回過頭來看CacheManager介面的實現類,會發現多了基於Redis的快取實現

之後在組態檔中新增Redis的連線資訊,重啟專案就可以請求介面進行測試了

spring:
  redis:
    host: 192.168.1.34
    port: 6379
    password: redis
    database: 1

修改組態檔

我們可以通過組態檔來對快取進行一些設定,找到快取的自動設定類CacheAutoConfiguration可以看到類中啟用了CacheProperties

CacheProperties類可以知道組態檔中可以設定哪些屬性,例如指定使用Redis作為快取實現,不過當我們引入Redis場景啟動器後,快取的預設實現已經被設定為Redis,所以這個設定也可以忽略

spring:
  cache:
    type: redis

指定cacheNames集

在快取註解中使用的cacheNames的作用是對快取資料進行分組,當快取中沒有這個組的時候會自動建立這個組,同時該屬性可以在組態檔中設定,不過需要注意的是一旦在組態檔中指定cacheNames,那麼快取註解將不再提供自動建立的功能,使用不存在的cacheNames會報錯

spring:
  cache:
    type: redis
    cache-names:
      - USERS
      - DEPT
      - ...

Redis設定項

設定項一個個介紹比較麻煩,這裡直接將Redis的設定項列出來,設定項對應CacheProperties.redis屬性

spring:
  cache:
    # 指定Redis作為快取實現
    type: redis
    # 指定專案中的cacheNames
    cache-names:
      - USERS
    redis:
      # 快取過期時間為10分鐘,單位為毫秒
      time-to-live: 600000
      # 是否允許快取空資料,當查詢到的結果為空時快取空資料到redis中
      cache-null-values: true
      # 為Redis的KEY拼接字首
      key-prefix: "BOOT_CACHE:"
      # 是否拼接KEY字首
      use-key-prefix: true
      # 是否開啟快取統計
      enable-statistics: false

修改Redis快取為JSON

快取功能已經實現,但是根據之前的測試來看,存到Redis中的資料是一堆亂碼不利於檢視和維護,這裡修改下Redis快取的序列化

原始碼分析

原始碼分析部分比較枯燥,可跳過

在快取的自動設定類CacheAutoConfiguration中可以看到使用@Import註解除參照了CacheConfigurationImportSelector類,該類實現了ImportSelector介面,可以動態的向ioc容器中注入指定的bean

而在CacheConfigurationImportSelector類中遍歷了CacheType列舉,這個CacheType正對應著一開始在組態檔中所寫的spring.cache.type=redis,在程式碼結束的位置也看到了Redis的快取設定類RedisCacheConfiguration

這裡挑重點直接看RedisCacheConfiguration設定類,在該設定類中使用@Bean向ioc容器中新增了CacheManager的實現,被Bean標註的方法參數列預設都是可以在ioc容器中找到的,而這個參數列中包含RedisCacheConfiguration

需要注意當前所在的位置是autoconfigure.cache包,而參數列中的RedisCacheConfiguration類是data.redis.cache包下的,這是兩個不同的類

這裡順著該物件往下看呼叫,先是呼叫了determineConfiguration,而後呼叫了createConfiguration,在createConfiguration可以看出Redis預設使用的是JDK的序列化實現

回過頭來看一眼,參數列中好像並不是RedisCacheConfiguration物件,而是RedisCacheConfiguration型別的ObjectProvider物件,這裡解釋一下ObjectProvider是物件提供者,他會優先取ioc容器中該型別的Bean,如果沒有就使用自己的物件,具體可以看determineConfiguration方法

這樣一來事情思路就理清了,只要使用ObjectProvider的特點,自己向IOC容器中提供一個RedisCacheConfiguration物件就可以覆蓋掉原本的設定了

功能實現程式碼

先建立個Redis快取設定類,編寫一個@Bean的方法返回RedisCacheConfiguration物件,為了防止組態檔中的設定項失效,這裡直接將上面的createConfiguration方法體複製過來,將CacheProperties放到參數列中,他會自己去ioc容器中取,另一個引數是JDK序列化用到的,這裡用不上就不拿過來了

然後將程式碼中的JDK序列化刪掉,通過Ctrl + P(IDEA Eclipse快捷鍵)可以看到他需要RedisSerializer型別的序列化物件

點開這個介面檢視他的實現類,發現Redis提供了兩個JSON序列化物件

下面的是帶有泛型的序列化器,這裡推薦通用的GenericJackson2JsonRedisSerializer,該類有空參構造直接new物件即可,替換掉JDK序列化

@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        // 獲取Redis設定資訊
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        // 獲取Redis預設設定
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 指定序列化器為GenericJackson2JsonRedisSerializer
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        // 過期時間設定
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        // KEY字首設定
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        // 快取空值設定
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        // 是否啟用字首
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

}

這樣一來JSON序列化功能就完成了,可以重啟檢查一下效果,由於反序列化的需要,JSON的每個物件中都新增了class資訊

修改字首生成規則

Redis的KEY一般用:來區分層級,這是個約定俗成的習慣,我使用的Redis視覺化工具也會基於:將key放在不同資料夾下進行分組展示,可是RedisCache生成的KEY中間有個雙冒號,導致視覺化介面中有一層資料夾是空的,強迫症表示難以接受,這個問題必須解決

字首的生成靠的是CacheKeyPrefix,點開這個類可以看到他將雙冒號直接寫死在程式碼中了,我們需要自定義介面去繼承他,然後代替他

public interface CustomKeyPrefix extends CacheKeyPrefix {

    String SEPARATOR = ":";

    String compute(String cacheName);

    static CustomKeyPrefix simple() {
        return (name) -> name + SEPARATOR;
    }

    static CustomKeyPrefix prefixed(String prefix) {
        Assert.notNull(prefix, "Prefix must not be null!");
        return (name) -> prefix + name + SEPARATOR;
    }

}

回到剛剛建立的Redis快取設定類中,

@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        // 獲取Redis設定資訊
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        // 獲取Redis預設設定
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 指定序列化器為GenericJackson2JsonRedisSerializer
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        // 過期時間設定
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        // 替換字首生成器(有字首和無字首)
        config = config.computePrefixWith(CustomKeyPrefix.simple());
        if (redisProperties.getKeyPrefix() != null) {
            config = config.computePrefixWith(CustomKeyPrefix.prefixed(redisProperties.getKeyPrefix()));
        }
        // 快取空值設定
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        // 是否啟用字首
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }

}

問題結局,可以重啟後檢查一下效果,非常滴完美