Spring-boot 中使用 AOP 和 Redis搭建緩存框架

這里提到的緩存是通用的緩存,如表格獲取時優先查詢redis,如果有,則讀取 redis 中的數據,如果沒有,讀取數據庫,并將返回結果存入 redis 中,然后新增/修改/刪除等操作時,需要將對應的緩存數據清空,以保證每次獲取都是最新的數據。當然,有其他需求的時候,也可以直接通過 redisTemplate來對 redis 進行增刪改查,這個做法跟 MySQL 等關系型數據庫是差不多的,關于 redis 操作的部分因為不是這篇文章的重點,所以這里就不贅述了。

這里的實現思路有兩種,第一種是完全通過 spring AOP面向切面編程,給 select 做一層切面,給 save/update/delete 做一層切面,來完成上面說的功能。第二種是Spring的 cache 庫(其實還是面向切面的技術),輔以 spring AOP,可以大大簡化切面編程部分代碼。

首先是要導入相關 jar 包,我現在項目都是 spring-boot 的,所以jar 包帶有 spring-boot 前綴的,如果不是 spring-boot 的,直接找對應 spring 版本就可以了,都是比較通用的 jar 包,也不難找。

<!-- redis —>
<dependency>   
<groupId>org.springframework.boot</groupId>   
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- cache 緩存 —>
<dependency>   
<groupId>org.springframework.boot</groupId>   
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

首先是cache 和 redis 的配置,我這里將兩個配置寫在了一個類中

@Configuration
@EnableCaching
@PropertySource(value = "classpath:/application.properties")
public class CacheConfig extends CachingConfigurerSupport {


    /**
     *
     * @return
     */
    @Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
        RedisCacheManager manager = new RedisCacheManager(redisTemplate);
        //設置統一的過期時間
        manager.setDefaultExpiration(120l);
        //可在 map 中給不同的 key 設置對應的過期時間
//        manager.setExpires(new HashMap<String, Long>());
        return manager;
    }

    @Value("${spring.redis.host}")
    String hostName;

    @Value("${spring.redis.password}")
    String password;

    @Value("${spring.redis.port}")
    Integer port;

    @Value("${spring.redis.timeout}")
    Integer timeout;

    @Bean
    JedisConnectionFactory jedisConnectionFactory() {
        JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
        jedisConnectionFactory.setHostName(hostName);
        jedisConnectionFactory.setPort(port);
        jedisConnectionFactory.setPassword(password);
        jedisConnectionFactory.setDatabase(0);
        jedisConnectionFactory.setTimeout(timeout);
        return jedisConnectionFactory;
    }
}

這個類有幾個地方要注意的,首先是類的注解,@configuration 是 spring-boot 將類裝配成 bean 的注解,@EnableCaching 是聲明開啟緩存功能的注解,@PropertySource 是引用配置文件的注解

配置類里面有兩個 bean,cacheManager是給 cache 庫配置緩存的實際位置,因為 spring-cache 可以搭配的緩存很多,也就有很多cacheManager 可以使用,我們這里用的是 redis,所以用的是 RedisCacheManager。

這里可以給緩存上過期時間,但是這里要注意一下這個過期時間指的是通過 cache類庫添加進 Redis 緩存的過期時間,也就是@Cacheable 和@CachePut 這兩個注解。如果單獨使用 RedisTemplate 添加緩存,是要另外設置過期時間的。另外一個bean是 Redis 的配置方法。
然后簡單介紹一下 cache 庫,這個庫是 Spring的緩存庫,使用起來也是非常簡單,通過幾個使用在類或方法上面的注解即可達成目的

@Cacheable   //通過方法的查找是優先查找緩存,如果找到,方法不會執行,當找不到時會去找數據庫,并將返回結果放入緩存
@CachePut     //通過方法的返回值會放入緩存中,無論緩存中是否有值,方法一定會被執行
@CacheEvict   //清除緩存中的一個或多個條目
@Cacheing      //這是一個分組的注解,能夠同時應用多個其他的緩存注解

這里只是簡單介紹一下這幾個注解的含義,這些內容在其他文章中也都有比較詳實的說明,這里也不過多的介紹了。

著重要說的是@Cacheable 注解,這個注解是可以達到查找時優先查找緩存,當緩存中沒有才去訪問數據庫功能的,這個功能實際是通過 AOP 來完成的,所以我們也可以自己寫相關代碼,不過既然有現成的,那直接調用是極好的。

@Cacheable 注解有幾個屬性,對緩存有著較大的影響(這些屬性其實這幾個注解都有,但是上面幾個注解有缺陷,下面會講)。

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

    String key() default "";

    String keyGenerator() default "";

    String condition() default "";

    String unless() default "";

}

這里只截取了 Cacheable 注解中部分屬性,其他屬性具體功效可以看源碼,介紹的也都比較詳實。因為 redis 是鍵值對數據庫,存儲模型都是 key-value 格式的,所以要存入緩存,得要有對應的 key。key 有兩種生成方式,第一種是直接指定,這種方式一般用于某些定制化的緩存當中,通用返回接口的緩存得要用第二種方式,也就是通過鍵生成器來生成緩存的 key。condition 和 unless 的區別是,condition 是條件成立時,存入緩存;unless 是排除值不成立的,存入緩存。這兩者看起來是一個意思,但是在邏輯上來說還是有細微差別的,一個是阻止存入緩存(unless),但是還是會在緩存中查找;另外一個是先判斷 condition 的條件(condition),如果不成立,則不會查找緩存,也不會存入緩存。以上的 key,condition,unless 都是使用的 SpEL 表達式,后面會在實例中有一些簡單的介紹,詳細的說明也可通過其他文章學習。

在這些屬性中,key 的生成格外重要,要能夠通用,不必為每個需要用緩存的方法都手動生成 key,又要保證唯一性,因為不止是方法名會影響 key 值,每次調用時參數的不一致 都應該是不同的 key。這里keyGenerator很好的幫我們完成了這樣的工作。

可以使用Spring默認的SimpleKeyGenerator來生成 key,但是這個 key 是不會將函數名組合在 key 中,也是有缺陷,所以我們需要自定義一個 keyGenerator。

@Override
public Object generate(Object target, Method method, Object... params) {
        Logger log = Logger.getLogger("Keygenerate");
        StringBuilder key = new StringBuilder();
        key.append(method.getDeclaringClass().getName()).append(".").append(method.getName()).append(":");  //先將類的全限定名和方法名拼裝在 key 中
        if (params.length == 0) {
            return key.append(NO_PARAM_KEY).toString();
        }
        for (Object param : params) {   //通過遍歷參數,將參數也拼裝在 key 中,保證每次獲取key 的唯一性
            if (param == null) {
                key.append(NULL_PARAM_KEY);
            } else if (ClassUtils.isPrimitiveArray(param.getClass())) {
                int length = Array.getLength(param);
                for (int i = 0; i < length; i++) {
                    key.append(Array.get(param, i));
                    key.append(',');
                }
            } else if (ClassUtils.isPrimitiveOrWrapper(param.getClass()) || param instanceof String) {
                key.append(param);
            } else {
                key.append(param.hashCode());   //如果是map 或 model 類型
            }
            key.append('-');
        }
        return key.toString();
    }

我是將實現了 KeyGenerator 的類放在了 Spring 的上下文中,這樣子只要是使用了@Cacheable 注解的方法都可以根據規則來生成唯一的 key 了。
當然實際項目中還是有可能是會需要針對特定的方法來生成 key 的,下面給一個實例來說明:

@Cacheable(unless = "#result == '{\"data\":[],\"total_count\":0}'",key = "'keyGenerator' + '.' + #methodName + ':' + #id")

這是一個帶有 unless 和 key 的注解,實現的功能是查詢,當沒有值時不存入緩存。
到此我們已經可以通過注解將獲取到的值存入緩存,并直接通過緩存將值獲取出來,不用額外的調用數據庫了,接下來的工作是當 save/update/delete 等操作時,將相關數據從緩存中清除,以保證獲取數據的準確性。

cache 庫的注解中是有可以實現這個功能的,@CacheEvict。但是當我們通過 KeyGenerator 來自定義生成 key 時,@CacheEvict 便無法獲取對應的 Key,則無法正確清除相關緩存。舉個栗子,有用戶表和訂單表,用戶表有一次查詢,在緩存中生成這次查詢的緩存,訂單表也有一次查詢,在緩存中生成這次查詢的緩存。這時新增一條訂單,需要清空訂單相關的全部緩存,否則會出現獲取數據不一致的情況。但是如果通過@CacheEvict 來做,是無法知道目前 Redis 中關于訂單緩存的 Key 的,當然可以全部清空,這樣會把用戶表的緩存也清空掉,這必然是不好的。

那為了實現相關操作只清空相關的所有緩存,這里采取 AOP 的方式,對 save/update/delete 切面,當有這些操作的時候,就清空對應的緩存。

這里有兩種做法,第一種直接將切點設置成save/update/delete,在每次進入方法前刪除緩存,但是這種做法一個是得嚴格限制方法名,另外一個是如果有一些其他方法想要刪除緩存,就得要增加切點。所以這里用第二種方法,自定義注解,然后通過檢查方法是否帶有該注解,來判斷是不是需要清空緩存。

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

首先是自定義的緩存,然后在需要用到刪除的方法上面使用該緩存即可

@ClearRedis
public String save(User user){...}

這樣準備工作就算完成了,接下來是AOP 相關的代碼

@Component
@Aspect
public class AspectJConfig {
    @Pointcut("execution(* com.Demo.*.*(..))")
    private void clearCache(){}
    //save 方法前,清空相關 key的緩存
    @Before("clearCache()")
    public void beforeSave(JoinPoint joinPoint) throws Throwable {
        String targetName = joinPoint.getTarget().getClass().getName();
        Class targetClass = Class.forName(targetName);
        Method[] methods = targetClass.getMethods();
        String methodName = joinPoint.getSignature().getName();
        boolean isClearRedisPresent = false;
        for (Method method : methods)
        {
            if (method.getName().equals(methodName))
            {
                if (method.isAnnotationPresent(ClearRedis.class))
                {
                    isClearRedisPresent = true;
                    break;
                }
            }
        }
        if (isClearRedisPresent)
        {
            //獲取切點作用的方法
            Signature signature = joinPoint.getSignature();
            MethodSignature methodSignature = (MethodSignature) signature;
            //獲取有作用方法前綴的 key,遍歷以刪除該切點作用方法關聯的緩存,因為 save 有刷新數據庫
            Set<String> keys = stringRedisTemplate.keys(methodSignature.getDeclaringTypeName() + "*");
            stringRedisTemplate.delete(keys);
            keys = stringRedisTemplate.keys(methodSignature.getDeclaringTypeName() + "*");            
        }
    }
}

由于是通過增加注解的方式,所以切點的范圍可以定位到 service 層的所有方法上。邏輯上來說在save/delete/update 前,后刪除緩存都是可以的,所以是用@Before 還是@After 或者@Around 都是可以的。

方法體內分為兩個部分,第一個部分是判斷該切點方法有沒有使用@ClearRedis 注解,第二部分是當使用了注解,則清空對應的緩存。這兩步都是利用了 JAVA 的反射機制,通過 joinPoint 來找到對應的調用類和調用方法以及調用方法的參數,這樣我們就可以根據類+方法名+參數列表來組裝對應的 key,這個跟上面在 KeyGenerator 中生成 key 的邏輯是一一對應的。最后是利用的 Redis 的模糊查詢的邏輯可刪除對應前綴的 Key,也就是我們可以不管參數是多少,是用哪個方法調用的,直接刪除指定類獲取到的所有緩存。

到此為止所有的部分就介紹完畢了,通過這樣的配置,可以簡單的在獲取方法上增加@Cacheable 注解生成緩存,同時在方法上增加@ClearRedis 來清空類對應的緩存,保證了讀的效率,也同時保證的讀的準確性。

最后有兩點存疑
1.我之前試過用@CacheEvict,想來生成指定的 Key,并通過前綴刪除,但是好像做不到,因為前綴刪除這個功能是 Redis 的,不是 cache 的。不過我覺得既然 cache 提供這樣一個注解,應該不至于做不到這個需求,所以還得再繼續看一下。

2.第二個就是如果將切點設置成 service 層所有的方法,會不會對效率有所影響,這個也要在實際的運行環境中檢測才行。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內容