如何使用Redis做快取
如何使用Redis做快取
我們都知道Redis作為NoSql資料庫的代表之一,通常會用來作為快取使用。也是我在工作中通常使用的快取之一。
1、我們什麼時候快取需要用到Redis?
我認為,快取可以分為兩大類:本地快取和分散式快取。當我們一個分散式系統就會考慮到快取一致性的問題,所以需要使用到一個快速的、高併發的、靈活的儲存服務,那麼Redis就能很好的滿足這些。
- 本地快取:
即把快取資訊儲存到應用記憶體內部,不能跨應用讀取。所以這樣的快取的讀寫效率上是非常高的,因為節省了http的呼叫時間。問題是不能跨服務讀取,在分散式系統中可能會找成每個機器快取內容不同的問題。 - 分散式快取:
即把快取內容儲存到單獨的快取系統中,當呼叫時,去指定快取服務取資料,因此就不會出現本地快取的多系統快取資料不同的問題。
SpringBoot連線Redis配置(本來懶得寫的, 但是我還是追求完美一點):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.4.2</version> </dependency>
RedisClient我使用的是SpringBoot2.0後自帶的lettuce框架,而並非jredis。
spring.redis.database=0 # Redis伺服器地址 spring.redis.host=81.70.xx.xx記得改嘍,如果沒有,可以私信我,我吧我的告訴你 # Redis伺服器連線埠 spring.redis.port=6379 spring.redis.timeout=5000 # Redis伺服器連線密碼(預設為空) spring.redis.password=zxxx spring.redis.lettuce.pool.max-active=8 spring.redis.lettuce.pool.min-idle=1 spring.redis.lettuce.pool.max-idle=8 spring.redis.lettuce.pool.max-wait=500ms spring.redis.lettuce.shutdown-timeout=100ms
2、 快取雛形 - 根據業務邏輯手擼程式碼
在不需要大面積使用快取的系統中,我們通常把Redis作為一種中間工具去使用。需在程式碼邏輯中加入自己的判斷。
public String baseCache(String name) {
if(StringUtils.isBlank(name)){
logger.error("Into BaseCache Service, Name is null.");
return null;
}
logger.info("Into BaseCache Service, {}", name);
//手動加入快取邏輯
String value = stringRedisTemplate.opsForValue().get("cache_sign:" + name);
if(!StringUtils.isBlank(value)){
return value;
}
else{
value = String.valueOf(++BASE_CACHE_SIGN);
stringRedisTemplate.opsForValue().set("cache_sign:" + name, value, 60, TimeUnit.SECONDS);
return String.valueOf(BASE_CACHE_SIGN);
}
}
3、通用快取 - 使用Aop或者Interceptor實現
個別介面或方法我們可以手擼程式碼,但是不管是後期維護還是程式碼的通用性都是比較侷限的。所以與其在業務邏輯中增加判斷邏輯,不如寫一個通用的。
3.1 先定義一個註解
我們通過這個註解來區別方法是否需要快取,註解放到方法上,此方法的返回結果將會被快取。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface UseCache {}
3.2 使用SpringMvc的攔截器,對介面結果進行快取。
我們將從Redis取快取結果提取到攔截器中,這樣我們就可以只通過一個註解去標識是否執行快取操作。
3.2.1 攔截器: HandleInterceptorAdapter
攔截器的作用我在這裡就不過多的說明。如果在攔截器中發現此介面包含UseCache註解,我們需要檢查Redis是否存在快取,如果存在快取,則直接返回其值即可。
程式碼如下:
/**
* 快取攔截器
*/
@Component
public class CustomCacheInterceptor extends HandlerInterceptorAdapter {
private static final Logger logger = LoggerFactory.getLogger(CustomCacheInterceptor.class);
/** RedisClient */
private final StringRedisTemplate stringRedisTemplate;
@Autowired
public CustomCacheInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 我們只需要實現preHandle方法即可,此方法會在介面呼叫前被呼叫,所以可以在這裡判斷快取,如果存在快取,直接返回即可。
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.info("Into Controller Before. This handler :{}", handler.getClass().getName());
if (handler instanceof HandlerMethod) {
HandlerMethod method = (HandlerMethod) handler;
//判斷是否存在我們定義的快取註解
boolean useCache = method.hasMethodAnnotation(UseCache.class);
//我們只對json進行快取,其他的同理,所以再判斷一下這個Controller是哪種介面方式。(包名+方法名+引數)
boolean methodResponseBody = method.hasMethodAnnotation(ResponseBody.class);
boolean classResponseBody = method.getBeanType().isAnnotationPresent(ResponseBody.class);
boolean restController = method.getBeanType().isAnnotationPresent(RestController.class);
if (useCache && (methodResponseBody || classResponseBody || restController)) {
logger.info("This Method:{} Is UseCache", method.getMethod().getName());
//我們使用一個工具類去生成這個方法的一個唯一key,使用此key當作redisKey。
String cacheKey = CacheUtils.keySerialization(request, method.getMethod());
//從Redis中取資料
String responseValue = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNoneBlank(responseValue)) {
//此方法存在快取,且拿到了快取值,所以直接返回給客戶端即可,不需要再繼續下一步
PrintWriter writer = response.getWriter();
writer.append(responseValue);
writer.flush();
writer.close();
response.flushBuffer();
return false;
}
}
}
return true;
}
}
3.2.2 ResponseBodyAdvice 和 @ControllerAdvice
上述中我們在攔截器中攔截了使用快取且存在快取的請求,直接返回快取內容。但是還存在一個問題: 我們從哪個地方將資料寫入Redis?
我之前考慮再重寫HandleInterceptorAdapter.postHandle(...)方法,然後在處理完成Controller後,攔截處理結果,將結果放入Redis。但是出現以下問題:
- 雖然能夠正常呼叫postHandle(...)方法,但是大多進行快取的都是ResponseBody資料,這樣的資料並不會存放到ModleAndView中,當然也不會在DispatcherServlet中處理ModleAndView。所以並不能從ModleAndView中獲取執行結果。
- 我打算從response中找到要返回到客戶端的資料。但是從上述方法我們就可以知道,response傳送資料是使用流的方式,當Controller執行結束之後,postHandle之前就把資料寫入了流中。如果重置輸出流太過麻煩。
所以我不能繼續使用此攔截器去獲取結果。
解決:在呼叫完Controller之後,response寫出之前,Springboot會呼叫一個通知:org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice。所以我們就可以實現這個介面去對response的body資料進行處理。
程式碼如下:
/**
* SpringBoot提供RestControllerAdvice註解,此註解為使用@ResponseBody的Controller生成一個Aop通知。
* 然後我們實現了ResponseBodyAdvice的方法:supports(...) 和 beforeBodyWrite(...)
*/
@RestControllerAdvice
public class ControllerResponseBody implements ResponseBodyAdvice<Object> {
private static final Logger logger = LoggerFactory.getLogger(ControllerResponseBody.class);
/** RedisClient */
private final StringRedisTemplate redisTemplate;
@Autowired
public ApiResponseBody(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 此方法返回boolean型別值,主要是通過返回值確認是否走beforeBodyWrite方法
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
logger.info("Into supports");
Method method = returnType.getMethod();
if(method != null){
logger.info("Find This method use cache.");
return method.isAnnotationPresent(UseCache.class);
}
return false;
}
/**
* 這個方法呼叫在response響應之前,且方法引數是包含Controller的處理結果的。
*/
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
try{
//將返回值轉換為json,方便儲存到redis。
String value = JsonUtils.toGJsonString(body);
// 拼接key
String cacheKey = CacheUtils.keySerialization(request, returnType.getMethod());
if(StringUtils.isNoneBlank(cacheKey)){
// 設定快取60s
redisTemplate.opsForValue().set(cacheKey, value, 60, TimeUnit.SECONDS);
}
logger.info("cache controller return content.");
}catch (Exception e){
logger.error("Cache Exception:{}", e.getMessage(), e);
}
return body;
}
}
3.2.3 使用測試
- 我們設定一下redis,使用SpringBoot預設的Lettuce,因為設定比較簡便,而且呢,據說效能也不錯,畢竟能讓Springboot預設至此,不會差到哪裡去
# Redis資料庫索引(預設為0)
spring.redis.database=0
# Redis伺服器地址
spring.redis.host=172.0.0.1
# Redis伺服器連線埠
spring.redis.port=6379
spring.redis.timeout=500
# Redis伺服器連線密碼(預設為空)
spring.redis.password=xxx
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.min-idle=1
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.max-wait=500ms
spring.redis.lettuce.shutdown-timeout=100ms
- 上面我們家了個攔截器,在Spring中我們通過配置web.xml去註冊攔截器,在SpringBoot中更加簡單
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private CustomCacheInterceptor cacheInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(cacheInterceptor).addPathPatterns("/cache/**");
}
}
- 對快取的操作我們分別在通知和攔截其中都已經實現,那麼我們就可以使用了,在我們的介面方法中使用@UseCache註解。
@RestController
@RequestMapping("/cache")
public class CacheController {
private static final Logger logger = LoggerFactory.getLogger(CacheController.class);
@Resource
private CacheService cacheService;
/**
* 使用@UseCache註解
*/
@UseCache
@RequestMapping("interceptorCache")
private String interceptorCache(String name){
logger.info("Into BaseCache Controller, {}", name);
String result = cacheService.incr(name);
return "OK" + " - " + name + " - " + result;
}
}
我就不再複製結果了,自己試一試吧
3.2.4 反饋
這個是一個最基礎的快取了,可以通過自己需求去擴充套件:如使用spel為註解UseCache自定義快取key、自定義快取時間等等。
- 我們使用攔截器的方法有一個侷限,即只能對請求的整個介面去做快取,但是有些時候我們的需求不是對整個介面進行快取,可能只想對service快取,可能想對某個sql快取。所以侷限性還是存在的。
3.3 使用Spring Aop + 註解實現快取
上面我們說到了使用攔截器實現時,只能對整個介面進行快取。所以我們換一種思路:面向切面程式設計,即使用AOP。
SpringBoot Aop專用包:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
3.3.1 我們對上一個的快取再優化一下吧
通常我們使用快取會存在各種不同的需求,如快取key,快取時間,快取條件等等。所以我們學著CacheAble註解,使用Spel表示式自定義key和超時時間。
/**
* 自定義註解使用快取
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface UseAopCache {
/**
* 自定義快取Key,支援spel表示式
*/
String customKey() default "" ;
/**
* 超時時間
*/
long timeOut() default 60000;
}
3.3.2 AOP解決思路
Spring AOP相對與攔截器來說提供了更好的引數支援,所以我們能夠更加全面的進行快取操作。Aop中有前置通知、後置通知、返回通知、異常通知、環繞通知這幾種,具體的區別就不在這裡仔細講解了,關注後續我的文件吧,我會寫一個專門介紹Aop的文章。
這裡我們就選用環繞通知,因為一個環繞通知就完全解決我們的快取問題。使得快取面可以縮小到每個方法上。
- 實現快取攔截的切入點 -- 註解方法/類
我們可以直接在AOP中配置切入點,我們使用的是通過註解來判斷是否快取及其快取策略,恰好AOP同樣支援。
如果我們需要對整個類進行攔截快取,我們的AOP同樣可以完美實現。(我的DEMO中就不再細說了,我只說一下方法上註解,關於註解放到類上自己琢磨一下,道理都是一樣的)
所以說,我們通過AOP來繫結具體的攔截方法 - 實現快取 -- 環繞通知
AOP面向切面程式設計是非常靈活的,我就特別喜歡環繞通知。
選擇環繞通知因為:1、一個方法可以滿足我們的現在做快取的需求;2、方法執行前後可控;3、可獲取更多的引數,包括但不侷限於目標方法、形參、實參、目標類等;4、擁有更全面的引數就可以至此更全面的Spel表示式;5、可直接獲取方法返回值;等等
我們可以在執行方法前判斷是否存在快取,不存在快取我們再繼續執行方法,否則直接返回Redis中的快取資料了。 - 快取靈活性 -- 註解變數及其Spel表示式
像CacheAble一樣支援Spel表示式其實就是為了滿足更多的業務需求。比如自定義快取key、設定不同的快取時間、設定快取條件和不快取條件、設定更新快取條件等等。所以這裡需要使用註解中的一些東西去動態的判斷快取邏輯。
我先舉個例子:使用spel自定義快取key。如果有興趣,可以根據這個繼續擴充套件。
3.3.3 具體實現
邏輯很簡單:
- 環繞通知前, 解析快取Key, 判斷Redis中是否存在快取
- 不存在快取就執行目標方法
- 獲取到方法執行結果, 進行快取
- 返回此次結果
@Aspect
@Component
public class CacheAdvice {
/** 用來解析Spel表示式, 這個是我自己實現的一個類,下面會具體詳解 */
private CacheOperatorExpression cacheOperatorExpression = new CacheOperatorExpression();
/** Redis */
private final StringRedisTemplate redisTemplate;
@Autowired
public CacheAdvice(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Pointcut(value = "execution(* com.nouser..*.*(..))")
public void cachePointcut() { }
/**
* 我們的切入點就是含有@UseAopCache註解的方法,@annotation裡填的是對應的引數的名字,Aop會自動封裝。
* 當然,我們也可以使用@annotation(com.nouser.config.annotations.UseAopCache), 這樣的話, 註解需要我們自己從joinPoint中解析。
* 同樣支援使用@Pointcut(value = "@annotation(com.nouser.config.annotations.UseAopCache)定義切面。
* 我這裡也定義了一個切面cachePointcut(), 取了並的關係, 是為了防止註解越界吧, 萬一引用的包中存在同名的註解呢.
*/
@Around(value = "cachePointcut() && @annotation(useAopCache)")
public Object aroundAdvice(ProceedingJoinPoint joinPoint, UseAopCache useAopCache) throws Throwable {
String keySpel = useAopCache.customKey();
//獲取Redis快取Key
String key = getRedisKey(joinPoint, keySpel);
//讀取redis資料快取資料
String result = redisTemplate.opsForValue().get(key);
if(StringUtils.isNoneBlank(result)){
//存在快取結果, 將快取的json轉換成Object返回
return JsonUtils.parseObject4G(result);
}
//不存在快取資料,執行方法, 獲取結果, 再放入Redis中
Object returnObject = joinPoint.proceed();
//這裡我沒有對null資料進行快取, 也可以在註解中設定對應的不快取策略
if(returnObject == null){
return returnObject;
}
// 轉換結果為Json
String cacheJson = JsonUtils.toGJsonString(returnObject);
// 將Json快取到Redis, 不要忘記重註解中獲取快取時間, 設定Redis的key過期時間
redisTemplate.opsForValue().set(key, cacheJson, useAopCache.timeOut(), TimeUnit.MILLISECONDS);
return returnObject;
}
/**
* 從joinPoint中獲取方法的上下文環境,然後從Spel表示式中解析出key
*/
private String getRedisKey(ProceedingJoinPoint joinPoint, String keySpel) {
if (StringUtils.isNoneBlank(keySpel)) {
return cacheOperatorExpression.generateKey(keySpel, joinPoint);
}
return defaultKey(joinPoint);
}
/**
* 如果沒有在註解的customKey()中設定Spel表示式, 我們總不能報錯吧, 這裡提供一個預設的Key, 資料都衝joinPoint中獲取
* packageName + ':' + methodName + '#' + param
* 為防止param中存在特殊字元, 這裡之保留[a-zA-Z0-9:#_.]
*/
private String defaultKey(ProceedingJoinPoint joinPoint) {
StringBuilder key = new StringBuilder();
String className = joinPoint.getTarget().getClass().getName();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String methodName = method.getName();
Object[] args = joinPoint.getArgs();
key.append(className).append(":").append(methodName).append("#");
if (args != null && args.length > 0) {
for (Object arg : args) {
key.append(arg).append("#");
}
}
return key.toString().replaceAll("[^a-zA-Z0-9:#_.]", "");
}
}
3.3.4 Spel表示式解析(簡單介紹一下, 具體請關注以後的部落格)
Spel表示式(趕快畫重點了, 這是個非常新奇的東西, 會有很多小妙用的), 全稱:Spring Expression Language, 類似於Struts2x中使用的OGNL表示式語言,能在執行時構建複雜表示式、存取物件圖屬性、物件方法呼叫等等,並且能與Spring功能完美整合,如能用來配置Bean定義。SpEL是單獨模組,只依賴於core模組,不依賴於其他模組,可以單獨使用。
我們主要是對註解中的自定義key進行解析, 生成快取真正key。解析Spel表示式主要需要兩個引數:解析器和上下文環境。
解析器:org.springframework.expression.spel.standard.SpelExpressionParser
上下文環境我看網上大多直接使用的StandardEvaluationContext, 但是我們在這個註解中主要是相關方法的解析, 所以建議使用StandardEvaluationContext的子類MethodBasedEvaluationContext。在Spring中解析CacheAble註解中的key同樣是使用MethodBasedEvaluationContext的子類。
MethodBasedEvaluationContext在新增上下文環境的變數時,使用了懶載入, 當我們註解中的key不使用引數時,就不再新增上下文的變數,在使用的時候才去進行懶載入.而且相對於網上的一些實現, 官方實現更加靠譜. 也更加全面.
我對MethodBaseEvaluationContent簡單做了一層封裝,註釋也很詳細,有一些需要注意的東西就看看程式碼吧. 程式碼如下:
/**
* 解析Spel表示式
*/
@Component
public class CacheOperatorExpression {
/** 這個是Spring 提供的一個方法, 為了獲取程式在執行中獲取方法的實參 */
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
/** 這裡對targetMethod做了一個快取, 防止每次都去解析重新獲取targetMethod */
private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);
/** Spel的解析器 */
private SpelExpressionParser parser;
/** 構造 */
public CacheOperatorExpression() {
this.parser = new SpelExpressionParser();
}
/** 構造 */
public CacheOperatorExpression(SpelExpressionParser parser) {
this.parser = parser;
}
public SpelExpressionParser getParser(SpelExpressionParser parser) {
return this.parser;
}
private ParameterNameDiscoverer getParameterNameDiscoverer() {
return this.parameterNameDiscoverer;
}
/** 這裡建立獲取對應的上下文環境 */
public EvaluationContext createEvaluationContext(Method method, Object[] args, Object target, Class<?> targetClass, Method targetMethod) {
/*
* rootObject,MethodBasedEvaluationContext的一個引數,可以為null,但是如果為null, 在StandardEvaluationContext構造中會設定rootObject = new TypedValue(rootObject)也就是rootObject = TypedValue.NULL;
* 這時我們在Spel表示式中就不能使用#root.xxxx獲取對應的值.
* 為了能夠使用#root我自定義了一個CacheRootObject
*/
CacheRootObject rootObject = new CacheRootObject(method, args, target, targetClass);
return new MethodBasedEvaluationContext(rootObject, targetMethod, args, getParameterNameDiscoverer());
}
/**
* 解析 spel 表示式
* @return 執行spel表示式後的結果
*/
public <T> T parseSpel(String spel, Method method, Object[] args, Object target, Class<?> targetClass, Method targetMethod, Class<T> conversionClazz) {
EvaluationContext context = createEvaluationContext(method, args, target, targetClass, targetMethod);
return this.parser.parseExpression(spel).getValue(context, conversionClazz);
}
public String generateKey(String spel, ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
Object target = joinPoint.getTarget();
Class<?> targetClass = joinPoint.getTarget().getClass();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Method targetMethod = getTargetMethod( method, targetClass);
return parseSpel(spel, method, args, target, targetClass, targetMethod, String.class);
}
/**
* 獲取targetMethod
* TargetMethod和Method?
* 我們使用joinPoint獲取的Method可能是一個介面方法,也就是我們把Aop的切點放在了介面上或介面的方法上。所以我們需要獲取到執行的對應Class上的此方法。
* eg: 我們獲取的{@code PersonBehavior.eatFood()}的Class可能是{@code ChildBehavior}或者{@code DefaultPersonBehavior}的. 他們都會對eatFood()進行覆蓋,
* 而如果切點放在Class PersonBehavior上, 那麼通過joinPoint獲取的Method實際並不是程式呼叫的Method。
* 所以我們需要通過程式呼叫的Class去反解析出真正呼叫的Method就是targetMethod.
*/
private Method getTargetMethod(Method method, Class<?> targetClass) {
AnnotatedElementKey methodKey = new AnnotatedElementKey(method, targetClass);
Method targetMethod = this.targetMethodCache.get(methodKey);
if (targetMethod == null) {
targetMethod = AopUtils.getMostSpecificMethod(method, targetClass);
if (targetMethod == null) {
targetMethod = method;
}
this.targetMethodCache.put(methodKey, targetMethod);
}
return targetMethod;
}
}
// ############################################
/**
* 自定義的RootObject, 讓spel表示式至此#root引數, #root就是對應這個Object, #root.method就是對應這個類中的Method
*/
public class CacheRootObject {
private final Method method;
private final Object[] args;
private final Object target;
private final Class<?> targetClass;
public CacheRootObject( Method method, Object[] args, Object target, Class<?> targetClass) {
this.method = method;
this.target = target;
this.targetClass = targetClass;
this.args = args;
}
/* get set方法*/
}
3.3.5 反饋
畢竟是我們自己實現的程式碼, 沒有千錘百練誰也不能說完美. 請問世間是否存在完美的程式碼,除了HelloWorld只求產品不改需求.
- 麻煩!!! 不管多少程式碼, 不管自己的邏輯有多麼完美, 但是還是要自己寫啊, 萬一改需求了這個快取邏輯行不通了呢, 程式設計師事情很多的好吧.
- 懶, 誰也想不起來那麼多的業務邏輯, 老闆也不會給你太多時間讓你去開發個靈活的“框架??”
- 有沒有更好的方法呢, 就那種配置配置就能使用的那種, 不用擔心出現bug的那種, 即使出現了bug能推出去的那種, 特別特別好使用的那種, 反正就不是我寫的程式碼bug就不是我的那種. 反正老闆也是隻看結果.
- 如果你使用的是SpringBoot, 還真有.
4、SpringBoot整合Redis快取
Redis那麼一個經典的NoSql資料庫,SpringBoot快取肯定也對它進行支援. SpringBoot的快取功能已經為我們提供了使用Redis做快取.
4.1 引入環境
上面我們已經引入了Redis,這裡我們還需要引入SpringBoot的Cache包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
4.2 查詢SpringBoot對Redis快取的支援
隨便百度一下或者google一下都能找到SpringBoot對各種快取的支援都是實現介面:org.springframework.cache.annotation.CachingConfigurer
註釋上同樣寫的大概意思就是:我們使用org.springframework.context.annotation.Configuration配置的實現類, 能夠為註解@EnableCaching實現快取解析和快取管理器, 所以, 我們只需要實現此介面, 就可以直接使用@EnableCaching註解進行快取管理.
我們可在Ide上檢視CachingConfigurer介面的子類可以看到好像並沒有關於Redis的實現。所以我們就需要手動去實現這個介面了。較好的是CachingConfigurere介面中註釋的非常清楚。大家可以看一下原始碼
/**
* Interface to be implemented by @{@link org.springframework.context.annotation.Configuration
* Configuration} classes annotated with @{@link EnableCaching} that wish or need to
* specify explicitly how caches are resolved and how keys are generated for annotation-driven
* cache management. Consider extending {@link CachingConfigurerSupport}, which provides a
* stub implementation of all interface methods.
*
* <p>See @{@link EnableCaching} for general examples and context; see
* {@link #cacheManager()}, {@link #cacheResolver()} and {@link #keyGenerator()}
* for detailed instructions.
*/
public interface CachingConfigurer {
/** 快取管理器 */
@Nullable
CacheManager cacheManager();
/** 快取解析器,註解上說是一個比快取管理器更加強大的實現. 他和cacheManager互斥, 只能存在一個, 兩個都有的話會報異常.
* 這次我使用的是CacheManager, 因為之前我嘗試CacheResolver的時候使用SimpleCacheResolver然後在CacheManager中自定義的快取過期時間不生效.然後沒有研究了, 下次研究完我再補上 */
@Nullable
CacheResolver cacheResolver();
/** key序列化方式 */
@Nullable
KeyGenerator keyGenerator();
/** 錯誤處理 */
@Nullable
CacheErrorHandler errorHandler();
}
4.3 快取管理器CacheManager
雖然SpringBoot沒有給我們實現CachingConfigurer, 但是快取管理器是已經幫助我們實現了的。我們引入了cache包後,會存在一個RedisCacheManager, 我們的快取管理器就使用它來實現.
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory).build();
我們使用RedisCacheManager提供的builder靜態方法去建立, 需要引數連結工廠, 即需要一個能夠建立Redis連結的物件, 這個物件存在於Spring容器中, 我們直接通過註解獲取即可。
我們使用redisCacheConfiguration來做一些配置, 比如key的字首、key/value的序列化方式、快取名稱和對應的快取時間等等。redis KeyValue的序列化方式:key就選用的StringRedisSerializer,而value我們大多都會選擇使用json. 這些序列化方式都是實現的RedisSerializer
/**
* 自定義Redis快取管理器
* 可以參考{@link RedisCacheConfiguration}
* 設定過期時間可參考:{@link RedisCacheConfiguration#entryTtl(java.time.Duration)}的return值
*/
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultCacheConf = RedisCacheConfiguration.defaultCacheConfig()
//設定快取key的字首生成方式
.computePrefixWith(cacheName -> profilesActive + "-" + cacheName + ":" )
// 設定key的序列化方式
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 設定value的序列化方式
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// 不快取null值,但是如果存在空值,org.springframework.cache.interceptor.CacheErrorHandler.handleCachePutError會異常:
// 異常內容: Cache 'cacheNullTest' does not allow 'null' values. Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.
// .disableCachingNullValues()
// 預設60s快取
.entryTtl(Duration.ofSeconds(60));
//設定快取時間,使用@Cacheable(value = "xxx")註解的value值
CacheTimes[] times = CacheTimes.values();
Set<String> cacheNames = new HashSet<>();
//設定快取時間,使用@Cacheable(value = "user")註解的value值作為key, value是快取配置,修改預設快取時間
ConcurrentHashMap<String, RedisCacheConfiguration> configMap = new ConcurrentHashMap<>();
for (CacheTimes time : times) {
cacheNames.add(time.getCacheName());
configMap.put(time.getCacheName(), defaultCacheConf.entryTtl(time.getCacheTime()));
}
//需要先初始化快取名稱,再初始化其它的配置。
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
//設定快取name
.initialCacheNames(cacheNames)
//設定快取配置
.withInitialCacheConfigurations(configMap)
//設定預設配置
.cacheDefaults(defaultCacheConf)
//說是與事務同步,但是具體還不是很清晰
.transactionAware()
.build();
return redisCacheManager;
}
4.4 異常處理
我們在看CachingConfigurer時, 會發現我們會獲取一個CacheErrorHandler的類, 這個類就是對快取過程中出現異常時對異常進行操作的物件.
CacheErrorHandler是一個介面,這個介面提供了對: 獲取快取異常、設定快取異常、解析快取異常、清除快取異常 這五種異常的處理.
官方給出了一個預設實現SimpleCacheErrorHandler,預設實現就像名稱一樣很簡單, 把異常丟擲, 不做任何處理, 但是如果丟擲異常,就會對我們的業務邏輯存在影響。
eg:我們的快取Redis突然宕機, 如果僅僅因為快取宕機就導致服務異常不可用那就太尷尬了,所以不建議使用預設的SimpleCacheErrorHandler, 所以我建議自己去實現這個, 我這裡選擇了打日誌的方式處理. 即使快取不可用,仍然可以走正常的邏輯去獲取. 可能這會對下游服務造成壓力,這就看你的實現了.
/**
* 異常處理介面
*/
public interface CacheErrorHandler {
void handleCacheGetError(RuntimeException exception, Cache cache, Object key);
void handleCachePutError(RuntimeException exception, Cache cache, Object key, @Nullable Object value);
void handleCacheEvictError(RuntimeException exception, Cache cache, Object key);
void handleCacheClearError(RuntimeException exception, Cache cache);
}
/** 官方預設實現 */
public class SimpleCacheErrorHandler implements CacheErrorHandler {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
throw exception;
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, @Nullable Object value) {
throw exception;
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
throw exception;
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
throw exception;
}
}
/**
* 我的實現, 效果可能和官方實現相反, 但是都沒有對異常進行處理.
*/
protected class CustomLogErrorHandler implements CacheErrorHandler{
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
String format = String.format("RedisCache Get Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());
logger.error(format, exception);
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
String format = String.format("RedisCache Put Exception:%s, cache customKey:%s, key:%s, value:%s", exception.getMessage(), cache.getName(), key.toString(), JSON.toJSONString(value));
logger.error(format, exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
String format = String.format("RedisCache Evict Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());
logger.error(format, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
String format = String.format("RedisCache Clear Exception:%s, cache customKey:%s", exception.getMessage(), cache.getName());
logger.error(format, exception);
}
}
4.5 完整程式碼
上面說了那麼多隻是為了讓大家好理解而已, 在SpringBoot專案中只需要建立一個下面的類即可.
這個依賴Redis的配置, 如何配置Redis在上面
@Configuration
@EnableCaching
public class RedisCacheConfig extends CachingConfigurerSupport {
private static final Logger logger = LoggerFactory.getLogger(RedisCacheConfig.class);
/**
* redis
*/
@Autowired
private RedisConnectionFactory connectionFactory;
/** 我自定義了一個字首, 去區分環境 */
@Value("${com.nouser.profiles.active}")
private String profilesActive;
/**
* 有點問題#######################自定義過期時間不生效
@Bean // important!
@Override
public CacheResolver cacheResolver() {
// configure and return CacheResolver instance
return new SimpleCacheResolver(cacheManager(connectionFactory));
}
*/
/**
* 設定com.example.demo.cache.RedisConfig#cacheResolver()就不在是用這個了
*/
@Bean // important!
@Override
public CacheManager cacheManager() {
// configure and return CacheManager instance
return cacheManager(connectionFactory);
}
/**
* 預設的key生成策略, 包名 + 方法名。建議使用Cacheable註解時使用Spel自定義快取key.
*/
@Bean
@Override
public KeyGenerator keyGenerator() {
return (o, method, params) -> o.getClass().getName() + ":" + method.getName();
}
/**
* 設定讀寫快取異常處理
*/
@Bean
@Override
public CacheErrorHandler errorHandler() {
logger.error("handler redis cache Exception.");
return new CustomLogErrorHandler();
}
/**
* 自定義Redis快取管理器
* 可以參考{@link RedisCacheConfiguration}
* 設定過期時間可參考:{@link RedisCacheConfiguration#entryTtl(java.time.Duration)}的return值
*/
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultCacheConf = RedisCacheConfiguration.defaultCacheConfig()
//設定快取key的字首生成方式
.computePrefixWith(cacheName -> profilesActive + "-" + cacheName + ":" )
// 設定key的序列化方式
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer()))
// 設定value的序列化方式
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer()))
// 不快取null值,但是如果存在空值,org.springframework.cache.interceptor.CacheErrorHandler.handleCachePutError會異常:
// 異常內容: Cache 'cacheNullTest' does not allow 'null' values. Avoid storing null via '@Cacheable(unless="#result == null")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.
// .disableCachingNullValues()
// 預設60s快取
.entryTtl(Duration.ofSeconds(60));
//設定快取時間,使用@Cacheable(value = "xxx")註解的value值
CacheTimes[] times = CacheTimes.values();//我把過期時間分階段做了一個enum類, 然後遍歷, 後續使用時也使用這個enum去設定時間
Set<String> cacheNames = new HashSet<>();
//設定快取時間,使用@Cacheable(value = "user")註解的value值作為key, value是快取配置,修改預設快取時間
ConcurrentHashMap<String, RedisCacheConfiguration> configMap = new ConcurrentHashMap<>();
for (CacheTimes time : times) {
cacheNames.add(time.getCacheName());
configMap.put(time.getCacheName(), defaultCacheConf.entryTtl(time.getCacheTime()));
}
//需要先初始化快取名稱,再初始化其它的配置。
RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
//設定快取name
.initialCacheNames(cacheNames)
//設定快取配置
.withInitialCacheConfigurations(configMap)
//設定預設配置
.cacheDefaults(defaultCacheConf)
//說是與事務同步,但是具體還不是很清晰
.transactionAware()
.build();
return redisCacheManager;
}
/**
* 因為預設key都是字串,就使用預設的字串序列化方式,沒毛病
*/
private RedisSerializer<String> keySerializer() {
return new StringRedisSerializer();
}
/**
* value值序列化方式
* 使用Jackson2Json的方式存入redis
* ** 注意,要快取的型別,必須有 "預設構造(無參構造)" ,否則從json2class時會報異常,提升沒有預設構造。
*/
private GenericJackson2JsonRedisSerializer valueSerializer() {
GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer();
return redisSerializer;
}
/**
* 其他集合等轉換正常,但是不知道為啥啊RespResult轉換異常
* java.lang.ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.example.demo.util.RespResult
*/
private FastJsonRedisSerializer valueSerializerFastJson(){
FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
return fastJsonRedisSerializer;
}
/**
* 自定義異常處理
*/
protected class CustomLogErrorHandler implements CacheErrorHandler{
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
String format = String.format("RedisCache Get Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());
logger.error(format, exception);
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
String format = String.format("RedisCache Put Exception:%s, cache customKey:%s, key:%s, value:%s", exception.getMessage(), cache.getName(), key.toString(), JSON.toJSONString(value));
logger.error(format, exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
String format = String.format("RedisCache Evict Exception:%s, cache customKey:%s, key:%s", exception.getMessage(), cache.getName(), key.toString());
logger.error(format, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
String format = String.format("RedisCache Clear Exception:%s, cache customKey:%s", exception.getMessage(), cache.getName());
logger.error(format, exception);
}
}
}
4.6 使用: @Cacheable
配置好了, 我們如何使用呢?
我們現在是使用的SpringBoot快取整合Redis, 所以我們只需要使用註解@Cacheable, 我們先看一下Cacheable註解, 然後說一下它如何使用.
/** 這裡只貼程式碼, 註釋自己去ide看吧, 原始碼上的註釋挺全的 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {
/** 快取名,和前面我們設定快取管理器時初始化快取名稱和配置一一對應, 如果為空, 則取預設配置 */
@AliasFor("cacheNames")
String[] value() default {};
@AliasFor("value")
String[] cacheNames() default {};
/** 設定快取的key, 每個快取key是唯一的, 我們使用Redis快取, 那麼它生成的結果就是我們的Redis Key */
String key() default "";
/** 指定key生成策略*/
String keyGenerator() default "";
/** 指定快取管理器 */
String cacheManager() default "";
/** 制定解析器 */
String cacheResolver() default "";
/** 是否走快取邏輯, 快取前進行判定, 是否走快取邏輯, 支援Spel表示式, 如果返回false, 將會跳過快取邏輯 */
String condition() default "";
/** 是否進行快取, 這個是在執行目標方法後進行判斷, 支援Spel表示式, 如果為true, 將不會對結果進行快取 */
String unless() default "";
/** 是否使用同步 */
boolean sync() default false;
}
單獨說一下sync(), 如果我們設定sync為true, 那麼我們執行到獲取快取的get方法時, 這個方法是訪問的加鎖的同步方法,只能同步呼叫,但是保證了快取失效時不會全部請求都到下游服務請求。
註解也非常清楚:參考org.springframework.data.redis.cache.RedisCache.get, 可以自己打斷點試一試, 反正這個不建議使用, 除非業務不影響業務的且需要保證下游服務的前提下.
關於Cacheable註解的使用.....我舉幾個例子吧
/**
* 快取key = packageName + ":" + methodName + "#" + #name + "#" + #id
* 如果方法結果為null 或長度 小於1 則不快取此結果
* 引數useCache = true 的時候才走快取邏輯,
*/
@Cacheable(value = "xxxx",
key = "(#root.targetClass.getName() + ':' + #root.methodName + '#' + #name + '#' + #id).replaceAll('[^0-9a-zA-Z:#._]', '')",
unless = "#result == null || #result.size() < 1",
condition = "#useCache"
)
public List<String> cache01(String name, String id, boolean useCache){}