iT邦幫忙

0

VScode 開發應用系統專案(7 -2 ) - Spring Boot Cache — Redis 快取暫存處理

  • 分享至 

  • xImage
  •  

Spring Boot Cache — Redis 快取暫存處理

概述

Spring 框架支援透明地為應用程式新增快取。其核心是將快取應用於方法,從而根據快取中可用的資訊減少程式Method的執行次數。快取應用,不會干擾呼叫者。只要使用@EnableCaching註解啟用快取支持,Spring Boot 就會自動配置快取基礎架構,當Method 被標註為 @Cacheable 後,Spring 會在第一次執行該Method時執行實際的邏輯,並將返回結果存入快取中。隨後的執行該Method將直接從快取中返回結果,而不會再次執行方法,這樣可以顯著提高應用程式的性能。配合多主機分散環境中執行,開發應用系統配置兩層快取,準備提供 Caffeine(JVM in-memory)與 Redis(分散式)快取的設定說明、使用範例、最佳實務,以及針對專案現有 util 的測試範例(CaffeineCacheUtilsTest、RedisCacheUtilsTest)。

  • Caffeine: 輕量、效能高的 JVM 內部快取,適合單一節點快取資料及加速讀取。專案位於 tw.lewishome.webapp.base.cache.caffeine,主要檔案:CaffeineConfigurationCaffeineCacheUtils
  • Redis: 分散式快取/資料存放,適用跨節點共用快取資料或需持久化的情境。專案位於 tw.lewishome.webapp.base.cache.redis,主要檔案:RedisConfiguration(Lettuce、RedisTemplateredisCacheManager)。

準備與檢核

  1. VScode 開發應用系統專案 (1) - 啟動Spring Boot Web專案。
  1. 工具類程式已經準備好可以使用。
  1. Spring Boot Cache — Caffeine 快取暫存處理

一、總覽 Spring Boot Redis 設定檔案

application.properties
因為發生 "Could not safely identify store assignment for repository candidate interface ..."(因為 Spring Data 同時存在多種 store 驅動而無法判斷時觸發)所以application.properties增加以下設定

spring.data.redis.repositories.enabled=false

調整後的application.properties

spring.application.name=webapp

# 這裡設定跟執行環境無關的專案的profile
# Sprint Boot 會以spring.profiles.active變數,尋找application-${spring.profiles.active}.properties
# 以下是設定會自動找尋 application-dev.properties載入設定(server會另外設定,覆蓋此變數)
spring.profiles.active=dev

#預設只開啟 primary datasource,後續由 application-dev.properties在打開並設置連線資訊。
primary.datasource.enabled=true
secondary.datasource.enabled=false
tertiary.datasource.enabled=false

# spring.main.allow-circular-references=true

spring.data.redis.repositories.enabled=false

RedisConfiguration
位置:src/main/java/tw/lewishome/webapp/base/cache/redis/RedisConfiguration.java

  • 建立 LettuceConnectionFactory(使用 getRedisStandaloneConfiguration()SystemEnvReader 讀取 REDIS_HOSTREDIS_PORTRRDIS_PASSWORD)、redisTemplate()StringRedisSerializer + GenericJackson2JsonRedisSerializer)、redisCacheManager(預設 TTL 由 REDIS_LIFE_HOUR 決定)。
  • 環境屬性: REDIS_HOST(預設 redis.lewishome.tw)、REDIS_PORT(預設 6379)、RRDIS_PASSWORDREDIS_LIFE_HOUR(預設 1 小時)。
package tw.lewishome.webapp.base.cache.redis;

import java.time.Duration;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.beans.factory.annotation.Qualifier;

import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
import tw.lewishome.webapp.base.utility.common.TypeConvert;

/**
 * https://www.1ju.org/article/spring-two-level-cache RedisConfig 負責設定 Redis
 * 連線與相關 Bean。
 *
 * 此類別主要用於 Spring Boot 專案中,集中管理 Redis 連線設定與相關元件的初始化。 透過此設定類別,可確保 Redis
 * 連線參數一致,並方便於專案中注入使用。
 *
 *
 * <h2>主要功能說明:</h2>
 * <ul>
 * <li><b>jedisConnectionFactory()</b>:建立 JedisConnectionFactory,設定 Redis
 * 連線主機、埠號、密碼及連線逾時時間(預設 600 毫秒)。</li>
 * <li><b>redisTemplate()</b>:建立 RedisTemplate,並指定連線工廠,方便於 Spring 中進行 Redis
 * 操作(如存取、查詢資料)。</li>
 * <li><b>getRedisStandaloneConfiguration()</b>:
 * 初始化 RedisStandaloneConfiguration,
 * 設定連線主機 ${REDIS_HOST}(redis.lewishome.tw)、埠號${REDIS_PORT}6379)及密碼(XXXX)。
 * </li>
 * <li><b>jedis(RedisProperties)</b>:根據外部設定(application.properties)建立 Jedis
 * 實例,便於直接操作 Redis。</li>
 * </ul>
 *
 * <h2>使用情境:</h2>
 * <ul>
 * <li>當需要在 Spring Boot 專案中存取 Redis 資料時,可直接注入 RedisTemplate 或 Jedis 實例。</li>
 * <li>集中管理 Redis 連線參數,避免重複設定與維護困難。</li>
 * <li>可依需求擴充連線設定(如叢集、SSL、連線池等)。</li>
 * </ul>
 *
 * @author Lewis
 * @since 2024
 */
@Configuration
@EnableRedisRepositories(basePackages = "tw.lewishome.webapp.base.cache.redis")
public class RedisConfiguration {

    @Autowired
    SystemEnvReader systemEnvReader;

    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * 
     * Constructs a new RedisConfiguration instance.
     * This is the default constructor, implicitly provided by the compiler
     * and can be used to create a new instance of the class.
     */
    public RedisConfiguration() {
        // Constructor body (can be empty)
    }

    /**
     * 建立 Redis 快取管理器的 Bean
     * 
     * 此方法配置一個 Redis 快取管理器,用於處理應用程式的快取需求。
     * 快取配置包含以下特點:
     * - 預設快取過期時間設定為 1 小時
     * - 使用 GenericJackson2JsonRedisSerializer 進行值的序列化
     * 
     * @param redisConnectionFactory Redis 連線工廠,用於建立與 Redis 伺服器的連線
     * @return CacheManager 返回配置完成的 Redis 快取管理器實例
     */
    @Bean(name = "redisCacheManager")
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
            @Qualifier("redisObjectMapper") ObjectMapper redisObjectMapper) {
        String redisTimeToLife = systemEnvReader.getProperty("REDIS_LIFE_HOUR","1");
        int timToLife = TypeConvert.toInteger(redisTimeToLife);
        Duration redisTtlDuration = Duration.ofHours(timToLife);
        RedisCacheConfiguration config = 
                RedisCacheConfiguration
                .defaultCacheConfig()
                .entryTtl(redisTtlDuration)
                .serializeValuesWith(
                    RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper))
                    );
        return RedisCacheManager
                    .builder(redisConnectionFactory)
                    .cacheDefaults(config)
                    .build();
    }

    /**
     * 建立並回傳一個 LettuceConnectionFactory 的 Spring Bean,用以連接 Redis(單機模式)。
     *
     * 方法會透過 getRedisStandaloneConfiguration() 取得 Redis 的單機設定,並使用該設定建立 LettuceConnectionFactory。
     * 方法內部會捕捉所有例外並印出堆疊追蹤;若在建立過程中發生例外,將回傳 null(呼叫端應注意 null 的處理以避免啟動或運行階段錯誤)。
     *
     * 注意事項:
     * - 此方法會作為 Spring Bean 提供 Redis 連線工廠,確保 getRedisStandaloneConfiguration() 回傳有效設定以正確建立連線。
     * - 生產環境中建議改以適當的錯誤處理與記錄機制,而非直接印出堆疊追蹤。
     *
     * @return 已建立的 LettuceConnectionFactory 實例;若建立失敗則回傳 null
     */
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {

        LettuceConnectionFactory lettuceConnectionFactory = null;
        try {
            RedisStandaloneConfiguration redisStandaloneConfiguration = getRedisStandaloneConfiguration();
            lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return lettuceConnectionFactory;
    }

    /**
     * 建立並回傳一個 RedisTemplate 的 Spring Bean,用以操作 Redis 資料庫。
     *
     * 方法會使用注入的 RedisConnectionFactory 作為連線工廠,並設定鍵的序列化器為 StringRedisSerializer,
     * 值的序列化器為 GenericJackson2JsonRedisSerializer,以確保資料在 Redis 中的正確存取與格式化。
     *
     * @param connectionFactory 注入的 RedisConnectionFactory,用於建立與 Redis 的連線
     * @return 已建立並配置完成的 RedisTemplate 實例
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory,
            ObjectMapper redisObjectMapper) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(
                redisObjectMapper);

        // Key serializers
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);

        // Value serializers
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }

    /**
     * Provide ObjectMapper configured for Redis JSON serialization (JavaTime support, typing).
     */
    @Bean(name = "redisObjectMapper")
    public ObjectMapper redisObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL);
        return mapper;
    }

    /**
     * StringRedisTemplate bean for simple string operations.
     */
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
        return new StringRedisTemplate(connectionFactory);
    }

    /**
     * 建立 RedisStandaloneConfiguration 的方法。
     * 此方法用於初始化 Redis 的單機配置,設定連線主機、埠號及密碼。
     *  
     *  @return RedisStandaloneConfiguration 已配置的 Redis 單機設定
     */
    private RedisStandaloneConfiguration getRedisStandaloneConfiguration() {

        String redisHostName = systemEnvReader.getProperty("REDIS_HOST","redis.lewishome.tw");
        String redisPort = systemEnvReader.getProperty("REDIS_PORT","6379");
        int  intRedisPort = TypeConvert.toInteger(redisPort);
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(
                redisHostName, intRedisPort);
                String redisHostPassword = systemEnvReader.getProperty("RRDIS_PASSWORD","XXXX");
        redisStandaloneConfiguration.setPassword(RedisPassword.of(redisHostPassword));
        return redisStandaloneConfiguration;
    }
}

MultiCacheInterceptor(多層快取攔截器
位置:src/main/java/tw/lewishome/webapp/base/cache/redis/MultiCacheInterceptor.java

  • 行為摘要:

    • 攔截快取讀取流程(繼承自 CacheInterceptor),當快取來源為 RedisCache 且成功取得值時,會檢查對應名稱的 Caffeine 快取(由 caffeineCacheManager 管理),若本地快取缺少該鍵則以 putIfAbsent 將該值寫入本地快取。
    • 這個策略實現了簡單的「read-through」同步:遠端為主(Redis),但同時暖啟本地快取以降低後續存取成本。
  • 重要細節與注意事項:

    • MultiCacheInterceptor 直接檢查 cache.getClass() == RedisCache.class 來判斷來源為 Redis,若專案中使用自訂 Cache 實作或代理類別,需確認判斷邏輯是否涵蓋情況,否則可能無法觸發同步行為。
    • 寫回本地快取採用 putIfAbsent,因此不會覆寫本地已存在的資料;如果需要更強的一致性策略(例如每次都覆寫或比對版本),需另外實作邏輯。
    • 由於讀取時會執行額外的本地快取寫入,若流量極高或寫入成本敏感,需評估是否造成額外負載。
    • 若要測試該攔截器,建議在整合測試環境模擬 Redis 回應(或使用 embedded Redis),並確認 Caffeine 在讀取後含有該鍵。
  • 範例(行為示意):

    1. 第一次從快取查詢:Redis 有值 → MultiCacheInterceptor 將值放入本地 Caffeine → 回傳結果給 caller。
    2. 第二次查詢(短時間內):直接命中本地 Caffeine → 不需再讀 Redis,延遲更低。
package tw.lewishome.webapp.base.cache.redis;

import org.osgi.service.component.annotations.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.interceptor.CacheInterceptor;
import org.springframework.data.redis.cache.RedisCache;

/**
 * Redis 多層快取攔截器,用於管理 Redis 和 Caffeine 快取的協同工作。
 * 此攔截器繼承自 CacheInterceptor,主要用於優化快取存取策略。
 * 
 * 當從 Redis 快取中檢索到數據時,會自動檢查本地 Caffeine 快取是否也存在該數據。
 * 如果本地快取中缺少該數據,則會自動將其添加到 Caffeine 快取中,以提升後續存取效能。
 *
 * @see CacheInterceptor
 * @see RedisCache
 * @see CacheManager
 *
 * @author Lewis
 * @version 1.0
 * @since 1.0
 */
@Component
public class MultiCacheInterceptor extends CacheInterceptor {

    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * 
     * Constructs a new RedisMultiCacheInterceptor instance.
     * This is the default constructor, implicitly provided by the compiler
     * and can be used to create a new instance of the class.
     */
    public MultiCacheInterceptor() {
        // Constructor body (can be empty)
    }

    /**
     * Caffeine Cache Manager,用於管理本地 Caffeine 快取。
     * 通過自動注入獲取對應的 CacheManager 實例。
     */
    @Autowired
    @Qualifier("caffeineCacheManager")
    private CacheManager caffeineCacheManager;


    /**
     * 從快取中檢索指定鍵的值。
     * 如果從 Redis 快取中檢索到值,且本地 Caffeine
     * 快取中缺少該值,則將其添加到 Caffeine 快取中。
     *  
     * @param cache 快取實例
     * @param key   要檢索的鍵
     * @return      包含檢索到的值的 Cache.ValueWrapper,如果未找到則為 null
     */

    @SuppressWarnings("null")
    @Override
    protected Cache.ValueWrapper doGet(Cache cache, Object key) {
        //Get item from cache
        var superGetResult = super.doGet(cache, key);

        if (superGetResult == null) {
            return superGetResult;
        }

        //If retrieved from Redis, check if it's missing from caffeine on local and add it
        if (cache.getClass() == RedisCache.class) {
            var caffeineCache = caffeineCacheManager.getCache(cache.getName());
            if (caffeineCache != null) {
                caffeineCache.putIfAbsent(key, superGetResult.get());
            }
        }

        return superGetResult;
    }
}

二、 Redis Cache 相關工具

RedisCacheUtils 工具方法摘要
位置:src/main/java/tw/lewishome/webapp/base/cache/redis/RedisCacheUtils.java

方法 參數 回傳值 說明
addCatchValue cacheName, cacheKey, cacheValue Boolean 新增快取值;若參數非法拋出 NullPointerException
getCacheKeyValue cacheName, cacheKey Object 取得快取值;若找不到拋出 NullPointerException
getAllCacheName List 取得所有 Cache 名稱列表
getAllCacheNameKeys cacheName List 取得指定 Cache 的所有 Key(支援 best-effort 多種底層實作)
getCacheAllKeyValueMap cacheName Map<String, Object> 取得指定 Cache 的所有 Key/Value 對
evictOneCaches cacheName void 清空指定 Cache 的所有資料
evictOneCachesKey cacheName, cacheKey void 清除指定 Cache 的特定 Key
evictAllCaches void 清空所有 Cache 資料

關鍵特性

  • 支援多種 Redis 底層實作(RedisOperations、RedisTemplate、記憶體 Map)的 Key 列舉,採用 best-effort 策略:
    • 若 nativeCache 為 RedisOperations,使用 ops.keys(cacheName + "*")
    • 若為 Map 實作(如 Caffeine),直接遍歷 keySet。
    • 若為 RedisCache,透過 RedisTemplate.keys(cacheName + "::*") 並移除前綴。
  • 異常處理採取 best-effort 忽略,不向上拋出(適合探索性查詢)。
  • 參數檢查類似 CaffeineCacheUtils,null/空白參數會拋出 NullPointerException。
  • 注意:生產環境大規模 Redis keys 操作建議改用 SCAN 或其他遊標機制,避免阻塞 Redis。

與 CaffeineCacheUtils 的區別

  • 底層實作:Caffeine(JVM 記憶體)vs. Redis(分散式)。
  • Key 列舉策略:Caffeine 直接遍歷,Redis 採 best-effort 多層次嘗試。
  • 序列化:Redis 透過 StringRedisSerializer/GenericJackson2JsonRedisSerializer,Caffeine 無此考量。
  • 適用場景:Caffeine 適合單機本地快取;Redis 適合跨節點共享或長期持久化快取。
package tw.lewishome.webapp.base.cache.redis;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.cache.RedisCache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

/**
 * RedisCacheUtils
 *
 * 提供對 Spring Cache 與底層 Redis (或記憶體 Map) 的輔助操作工具類別。
 * 此類別透過注入的 CacheManager 與 RedisTemplate 提供下列功能:
 * - 新增指定 CacheName 與 Key 的快取值
 * - 取得系統中所有 Cache 名稱
 * - 列舉指定 CacheName 底下的所有 Key(支援多種底層實作的 best-effort 列舉)
 * - 取得指定 Cache 的所有 Key/Value 組合
 * - 取得或移除指定 Cache 與 Key 的資料
 * - 清除單一或全部 Cache
 *
 * 注意事項:
 * - 對於 Redis 的 Key 列舉,視底層實作(RedisOperations、RedisTemplate 或記憶體 Map)採用不同策略,
 * 若底層不支援 keys 操作或遇到例外,會採取 best-effort 並忽略該例外(不會拋出)。
 * - 部分方法在參數為 null 或找不到對應的 Cache/Key 時會拋出 NullPointerException;呼叫方需留意並處理。
 */

@Service
public class RedisCacheUtils {
    /** 
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new RedisCacheUtils instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     * 
     */
    public RedisCacheUtils() {
        // Constructor body (can be empty)
    }

    @Autowired
    @Qualifier("redisCacheManager")
    private CacheManager cacheManager;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 新增指定 CacheName 與 Key 的 Object 資料。
     *
     * 此方法會直接透過 CacheManager 取得 Cache 並呼叫 put。若需要支援過期或序列化細節,
     *           請依據實際 CacheManager/Cache 實作做額外處理。
     * 
     * @param cacheName  要操作的 Cache 名稱(不可為 null 或空白)
     * @param cacheKey   Cache 的 Key(不可為 null 或空白)
     * @param cacheValue 要放入的資料物件(可為 null,視 Cache 實作而定)
     * @return 新增成功則回傳 true;若輸入參數不合法或找不到對應的 Cache,方法會拋出 NullPointerException
     * @throws NullPointerException 當 cacheName 或 cacheKey 為 null/空白,或指定的 CacheName
     *                              不存在時拋出
  
     */
    public Boolean addCatchValue(String cacheName, String cacheKey, Object cacheValue) throws NullPointerException {
        if (StringUtils.isBlank(cacheName)) {
            throw new NullPointerException("cacheName is null or blank");
        }
        if (StringUtils.isBlank(cacheKey) || StringUtils.isBlank(cacheName)) {
            throw new NullPointerException("Cache  Key is null or blank");
        }
        Cache oneCache = cacheManager.getCache(cacheName);
        if (oneCache == null) {
            throw new NullPointerException("Cache Name not found / create");
        }
        oneCache.put(cacheKey, cacheValue);
        return true;
    }

    /**
     * 取得所有 Cache 名稱列表。
     *
     * @return 所有 Cache 名稱的 List
     */

    public List<String> getAllCacheName() {
        List<String> listAllCacheNames = new ArrayList<>(cacheManager.getCacheNames());
        return listAllCacheNames;
    }

    /**
     * 取得指定 CacheName 底下所有 Cache Key 的列表(best-effort)。
     *
     * 使用 Redis keys 操作可能對生產環境效能產生影響;於大資料量場景建議改用 SCAN 或其他可靠的方法。
     * 
     * 本方法會嘗試以下列順序取得 Key:
     * 1. 若底層 nativeCache 實作為 Spring 的 RedisOperations,則以 ops.keys(cacheName + "*")
     * 嘗試取得。
     * 2. 若 nativeCache 為 Map-like 的實作,則直接列舉其 keySet。
     * 3. 若 Cache 實作為 RedisCache,則利用注入的 RedisTemplate 對 key pattern (cacheName::*) 做
     * keys 查詢,並去除前綴回傳實際的 key。
     * 以上任一步驟發生例外或不支援時,會以 best-effort 忽略該錯誤並回傳已成功蒐集到的 keys。
     *
     * @param cacheName 要列舉的 Cache 名稱(若為空白或找不到 Cache,回傳空 List)
     * @return 指定 CacheName 下的所有 Key 字串列表(若無或無法取得則為空 List)
     * 
     */

    @SuppressWarnings({ "unchecked" })
    public List<String> getAllCacheNameKeys(String cacheName) {

        List<String> listCacheKey = new ArrayList<>();
        if (StringUtils.isBlank(cacheName)) {
            return listCacheKey;
        }

        Cache oneCache = cacheManager.getCache(cacheName);
        if (oneCache == null) {
            return listCacheKey;
        }

        Object nativeCache = oneCache.getNativeCache();
        // if (nativeCache == null) {
        // return listCacheKey;
        // }

        // If the native cache is a RedisTemplate / RedisOperations, use keys(...)
        if (nativeCache instanceof org.springframework.data.redis.core.RedisOperations) {
            org.springframework.data.redis.core.RedisOperations<Object, Object> ops = (org.springframework.data.redis.core.RedisOperations<Object, Object>) nativeCache;
            try {
                java.util.Set<Object> keys = ops.keys(cacheName + "*");
                if (keys != null) {
                    for (Object k : keys) {
                        listCacheKey.add(String.valueOf(k));
                    }
                }
            } catch (Exception e) {
                // best-effort: ignore failures (e.g. keys not supported)
            }
            return listCacheKey;
        }

        // If native cache is a Map-like (in-memory) implementation
        if (nativeCache instanceof java.util.Map) {
            for (Object k : ((java.util.Map<?, ?>) nativeCache).keySet()) {
                listCacheKey.add(String.valueOf(k));
            }
            return listCacheKey;
        }

        // Best-effort: try to extract keys from RedisCache internals via reflection
        if (oneCache instanceof RedisCache) {
            try {
                String pattern = cacheName + "::*";
                Set<String> keys = redisTemplate.keys(pattern);
                keys.forEach(x -> {
                    String keyOnly = x.replaceFirst(cacheName + "::", "");
                    listCacheKey.add(keyOnly);

                });
            } catch (Exception e) {
                // ignore
            }
        }

        return listCacheKey;
    }

    /**
     * 取得指定 CacheName 的所有 Key 與 Value 組成的 Map。
     *
     * 會先以 getAllCacheNameKeys 列出所有 key,然後逐一呼叫 getCacheKeyValue 取得對應的 value,組成 Map
     * 回傳。
     * 若某些 key 在取得 value 時發生例外或不存在,行為依賴 getCacheKeyValue 的實作(可能會拋出 NullPointerException)。
     *
     * @param cacheName 要查詢的 Cache 名稱(不可為 null)
     * @return 由 key -> value 組成的 Map(若該 Cache 沒有任何 key 則回傳空 Map)
     * @throws NullPointerException 當 cacheName 為 null 時拋出
     */
    public Map<String, Object> getCacheAllKeyValueMap(String cacheName) {

        Map<String, Object> mapAllCacheKeyValue = new HashMap<>();

        if (cacheName == null) {
            throw new NullPointerException("Cache is null");
        }
        List<String> listCacheKey = getAllCacheNameKeys(cacheName);
        for (String oneKey : listCacheKey) {
            Object oneValue = getCacheKeyValue(cacheName, oneKey);
            mapAllCacheKeyValue.put(oneKey, oneValue);
        }

        return mapAllCacheKeyValue;
    }

    /**
     * 取得指定 CacheName 與 Key 的資料 Object。
     * 此方法目前以嚴格的 NullPointerException 來表示「不存在」,呼叫端應注意此行為以避免未預期的例外。
     *
     * @param cacheName 要查詢的 Cache 名稱(不可為 null)
     * @param cacheKey  要查詢的 Cache Key
     * @return 查詢到的物件,若未找到則會拋出 NullPointerException(呼叫端可依實際需求改為回傳 null)
     * @throws NullPointerException 當 cacheName 為 null、找不到對應的 Cache,或找不到指定的 key 時拋出
     */
    @SuppressWarnings("null")
    public Object getCacheKeyValue(String cacheName, String cacheKey) throws NullPointerException {
        if (cacheName == null) {
            throw new NullPointerException("Cache is null");
        }
        Cache oneCache = cacheManager.getCache(cacheName);
        if (oneCache == null) {
            throw new NullPointerException("Cache Name not found");
        }
        if (cacheManager.getCache(cacheName).get(cacheKey) == null) {
            throw new NullPointerException("Cache Key not found");
        }
        return cacheManager.getCache(cacheName).get(cacheKey).get();
    }

    /**
     * 移除系統中所有 Cache 的資料。
     *
     * 會迭代 CacheManager 中的所有 Cache 名稱,並逐一呼叫 evictOneCaches 進行清除。
     * 若某個 Cache 名稱為 null 或無法取得底層 Cache,將會跳過該項並繼續處理其他 Cache。
     * 此操作會清除所有註冊在 CacheManager 底下的快取內容,請於必要時小心使用(例如系統初始化或管理後台)。
     */
    public void evictAllCaches() {
        for (String oneCacheName : getAllCacheName()) {
            if (oneCacheName == null) {
                continue;
            }
            evictOneCaches(oneCacheName);
        }
    }

    /**
     * 移除指定 CacheName 的所有資料。
     *
     * 若 cacheName 為 null 或對應的 Cache 不存在或其 nativeCache 為 null,則不做任何處理。
     * 否則會呼叫 Cache.clear() 清空該 Cache 內容。
     *
     * 若 CacheManager 上不存在該名稱,本方法不會自動建立新的 Cache,僅為保守跳過處理。
     * 
     * @param cacheName 要清除的 Cache 名稱(若為 null 則直接回傳)
     */
    public void evictOneCaches(String cacheName) {
        if (cacheName == null) {
            return;
        }
        Cache oneCache = cacheManager.getCache(cacheName);
        if (oneCache == null || oneCache.getNativeCache() == null) {
            return;
        }

        oneCache.clear();
    }

    /**
     * 移除指定 CacheName 與 Key 的資料。
     *
     * 若 cacheName 為 null、找不到對應的 Cache、或該 key 不存在,則不會做任何操作。
     * 若 key 存在則呼叫 Cache.evict(cacheKey) 以移除該筆資料。
     * 此方法在移除前會做存在性檢查,故呼叫 evict 時較為保守;若底層 Cache 的存在檢查成本高,可能會多做一次查詢。
     * 
     * @param cacheName 要操作的 Cache 名稱(若為 null 則直接回傳)
     * @param cacheKey  要移除的 Cache Key
     * 
     */
    @SuppressWarnings("null")
    public void evictOneCachesKey(String cacheName, String cacheKey) {
        if (cacheName == null) {
            return;
        }

        Cache oneCache = cacheManager.getCache(cacheName);
        if (oneCache == null) {
            return;
        }
        if (oneCache.get(cacheKey) == null) {
            return;
        }
        if (oneCache.get(cacheKey).get() == null) {
            return;
        }
        oneCache.evict(cacheKey);
    }

}

三、單元測試

RedisCacheUtilsTest
位置:src/test/java/tw/lewishome/webapp/base/cache/redis/RedisCacheUtilsTest.java

設定與初始化

  • 使用 @SpringBootTest 進行整合測試。
  • @TestPropertySource(properties = {...}) 覆寫 Redis 連線參數:
    • REDIS_HOST=localhostREDIS_PORT=6379REDIS_LIFE_HOUR=1RRDIS_PASSWORD= (空密碼)
  • @BeforeEach 初始化測試資料:類似 CaffeineCacheUtilsTest,在兩個 Cache 各放入兩筆 Key/Value。

測試用例摘要

測試方法 目的 預期結果
testAddCatchValue_BlankKeyOrName 驗證空白參數拒絕 拋出 NullPointerException
testAddCatchValue_Success 驗證新增成功 回傳 true
testAddCatchValue_DuplicateSuccess 驗證重複鍵行為 相同鍵覆寫,key 計數為 3
testGetAllCacheName 驗證取得所有 Cache 名稱 包含 setUp 新增的 cache
testGetAllCacheNameKeys 驗證取得指定 Cache 的所有 key 使用 Redis keys 列舉或 best-effort 策略
testEvictAllCaches 驗證清空所有 cache 所有 cache 內容清空
testEvictOneCaches_NullCacheName 驗證 null cache 名稱處理 不拋出異常
testEvictOneCaches_CacheNotFound 驗證不存在 cache 的清除 不拋出異常
testEvictOneCaches_CacheFound 驗證清除指定 cache 成功清空
testEvictOneCachesKey_CacheNameNotFound 驗證不存在 cache 的單鍵清除 不拋出異常
testEvictOneCachesKey_CacheKeyNotFound 驗證不存在 key 的清除 不拋出異常
testGetCacheAllKeyValueMap 驗證取得所有 Key/Value 對 返回正確的 Map 內容
testGetCatchValue_NullCacheName 驗證 null cache 名稱查詢 拋出 NullPointerException
testGetCatchValue_CacheNotFound 驗證不存在 cache 查詢 拋出 NullPointerException
testGetCatchValue_KeyNotFound 驗證不存在 key 查詢 拋出 NullPointerException
testGetCatchValue_KeyFound 驗證成功取得快取值 返回正確的快取值

重點觀察

  • 測試結構與 CaffeineCacheUtilsTest 類似,確保 API 一致性。
  • 註冊輸出語句(System.out.println())用於除錯,便於觀察 Redis 操作行為。
  • 若環境無 Redis 伺服器,@BeforeEach 可能無法成功連線;此時建議使用 embedded Redis 或 Mock。
  • getAllCacheNameKeys() 在 Redis 環境會使用 RedisTemplate.keys() 進行 best-effort 列舉。
package tw.lewishome.webapp.base.cache.redis;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import java.util.*;
import static org.junit.jupiter.api.Assertions.*;

//啟動SpringBootTest
@SpringBootTest
// 指定適用Properties (這裡指定專案的properties 檔案,已可以另外指定 test專用的properties 檔案)
@TestPropertySource(locations = "classpath:application.properties")
class RedisCacheUtilsTest {

    @Autowired
    private RedisCacheUtils redisCacheUtils;

    @BeforeEach
    void setUp() {
        redisCacheUtils.addCatchValue("testCatch1", "testCatchKey1", "testCatchValue1");
        redisCacheUtils.addCatchValue("testCatch1", "testCatchKey2", "testCatchValue2");
        redisCacheUtils.addCatchValue("testCatch2", "testCatchKey1", "testCatchValue3");
        redisCacheUtils.addCatchValue("testCatch2", "testCatchKey2", "testCatchValue4");

    }

    @Test
    void testAddCatchValue_BlankKeyOrName() {

        NullPointerException ex1 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.addCatchValue("", "key", "value"));
        assertNotNull(ex1.getMessage()); // assert NPE was thrown and contains a message

        NullPointerException ex2 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.addCatchValue("cache1", "", "value"));
        assertNotNull(ex2.getMessage()); // assert NPE was thrown and contains a message

        NullPointerException ex3 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.addCatchValue(" ", "key", "value"));
        assertNotNull(ex3.getMessage()); // assert NPE was thrown and contains a message

        NullPointerException ex4 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.addCatchValue("cache1", " ", "value"));
        assertNotNull(ex4.getMessage()); // assert NPE was thrown and contains a message

    }

    @Test
    void testAddCatchValue_Success() {
        Boolean result = redisCacheUtils.addCatchValue("cache1", "key", "value" );
        assertTrue(result);
        Boolean result2 = redisCacheUtils.addCatchValue("cache1", "key2", "");
        assertTrue(result2);
        Boolean result3 = redisCacheUtils.addCatchValue("cache1", "key3", null);
        assertTrue(result3);
    }

    @Test
    void testAddCatchValue_DuplicateSuccess() {
        List<String> listResult = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertTrue(listResult.size()>0); // 兩個初始值

        Boolean result1 = redisCacheUtils.addCatchValue("testCatch1", "key", "value");
        assertTrue(result1);
        List<String> listResult1 = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertEquals(3, listResult1.size());

        Boolean result2 = redisCacheUtils.addCatchValue("testCatch1", "key", "value");
        assertTrue(result2);
        List<String> listResult2 = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertEquals(3, listResult2.size()); // duplicate 不會增加

        Boolean result3 = redisCacheUtils.addCatchValue("cache1", "key", "value");
        assertTrue(result3);
        List<String> listResult3 = redisCacheUtils.getAllCacheNameKeys("cache1");
        assertEquals(1, listResult3.size()); // cache1 新增第一筆

    }

    @Test
    void testGetAllCacheName() {
        List<String> result = redisCacheUtils.getAllCacheName();    
        assertTrue(result.contains("testCatch1")); // from setUp
        assertTrue(result.contains("testCatch2")); // from setUp
    }


    @Test
    void testGetAllCacheNameKeys() {
        List<String> result = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        System.out.println(result);
        assertTrue(result.contains("testCatchKey1"));
        assertTrue(result.contains("testCatchKey2"));
        List<String> result1 = redisCacheUtils.getAllCacheNameKeys("testCatchKey1");
        assertEquals(0, result1.size()); // from config file has one default key

    }

    @Test
    void testEvictAllCaches() {
        redisCacheUtils.evictAllCaches();
        List<String> result = redisCacheUtils.getAllCacheName();
        System.out.println(result); //Redis 已被清除,但 CacheManager 仍保留 Cache 名稱
        assertTrue(result.size()>0); 
        List<String> listResult1 = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertEquals(0, listResult1.size()); 
        List<String> listResult2 = redisCacheUtils.getAllCacheNameKeys("testCatch2");
        assertEquals(0, listResult2.size());
        List<String> listResult3 = redisCacheUtils.getAllCacheNameKeys("catchUserMenuItems");
        assertEquals(0, listResult3.size());
        List<String> listResult4 = redisCacheUtils.getAllCacheNameKeys("catchUserAuthSeq");
        assertEquals(0, listResult4.size());
    }

    @Test
    void testEvictOneCaches_NullCacheName() {
        redisCacheUtils.evictOneCaches(null); // should be ignored (evictOneCaches with null)
        List<String> result = redisCacheUtils.getAllCacheName();
        System.out.println(result); //Redis 已被清除,但 CacheManager 仍保留 Cache 名稱
        assertTrue(result.size()>0); // config file 兩個 + setUp 兩個
    }

    @Test
    void testEvictOneCaches_CacheNotFound() {
        redisCacheUtils.evictOneCaches("cache1"); // cacheManager will add this cacheName but not in redis 
        List<String> result = redisCacheUtils.getAllCacheName();
        System.out.println(result); //Redis 不會增加,但 CacheManager 仍保留 Cache 名稱
        assertTrue(result.size()>0); // config file 兩個 + setUp 兩個
        // Should not throw
    }

    @Test
    void testEvictOneCaches_CacheFound() {
        List<String> listResult1 = redisCacheUtils.getAllCacheNameKeys("testCatch2");
        System.out.println(listResult1); //before evict
        redisCacheUtils.evictOneCaches("testCatch2"); // caches should be evicted (cleaned) but cache names remain
        List<String> result = redisCacheUtils.getAllCacheName();
        System.out.println(result); //after evict
        List<String> listResult2 = redisCacheUtils.getAllCacheNameKeys("testCatch2");
        System.out.println(listResult2); //after evict
        assertEquals(0, listResult2.size()); // after evict
    }

    @Test
    void testEvictOneCachesKey_CacheNameNotFound() {
        redisCacheUtils.evictOneCachesKey("cache1", "testCatchKey3");
        List<String> result = redisCacheUtils.getAllCacheName();
        assertTrue(result.contains("cache1")); // cacheManager will add this cacheName but no data inside
    }

    @Test
    void testEvictOneCachesKey_CacheKeyNotFound() { // cacheName found but key not found not throws exception NPE
        List<String> listResult1 = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertTrue(listResult1.size()>0); // config file 兩個 + setUp 兩個
        redisCacheUtils.evictOneCachesKey("testCatch1", "NoTestCatchKey1");
        List<String> result = redisCacheUtils.getAllCacheName();
         assertTrue(result.size()>0); // config file 兩個 + setUp 兩個
        List<String> listResult2 = redisCacheUtils.getAllCacheNameKeys("testCatch1");
        assertTrue(listResult2.size()>0); // config file 兩個 + setUp 兩個
    
    }

    @Test
    void testGetCacheAllKeyValueMap() {
        // Test
        Map<String, Object> result = redisCacheUtils.getCacheAllKeyValueMap("testCatch1");
        // Verify
        assertNotNull(result);
        assertTrue(result.size()>0); // config file 兩個 + setUp 兩個
        assertEquals("testCatchValue1", (String) result.get("testCatchKey1"));
        assertEquals("testCatchValue2", (String) result.get("testCatchKey2"));
    }

    @Test
    void testGetCatchValue_NullCacheName() { // cacheName null already control in function return null
        NullPointerException ex3 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.getCacheKeyValue(null, "testCatchKey1"));
        assertNotNull(ex3.getMessage()); // assert NPE was thrown and contains a message
    }

    @Test
    void testGetCatchValue_CacheNotFound() { // cacheName not exist already control in function return null
        NullPointerException ex3 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.getCacheKeyValue("cache1", "testCatchKey1"));
        assertNotNull(ex3.getMessage()); //
    }

    @Test
    void testGetCatchValue_KeyNotFound() {
        NullPointerException ex3 = assertThrows(NullPointerException.class,
                () -> redisCacheUtils.getCacheKeyValue("testCatch1", "key1"));
        assertNotNull(ex3.getMessage()); //
    }

    @Test
    void testGetCatchValue_KeyFound() {
        Object result = redisCacheUtils.getCacheKeyValue("testCatch1", "testCatchKey1");
        assertEquals("testCatchValue1", result);
    }

}

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言