1. 程式人生 > 其它 >如何使用Redis做快取

如何使用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 使用測試

  1. 我們設定一下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
  1. 上面我們家了個攔截器,在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/**");
    }
}
  1. 對快取的操作我們分別在通知和攔截其中都已經實現,那麼我們就可以使用了,在我們的介面方法中使用@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的文章。
這裡我們就選用環繞通知,因為一個環繞通知就完全解決我們的快取問題。使得快取面可以縮小到每個方法上。

  1. 實現快取攔截的切入點 -- 註解方法/類
    我們可以直接在AOP中配置切入點,我們使用的是通過註解來判斷是否快取及其快取策略,恰好AOP同樣支援。
    如果我們需要對整個類進行攔截快取,我們的AOP同樣可以完美實現。(我的DEMO中就不再細說了,我只說一下方法上註解,關於註解放到類上自己琢磨一下,道理都是一樣的)
    所以說,我們通過AOP來繫結具體的攔截方法
  2. 實現快取 -- 環繞通知
    AOP面向切面程式設計是非常靈活的,我就特別喜歡環繞通知。
    選擇環繞通知因為:1、一個方法可以滿足我們的現在做快取的需求;2、方法執行前後可控;3、可獲取更多的引數,包括但不侷限於目標方法、形參、實參、目標類等;4、擁有更全面的引數就可以至此更全面的Spel表示式;5、可直接獲取方法返回值;等等
    我們可以在執行方法前判斷是否存在快取,不存在快取我們再繼續執行方法,否則直接返回Redis中的快取資料了。
  3. 快取靈活性 -- 註解變數及其Spel表示式
    像CacheAble一樣支援Spel表示式其實就是為了滿足更多的業務需求。比如自定義快取key、設定不同的快取時間、設定快取條件和不快取條件、設定更新快取條件等等。所以這裡需要使用註解中的一些東西去動態的判斷快取邏輯。
    我先舉個例子:使用spel自定義快取key。如果有興趣,可以根據這個繼續擴充套件。

3.3.3 具體實現

邏輯很簡單:

  1. 環繞通知前, 解析快取Key, 判斷Redis中是否存在快取
  2. 不存在快取就執行目標方法
  3. 獲取到方法執行結果, 進行快取
  4. 返回此次結果
@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只求產品不改需求.

  1. 麻煩!!! 不管多少程式碼, 不管自己的邏輯有多麼完美, 但是還是要自己寫啊, 萬一改需求了這個快取邏輯行不通了呢, 程式設計師事情很多的好吧.
  2. 懶, 誰也想不起來那麼多的業務邏輯, 老闆也不會給你太多時間讓你去開發個靈活的“框架??”
  3. 有沒有更好的方法呢, 就那種配置配置就能使用的那種, 不用擔心出現bug的那種, 即使出現了bug能推出去的那種, 特別特別好使用的那種, 反正就不是我寫的程式碼bug就不是我的那種. 反正老闆也是隻看結果.
  4. 如果你使用的是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){}