SpringBoot如何快取方法返回值?

2023-10-24 12:00:26

Why?

為什麼要對方法的返回值進行快取呢?

簡單來說是為了提升後端程式的效能和提高前端程式的存取速度。減小對db和後端應用程式的壓力。

一般而言,快取的內容都是不經常變化的,或者輕微變化對於前端應用程式是可以容忍的。

否則,不建議加入快取,因為增加快取會使程式複雜度增加,還會出現一些其他的問題,比如快取同步,資料一致性,更甚者,可能出現經典的快取穿透、快取擊穿、快取雪崩問題。

HowDo

如何快取方法的返回值?應該會有很多的辦法,本文簡單描述兩個比較常見並且比較容易實現的辦法:

  • 自定義註解
  • SpringCache

annotation

整體思路:

第一步:定義一個自定義註解,在需要快取的方法上面新增此註解,當呼叫該方法的時候,方法返回值將被快取起來,下次再呼叫的時候將不會進入該方法。其中需要指定一個快取鍵用來區分不同的呼叫,建議為:類名+方法名+引數名

第二步:編寫該註解的切面,根據快取鍵查詢快取池,若池中已經存在則直接返回不執行方法;若不存在,將執行方法,並在方法執行完畢寫入緩衝池中。方法如果拋異常了,將不會建立快取

第三步:快取池,首先需要儘量保證快取池是執行緒安全的,當然了沒有絕對的執行緒安全。其次為了不發生快取臃腫的問題,可以提供快取釋放的能力。另外,快取池應該設計為可替代,比如可以絲滑得在使用程式記憶體和使用redis直接調整。

MethodCache

建立一個名為MethodCache 的自定義註解


package com.ramble.methodcache.annotation;
import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface MethodCache {

}


MethodCacheAspect

編寫MethodCache註解的切面實現


package com.ramble.methodcache.annotation;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Aspect
@Component
public class MethodCacheAspect {

    private static final Map<String, Object> CACHE_MAP = new ConcurrentHashMap<>();
    
    @Around(value = "@annotation(methodCache)")
    public Object around(ProceedingJoinPoint jp, MethodCache methodCache) throws Throwable {
        String className = jp.getSignature().getDeclaringType().getSimpleName();
        String methodName = jp.getSignature().getName();
        String args = String.join(",", Arrays.toString(jp.getArgs()));
        String key = className + ":" + methodName + ":" + args;
        // key 範例:DemoController:findUser:[FindUserParam(id=1, name=c7)]
        log.debug("快取的key={}", key);
        Object cache = getCache(key);
        if (null != cache) {
            log.debug("走快取");
            return cache;
        } else {
            log.debug("不走快取");
            Object value = jp.proceed();
            setCache(key, value);
            return value;
        }
    }
    
    private Object getCache(String key) {
        return CACHE_MAP.get(key);
    }
    
    private void setCache(String key, Object value) {
        CACHE_MAP.put(key, value);
    }
}


  • Around:對被MethodCache註解修飾的方法啟用環繞通知
  • ProceedingJoinPoint:通過此物件獲取方法所在類、方法名和引數,用來組裝快取key
  • CACHE_MAP:快取池,生產環境建議使用redis等可以分散式儲存的容器,直接放程式記憶體不利於後期業務擴張後多範例部署

controller


package com.ramble.methodcache.controller;
import com.ramble.methodcache.annotation.MethodCache;
import com.ramble.methodcache.controller.param.CreateUserParam;
import com.ramble.methodcache.controller.param.FindUserParam;
import com.ramble.methodcache.service.DemoService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@Tag(name = "demo - api")
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/demo")
public class DemoController {

    private final DemoService demoService;
    
    @MethodCache
    @GetMapping("/{id}")
    public String getUser(@PathVariable("id") String id) {
        return demoService.getUser(id);
    }
    
    @Operation(summary = "查詢使用者")
    @MethodCache
    @PostMapping("/list")
    public String findUser(@RequestBody FindUserParam param) {
        return demoService.findUser(param);
    }
}


通過反覆呼叫被@MethodCache註解修飾的方法,會發現若快取池有資料,將不會進入方法體。

SpringCache

其實SpringCache的實現思路和上述方法基本一致,SpringCache提供了更優雅的程式設計方式,更絲滑的快取池切換和管理,更強大的功能和統一規範。

EnableCaching

使用 @EnableCaching 開啟SpringCache功能,無需引入額外的pom。

預設情況下,快取池將由 ConcurrentMapCacheManager 這個物件管理,也就是預設是程式記憶體中快取。其中用於存放快取資料的是一個 ConcurrentHashMap,原始碼如下:


public class ConcurrentMapCacheManager implements CacheManager, BeanClassLoaderAware {

    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap(16);
    
   
    ......
    
}


此外可選的快取池管理物件還有:

  • EhCacheCacheManager

  • JCacheCacheManager

  • RedisCacheManager

  • ......

Cacheable


package com.ramble.methodcache.controller;
import com.ramble.methodcache.controller.param.FindUserParam;
import com.ramble.methodcache.service.DemoService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;

@Tag(name = "user - api")
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
public class UserController {

    private final DemoService demoService;
    
    @Cacheable(value = "userCache")
    @GetMapping("/{id}")
    public String getUser(@PathVariable("id") String id) {
        return demoService.getUser(id);
    }
    
    @Operation(summary = "查詢使用者")
    @Cacheable(value = "userCache")
    @PostMapping("/list")
    public String findUser(@RequestBody FindUserParam param) {
        return demoService.findUser(param);
    }
}


  • 使用@Cacheable註解修飾需要快取返回值的方法
  • value必填,不然執行時報異常。類似一個分組,將不同的資料或者方法(當然也可以其他維度,主要看業務需要)放到一堆,便於管理
  • 可以修飾介面方法,但是不建議,IDEA會報一個提示Spring doesn't recommend to annotate interface methods with @Cache* annotation

常用屬性:

  • value:快取名稱
  • cacheNames:快取名稱。value 和cacheNames都被AliasFor註解修飾,他們互為別名
  • key:快取資料時候的key,預設使用方法引數的值,可以使用SpEL生產key
  • keyGenerator:key生產器。和key二選一
  • cacheManager:快取管理器
  • cacheResolver:和caheManager二選一,互為別名
  • condition:建立快取的條件,可用SpEL表示式(如#id>0,表示當入參id大於0時候才快取方法返回值)
  • unless:不建立快取的條件,如#result==null,表示方法返回值為null的時候不快取

CachePut

用來更新快取。被CachePut註解修飾的方法,在被呼叫的時候不會校驗快取池中是否已經存在快取,會直接發起呼叫,然後將返回值放入快取池中。

CacheEvict

用來刪除快取,會根據key來刪除快取中的資料。並且不會將本方法返回值快取起來。

常用屬性:

  • value/cacheeName:快取名稱,或者說快取分組
  • key:快取資料的鍵
  • allEntries:是否根據快取名稱清空所有快取,預設為false。當此值為true的時候,將根據cacheName清空快取池中的資料,然後將新的返回值放入快取
  • beforeInvocation:是否在方法執行之前就清空快取,預設為false

Caching

此註解用於在一個方法或者類上面,同時指定多個SpringCache相關注解。這個也是SpringCache的強大之處,可以自定義各種快取建立、更新、刪除的邏輯,應對複雜的業務場景。

屬性:

  • cacheable:指定@Cacheable註解
  • put:指定@CachePut註解
  • evict:指定@CacheEvict註解

原始碼:


@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Caching {
    Cacheable[] cacheable() default {};

    CachePut[] put() default {};

    CacheEvict[] evict() default {};
}

相當於就是註解裡面套註解,用來完成複雜和多變的場景,這個設計相當的哇塞。

CacheConfig

放在類上面,那麼類中所有方法都會被快取

SpringCacheEnv

SpringCache內建了一些環境變數,可用於各個註解的屬性中。

  • methodName:被修飾方法的方法名

  • method:被修飾方法的Method物件

  • target:被修飾方法所屬的類物件的範例

  • targetClass:被修飾方法所屬類物件

  • args:方法入參,是一個 object[] 陣列

  • caches:這個物件其實就是ConcurrentMapCacheManager中的cacheMap,這個cacheMap呢就是一開頭提到的ConcurrentHashMap,即快取池。caches的使用場景尚不明瞭。

  • argumentName:方法的入參

  • result:方法執行的返回值

使用範例:


@Cacheable(value = "userCache", condition = "#result!=null",unless = "#result==null")
public String showEnv() { 
    return "列印各個環境變數";
 }

表示僅當方法返回值不為null的時候才快取結果,這裡通過result env 獲取返回值。

另外,condition 和 unless 為互補關係,上述condition = "#result!=null"和unless = "#result==null"其實是一個意思。


@Cacheable(value = "userCache", key = "#name")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用方法入參作為該條快取資料的key,若傳入的name為gg,則實際快取的資料為:gg->列印各個環境變數

另外,如果name為空會報異常,因為快取key不允許為null


@Cacheable(value = "userCache",key = "#root.args")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用方法的入參作為快取的key,若傳遞的引數為id=100,name=gg,則實際快取的資料為:Object[]->列印各個環境變數,Object[]陣列中包含兩個值。

既然是陣列,可以通過下標進行存取,root.args[1] 表示獲取第二個引數,本例中即 取 name 的值 gg,則實際快取的資料為:gg->列印各個環境變數。


@Cacheable(value = "userCache",key = "#root.targetClass")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用被修飾的方法所屬的類作為快取key,實際快取的資料為:Class->列印各個環境變數,key為class物件,不是全限定名,全限定名是一個字串,這裡是class物件。

可是,不是很懂這樣設計的應用場景是什麼......


@Cacheable(value = "userCache",key = "#root.target")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用被修飾方法所屬類的範例作為key,實際快取的資料為:UserController->列印各個環境變數。

被修飾的方法就是在UserController中,偵錯的時候甚至可以獲取到此範例注入的其它容器物件,如userService等。

可是,不是很懂這樣設計的應用場景是什麼......


@Cacheable(value = "userCache",key = "#root.method")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用Method物件作為快取的key,是Method物件,不是字串。

可是,不是很懂這樣設計的應用場景是什麼......


@Cacheable(value = "userCache",key = "#root.methodName")
public String showEnv(String id, String name) {
    return "列印各個環境變數";
}

表示使用方法名作為快取的key,就是一個字串。

如何獲取快取的資料?

ConcurrentMapCacheManager的cacheMap是一個私有變數,所以沒有辦法可以列印快取池中的資料,不過可以通過偵錯的方式進入物件內部檢視。如下:


@Tag(name = "user - api")
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
public class UserController {

    private final ConcurrentMapCacheManager cacheManager;
    
    /**
     * 只有偵錯才課可以檢視快取池中的資料
     */
    @GetMapping("/cache")
    public void showCacheData() {
        //需要debug進入
        Collection<String> cacheNames = cacheManager.getCacheNames();
    }
    
}


總結:

雖然提供了很多的環境變數,但是大多都無法找到對應的使用場景,其實在實際開發中,最常見的就是key的生產,一般而言使用類名+方法名+引數值足矣。

SqEL

參考:https://juejin.cn/post/6987993458807930893

cite