手把手教你做一個快取工具

林嘉瑜發表於2020-07-04

日常開發中,某些資料介面即使優化到極致,都難免還會存在計算量巨大導致響應過慢,多數情況單獨做一個統計表用於存放這些處理後的資料用於讀取,或者接入redis/memcache存資料,就是說單次響應本身是可以接受較慢一些的,實時性並非特別高,則可以考慮引入快取機制,提升使用體驗。說到用快取,那就會有人提出用redis,但是專案組認為專案緊急,不希望浪費時間到新的工具研究上,或雖然熟悉,但維護工作有成本,為了有限的效果付出太多不划算。那麼怎麼辦,沒得搞了,只能手把手給專案做一個快取工具了!吃掉JVM!也和spring cache很類似的。

這樣的快取機制,無非就是key-value模型的體現,所以首先想到了map。

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

一個快取工具就完成了,快吧。怎麼用的話,就類似這樣嘛:

@GetMapping("/{id}")
public Object get(@PathVariable("id") String id) {
  if (cache.containKey(id)) {
    return cache.get(id);
  }
  //  調取服務獲取物件
  Object obj = service.get(id);
  //  塞進快取中
  cache.put(id, obj);
  return obj;
}

挺好用的,既方便效果又達成。

但是一想到是JVM的記憶體,那麼物件是存放在堆中的,一旦發生GC,資料被清掉了呢,會不會在下面這個操作的時候,我containKey判斷它的確存在,但是到了return的時候,應該返回的物件沒有了。

if (cache.containKey(id)) {
  return cache.get(id);
}

這的確是個問題,需要防止它發生。有辦法,換個思路寫上面的程式碼:

Object obj = cache.get(id);
if (obj != null) {
  return obj;
}

這樣寫總可以了吧,物件真的存在的時候我才給直接返回,不然還是老老實實執行查詢物件的方法。

好是挺好,但是總不至於每次一個類,我就得它new一個Map吧,那得多費Map,而且Map可以存在很多的物件在裡面,只要它的Key不重複。

考慮下Spring的元件處理,其實也是抽Map成類,當然可以將Map放到一個類中做靜態屬性欄位。Map的重複利用算是解決了。但是快取的資料不是一直都不變的,那還需要給它來一個定時,刷走快取資料。

@Component
public Cache<String, Object> extends HashMap<String, Object> {
  
  @Scheduled(cron="0 0/5 * * * ?")
  public void flushCache() {
    clear();
  }
    
}

其實這樣做還是不夠靈活,應該更能定製化地刷走快取,有些資料是5分鐘才變化,但有些資料一天都不變呢。這樣的話,可以考慮用Java的定時器,對上面進行優化,定時刪除指定的資料。

快取的地方有了,定時重新整理有了,但是仍然不好用,因為每個方法我都需要寫程式碼去判斷是否存在快取。為了解決這個問題,很自然地想到了加註解,AOP做切面,交給切面去處理,很快,就做出來了。

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CachePut {

  String key() default "";

}

再把切面實現下:

@Aspect
@Component
public class CachePutAspect {

  @Autowired
  private Cache cache;

  @Pointcut("@annotation(com.lin.cache.Cache)")
  public void cachePointCut() {
  }

  @SuppressWarnings("rawtypes")
  @Around("cachePointCut()")
  public Object pointCut(ProceedingJoinPoint pjp) throws Exception {
    Object result = null;
    String methodName = pjp.getSignature().getName();
    Class<?> classTarget = pjp.getTarget().getClass();
    Class<?>[] par = ((MethodSignature) pjp.getSignature()).getParameterTypes();
    Method method = classTarget.getMethod(methodName, par);
    CachePut cachePut = method.getAnnotation(CachePut.class);
    if (cachePut != null) {
      result = cache.get(cacheImport.key());
      //  避免獲取結果時遇上clear操作
      if (result != null) {
        return result;
      }
      try {
        result = pjp.proceed();
        //  將結果快取
        cacheMap.put(key, result);
      } catch (Throwable throwable) {
        throw new Exception("方法錯誤");
      }
    }
    return result;
  }

}

使用的時候就是這樣的了。

@CachePut(key = "getId")
@GetMapping("/{id}")
public Object get(@PathVariable("id") String id) {
  //  調取服務獲取物件
  Object obj = service.get(id);
  return obj;
}

好像還是不對勁的,因為key都是固定是"getId",豈不是每個不同物件都被覆蓋掉了,但肯定不行,那怎麼辦。想起來之前用redis做快取的時候,用的spring的快取@CachePut(key = "info + #id"),從方法的入參那裡拿到唯一的標識,將快取結果區分開來,這裡網上的資料比較少,啃原始碼一下子沒看明白,怎麼想都覺得這個操作很簡單。直到偶爾翻翻,找到有人提供了一個可行的例子,才得以讓這個快取工具得到昇華。

@Aspect
@Component
public class CachePutAspect {

  @Autowired
  private Cache cache;

  @Pointcut("@annotation(com.lin.cache.Cache)")
  public void cachePointCut() {
  }

  @SuppressWarnings("rawtypes")
  @Around("cachePointCut()")
  public Object pointCut(ProceedingJoinPoint pjp) throws Exception {
    Object result = null;
    String methodName = pjp.getSignature().getName();
    Class<?> classTarget = pjp.getTarget().getClass();
    Class<?>[] par = ((MethodSignature) pjp.getSignature()).getParameterTypes();
    Method method = classTarget.getMethod(methodName, par);
    CachePut cachePut = method.getAnnotation(CachePut.class);
    if (cachePut != null) {
      String key = generateKeyBySpEL(cacheImport.key(), pjp);
      result = cacheMap.get(key);
      //  避免獲取結果時遇上clear操作
      if (result != null) {
        return result;
      }
      try {
        result = pjp.proceed();
        //  將結果快取
        cacheMap.put(key, result);
      } catch (Throwable throwable) {
        throw new Exception("方法錯誤");
      }
    }
    return result;
  }

  //  使用SpringEL,將入引數據和表示式繫結起來,得到Key  
  public String generateKeyBySpEL(String key, ProceedingJoinPoint pjp) {
    Expression expression = parserSpel.parseExpression(key);
    EvaluationContext context = new StandardEvaluationContext();
    MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
    Object[] args = pjp.getArgs();
    String[] paramNames = parameterNameDiscoverer
        .getParameterNames(methodSignature.getMethod());
    for(int i = 0 ; i < args.length ; i++) {
      context.setVariable(paramNames[i], args[i]);
    }
    return expression.getValue(context).toString();
  }
    
}

現在就可以這樣用靈活的註解了:

@CachePut(key = "'info-' + #id)

 

差不多了完成這個快取工具了,當然除了將結果放進快取的操作用註解處理,把快取移除的操作也可以用註解完成。這裡就不實現了。

enn。。好像和Spring Cache的比較像,將就吧。

相關文章