Spring 框架支援透明地為應用程式新增快取。其核心是將快取應用於方法,從而根據快取中可用的資訊減少程式Method的執行次數。快取應用,不會干擾呼叫者。只要使用@EnableCaching註解啟用快取支持,Spring Boot 就會自動配置快取基礎架構,當Method 被標註為 @Cacheable 後,Spring 會在第一次執行該Method時執行實際的邏輯,並將返回結果存入快取中。隨後的執行該Method將直接從快取中返回結果,而不會再次執行方法,這樣可以顯著提高應用程式的性能。配合多主機分散環境中執行,開發應用系統配置兩層快取,準備提供 Caffeine(JVM in-memory)與 Redis(分散式)快取的設定說明、使用範例、最佳實務,以及針對專案現有 util 的測試範例(CaffeineCacheUtilsTest、RedisCacheUtilsTest)。
tw.lewishome.webapp.base.cache.caffeine,主要檔案:CaffeineConfiguration、CaffeineCacheUtils。tw.lewishome.webapp.base.cache.redis,主要檔案:RedisConfiguration(Lettuce、RedisTemplate、redisCacheManager)。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_HOST、REDIS_PORT、RRDIS_PASSWORD)、redisTemplate()(StringRedisSerializer + GenericJackson2JsonRedisSerializer)、redisCacheManager(預設 TTL 由 REDIS_LIFE_HOUR 決定)。REDIS_HOST(預設 redis.lewishome.tw)、REDIS_PORT(預設 6379)、RRDIS_PASSWORD、REDIS_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 將該值寫入本地快取。重要細節與注意事項:
MultiCacheInterceptor 直接檢查 cache.getClass() == RedisCache.class 來判斷來源為 Redis,若專案中使用自訂 Cache 實作或代理類別,需確認判斷邏輯是否涵蓋情況,否則可能無法觸發同步行為。putIfAbsent,因此不會覆寫本地已存在的資料;如果需要更強的一致性策略(例如每次都覆寫或比對版本),需另外實作邏輯。範例(行為示意):
MultiCacheInterceptor 將值放入本地 Caffeine → 回傳結果給 caller。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;
}
}
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 資料 |
關鍵特性
RedisOperations,使用 ops.keys(cacheName + "*")。Map 實作(如 Caffeine),直接遍歷 keySet。RedisCache,透過 RedisTemplate.keys(cacheName + "::*") 並移除前綴。與 CaffeineCacheUtils 的區別
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=localhost、REDIS_PORT=6379、REDIS_LIFE_HOUR=1、RRDIS_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 |
驗證成功取得快取值 | 返回正確的快取值 |
重點觀察
System.out.println())用於除錯,便於觀察 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);
}
}