簡介: 介紹 spring 3.1 激動人心的新特性:註釋驅動的快取,本文通過一個簡單的例子進行展開,通過對比我們原來的自定義快取和 spring 的基於註釋的 cache 配置方法,展現了 spring cache 的強大之處,然後介紹了其基本的原理,擴充套件點和使用場景的限制。通過閱讀本文,你可以短時間內掌握 spring 帶來的強大快取技術,在很少的配置下即可給既有程式碼提供快取能力。
概述
Spring 3.1 引入了激動人心的基於註釋(annotation)的快取(cache)技術,它本質上不是一個具體的快取實現方案(例如 EHCache 或者 OSCache),而是一個對快取使用的抽象,通過在既有程式碼中新增少量它定義的各種 annotation,即能夠達到快取方法的返回物件的效果。
Spring 的快取技術還具備相當的靈活性,不僅能夠使用 SpEL(Spring Expression Language)來定義快取的 key 和各種 condition,還提供開箱即用的快取臨時儲存方案,也支援和主流的專業快取例如 EHCache 整合。
其特點總結如下:
- 通過少量的配置 annotation 註釋即可使得既有程式碼支援快取
- 支援開箱即用 Out-Of-The-Box,即不用安裝和部署額外第三方元件即可使用快取
- 支援 Spring Express Language,能使用物件的任何屬性或者方法來定義快取的 key 和 condition
- 支援 AspectJ,並通過其實現任何方法的快取支援
- 支援自定義 key 和自定義快取管理者,具有相當的靈活性和擴充套件性
本文將針對上述特點對 Spring cache 進行詳細的介紹,主要通過一個簡單的例子和原理介紹展開,然後我們將一起看一個比較實際的快取例子,最後會介紹 spring cache 的使用限制和注意事項。OK,Let ’ s begin!
這裡先展示一個完全自定義的快取實現,即不用任何第三方的元件來實現某種物件的記憶體快取。
場景是:對一個賬號查詢方法做快取,以賬號名稱為 key,賬號物件為 value,當以相同的賬號名稱查詢賬號的時候,直接從快取中返回結果,否則更新快取。賬號查詢服務還支援 reload 快取(即清空快取)。
首先定義一個實體類:賬號類,具備基本的 id 和 name 屬性,且具備 getter 和 setter 方法
清單 1. Account.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package cacheOfAnno; public class Account { private int id; private String name; public Account(String name) { this.name = name; } public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } } |
然後定義一個快取管理器,這個管理器負責實現快取邏輯,支援物件的增加、修改和刪除,支援值物件的泛型。如下:
清單 2. MyCacheManager.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
package oldcache; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class MyCacheManager<T> { private Map<String,T> cache = new ConcurrentHashMap<String,T>(); public T getValue(Object key) { return cache.get(key); } public void addOrUpdateCache(String key,T value) { cache.put(key, value); } public void evictCache(String key) {// 根據 key 來刪除快取中的一條記錄 if(cache.containsKey(key)) { cache.remove(key); } } public void evictCache() {// 清空快取中的所有記錄 cache.clear(); } } |
好,現在我們有了實體類和一個快取管理器,還需要一個提供賬號查詢的服務類,此服務類使用快取管理器來支援賬號查詢快取,如下:
清單 3. MyAccountService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
package oldcache; import cacheOfAnno.Account; public class MyAccountService { private MyCacheManager<Account> cacheManager; public MyAccountService() { cacheManager = new MyCacheManager<Account>();// 構造一個快取管理器 } public Account getAccountByName(String acctName) { Account result = cacheManager.getValue(acctName);// 首先查詢快取 if(result!=null) { System.out.println("get from cache..."+acctName); return result;// 如果在快取中,則直接返回快取的結果 } result = getFromDB(acctName);// 否則到資料庫中查詢 if(result!=null) {// 將資料庫查詢的結果更新到快取中 cacheManager.addOrUpdateCache(acctName, result); } return result; } public void reload() { cacheManager.evictCache(); } private Account getFromDB(String acctName) { System.out.println("real querying db..."+acctName); return new Account(acctName); } } |
現在我們開始寫一個測試類,用於測試剛才的快取是否有效
清單 4. Main.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package oldcache; public class Main { public static void main(String[] args) { MyAccountService s = new MyAccountService(); // 開始查詢賬號 s.getAccountByName("somebody");// 第一次查詢,應該是資料庫查詢 s.getAccountByName("somebody");// 第二次查詢,應該直接從快取返回 s.reload();// 重置快取 System.out.println("after reload..."); s.getAccountByName("somebody");// 應該是資料庫查詢 s.getAccountByName("somebody");// 第二次查詢,應該直接從快取返回 } } |
按照分析,執行結果應該是:首先從資料庫查詢,然後直接返回快取中的結果,重置快取後,應該先從資料庫查詢,然後返回快取中的結果,實際的執行結果如下:
清單 5. 執行結果
1 2 3 4 5 |
real querying db...somebody// 第一次從資料庫載入 get from cache...somebody// 第二次從快取載入 after reload...// 清空快取 real querying db...somebody// 又從資料庫載入 get from cache...somebody// 從快取載入 |
可以看出我們的快取起效了,但是這種自定義的快取方案有如下劣勢:
- 快取程式碼和業務程式碼耦合度太高,如上面的例子,AccountService 中的 getAccountByName()方法中有了太多快取的邏輯,不便於維護和變更
- 不靈活,這種快取方案不支援按照某種條件的快取,比如只有某種型別的賬號才需要快取,這種需求會導致程式碼的變更
- 快取的儲存這塊寫的比較死,不能靈活的切換為使用第三方的快取模組
如果你的程式碼中有上述程式碼的影子,那麼你可以考慮按照下面的介紹來優化一下你的程式碼結構了,也可以說是簡化,你會發現,你的程式碼會變得優雅的多!
Hello World,註釋驅動的 Spring Cache
Hello World 的實現目標
本 Hello World 類似於其他任何的 Hello World 程式,從最簡單實用的角度展現 spring cache 的魅力,它基於剛才自定義快取方案的實體類 Account.java,重新定義了 AccountService.java 和測試類 Main.java(注意這個例子不用自己定義快取管理器,因為 spring 已經提供了預設實現)
需要的 jar 包
為了實用 spring cache 快取方案,在工程的 classpath 必須具備下列 jar 包。
注意這裡我引入的是最新的 spring 3.2.0.M1 版本 jar 包,其實只要是 spring 3.1 以上,都支援 spring cache。其中 spring-context-*.jar 包含了 cache 需要的類。
定義實體類、服務類和相關配置檔案
實體類就是上面自定義快取方案定義的 Account.java,這裡重新定義了服務類,如下:
清單 6. AccountService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package cacheOfAnno; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; public class AccountService { @Cacheable(value="accountCache")// 使用了一個快取名叫 accountCache public Account getAccountByName(String userName) { // 方法內部實現不考慮快取邏輯,直接實現業務 System.out.println("real query account."+userName); return getFromDB(userName); } private Account getFromDB(String acctName) { System.out.println("real querying db..."+acctName); return new Account(acctName); } } |
注意,此類的 getAccountByName 方法上有一個註釋 annotation,即 @Cacheable(value=”accountCache”),這個註釋的意思是,當呼叫這個方法的時候,會從一個名叫 accountCache 的快取中查詢,如果沒有,則執行實際的方法(即查詢資料庫),並將執行的結果存入快取中,否則返回快取中的物件。這裡的快取中的 key 就是引數 userName,value 就是 Account 物件。“accountCache”快取是在 spring*.xml 中定義的名稱。
好,因為加入了 spring,所以我們還需要一個 spring 的配置檔案來支援基於註釋的快取
清單 7. Spring-cache-anno.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:p="http://www.springframework.org/schema/p" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd"> <cache:annotation-driven /> <bean id="accountServiceBean" class="cacheOfAnno.AccountService"/> <!-- generic cache manager --> <bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager"> <property name="caches"> <set> <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="default" /> <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean" p:name="accountCache" /> </set> </property> </bean> </beans> |
注意這個 spring 配置檔案有一個關鍵的支援快取的配置項:<cache:annotation-driven />,
這個配置項預設使用了一個名字叫 cacheManager 的快取管理器,這個快取管理器有一個 spring 的預設實現,即 org.springframework.cache.support.SimpleCacheManager,這個快取管理器實現了我們剛剛自定義的快取管理器的邏輯,它需要配置一個屬性 caches,即此快取管理器管理的快取集合,除了預設的名字叫 default 的快取,我們還自定義了一個名字叫 accountCache 的快取,使用了預設的記憶體儲存方案 ConcurrentMapCacheFactoryBean,它是基於 java.util.concurrent.ConcurrentHashMap 的一個記憶體快取實現方案。
OK,現在我們具備了測試條件,測試程式碼如下:
清單 8. Main.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
package cacheOfAnno; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; public class Main { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); // 第一次查詢,應該走資料庫 System.out.print("first query..."); s.getAccountByName("somebody"); // 第二次查詢,應該不查資料庫,直接返回快取的值 System.out.print("second query..."); s.getAccountByName("somebody"); System.out.println(); } } |
上面的測試程式碼主要進行了兩次查詢,第一次應該會查詢資料庫,第二次應該返回快取,不再查資料庫,我們執行一下,看看結果
清單 9. 執行結果
1 2 3 |
first query...real query account.somebody// 第一次查詢 real querying db...somebody// 對資料庫進行了查詢 second query...// 第二次查詢,沒有列印資料庫查詢日誌,直接返回了快取中的結果 |
可以看出我們設定的基於註釋的快取起作用了,而在 AccountService.java 的程式碼中,我們沒有看到任何的快取邏輯程式碼,只有一行註釋:@Cacheable(value=”accountCache”),就實現了基本的快取方案,是不是很強大?
如何清空快取
好,到目前為止,我們的 spring cache 快取程式已經執行成功了,但是還不完美,因為還缺少一個重要的快取管理邏輯:清空快取,當賬號資料發生變更,那麼必須要清空某個快取,另外還需要定期的清空所有快取,以保證快取資料的可靠性。
為了加入清空快取的邏輯,我們只要對 AccountService.java 進行修改,從業務邏輯的角度上看,它有兩個需要清空快取的地方
- 當外部呼叫更新了賬號,則我們需要更新此賬號對應的快取
- 當外部呼叫說明重新載入,則我們需要清空所有快取
清單 10. AccountService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
package cacheOfAnno; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; public class AccountService { @Cacheable(value="accountCache")// 使用了一個快取名叫 accountCache public Account getAccountByName(String userName) { // 方法內部實現不考慮快取邏輯,直接實現業務 return getFromDB(userName); } <strong>@CacheEvict(value="accountCache",key="#account.getName()")// 清空 accountCache 快取</strong> <strong> public void updateAccount(Account account) {</strong> updateDB(account); } <strong> @CacheEvict(value="accountCache",allEntries=true)// 清空 accountCache 快取</strong> public void reload() { } private Account getFromDB(String acctName) { System.out.println("real querying db..."+acctName); return new Account(acctName); } private void updateDB(Account account) { System.out.println("real update db..."+account.getName()); } } |
清單 11. Main.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package cacheOfAnno; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; public class Main { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); // 第一次查詢,應該走資料庫 System.out.print("first query..."); s.getAccountByName("somebody"); // 第二次查詢,應該不查資料庫,直接返回快取的值 System.out.print("second query..."); s.getAccountByName("somebody"); System.out.println(); <strong>System.out.println("start testing clear cache...");</strong> <strong> // 更新某個記錄的快取,首先構造兩個賬號記錄,然後記錄到快取中</strong> Account account1 = s.getAccountByName("somebody1"); Account account2 = s.getAccountByName("somebody2"); <strong> // 開始更新其中一個</strong> <strong> account1.setId(1212);</strong> s.updateAccount(account1); <strong> s.getAccountByName("somebody1");// 因為被更新了,所以會查詢資料庫</strong> <strong> s.getAccountByName("somebody2");// 沒有更新過,應該走快取</strong> <strong> s.getAccountByName("somebody1");// 再次查詢,應該走快取</strong> <strong> // 更新所有快取</strong> s.reload(); <strong> s.getAccountByName("somebody1");// 應該會查詢資料庫</strong> <strong> s.getAccountByName("somebody2");// 應該會查詢資料庫</strong> <strong> s.getAccountByName("somebody1");// 應該走快取</strong> <strong> s.getAccountByName("somebody2");// 應該走快取</strong> } } |
清單 12. 執行結果
1 2 3 4 5 6 7 8 9 |
first query...real querying db...somebody second query... start testing clear cache... real querying db...somebody1 real querying db...somebody2 real update db...somebody1 real querying db...somebody1 real querying db...somebody1 real querying db...somebody2 |
結果和我們期望的一致,所以,我們可以看出,spring cache 清空快取的方法很簡單,就是通過 @CacheEvict 註釋來標記要清空快取的方法,當這個方法被呼叫後,即會清空快取。注意其中一個 @CacheEvict(value=”accountCache”,key=”#account.getName()”),其中的 Key 是用來指定快取的 key 的,這裡因為我們儲存的時候用的是 account 物件的 name 欄位,所以這裡還需要從引數 account 物件中獲取 name 的值來作為 key,前面的 # 號代表這是一個 SpEL 表示式,此表示式可以遍歷方法的引數物件,具體語法可以參考 Spring 的相關文件手冊。
如何按照條件操作快取
前面介紹的快取方法,沒有任何條件,即所有對 accountService 物件的 getAccountByName 方法的呼叫都會起動快取效果,不管引數是什麼值,如果有一個需求,就是隻有賬號名稱的長度小於等於 4 的情況下,才做快取,大於 4 的不使用快取,那怎麼實現呢?
Spring cache 提供了一個很好的方法,那就是基於 SpEL 表示式的 condition 定義,這個 condition 是 @Cacheable 註釋的一個屬性,下面我來演示一下
清單 13. AccountService.java(getAccountByName 方法修訂,支援條件)
1 2 3 4 5 |
@Cacheable(value="accountCache",condition="#userName.length() <= 4")// 快取名叫 accountCache public Account getAccountByName(String userName) { // 方法內部實現不考慮快取邏輯,直接實現業務 return getFromDB(userName); } |
注意其中的 condition=”#userName.length() <=4”,這裡使用了 SpEL 表示式訪問了引數 userName 物件的 length() 方法,條件表示式返回一個布林值,true/false,當條件為 true,則進行快取操作,否則直接呼叫方法執行的返回結果。
清單 14. 測試方法
1 2 3 4 |
s.getAccountByName("somebody");// 長度大於 4,不會被快取 s.getAccountByName("sbd");// 長度小於 4,會被快取 s.getAccountByName("somebody");// 還是查詢資料庫 s.getAccountByName("sbd");// 會從快取返回 |
清單 15. 執行結果
1 2 3 |
real querying db...somebody real querying db...sbd real querying db...somebody |
可見對長度大於 4 的賬號名 (somebody) 沒有快取,每次都查詢資料庫。
假設 AccountService 現在有一個需求,要求根據賬號名、密碼和是否傳送日誌查詢賬號資訊,很明顯,這裡我們需要根據賬號名、密碼對賬號物件進行快取,而第三個引數“是否傳送日誌”對快取沒有任何影響。所以,我們可以利用 SpEL 表示式對快取 key 進行設計
清單 16. Account.java(增加 password 屬性)
1 2 3 4 5 6 7 |
private String password; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } |
清單 17. AccountService.java(增加 getAccount 方法,支援組合 key)
1 2 3 4 5 6 |
@Cacheable(value="accountCache",key="#userName.concat(#password)") public Account getAccount(String userName,String password,boolean sendLog) { // 方法內部實現不考慮快取邏輯,直接實現業務 return getFromDB(userName,password); } |
注意上面的 key 屬性,其中引用了方法的兩個引數 userName 和 password,而 sendLog 屬性沒有考慮,因為其對快取沒有影響。
清單 18. Main.java
1 2 3 4 5 6 7 8 9 10 11 |
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); s.getAccount("somebody", "123456", true);// 應該查詢資料庫 s.getAccount("somebody", "123456", true);// 應該走快取 s.getAccount("somebody", "123456", false);// 應該走快取 s.getAccount("somebody", "654321", true);// 應該查詢資料庫 s.getAccount("somebody", "654321", true);// 應該走快取 } |
上述測試,是採用了相同的賬號,不同的密碼組合進行查詢,那麼一共有兩種組合情況,所以針對資料庫的查詢應該只有兩次。
清單 19. 執行結果
1 2 |
real querying db...userName=somebody password=123456 real querying db...userName=somebody password=654321 |
和我們預期的一致。
如何做到:既要保證方法被呼叫,又希望結果被快取
根據前面的例子,我們知道,如果使用了 @Cacheable 註釋,則當重複使用相同引數呼叫方法的時候,方法本身不會被呼叫執行,即方法本身被略過了,取而代之的是方法的結果直接從快取中找到並返回了。
現實中並不總是如此,有些情況下我們希望方法一定會被呼叫,因為其除了返回一個結果,還做了其他事情,例如記錄日誌,呼叫介面等,這個時候,我們可以用 @CachePut 註釋,這個註釋可以確保方法被執行,同時方法的返回值也被記錄到快取中。
清單 20. AccountService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Cacheable(value="accountCache")// 使用了一個快取名叫 accountCache public Account getAccountByName(String userName) { // 方法內部實現不考慮快取邏輯,直接實現業務 return getFromDB(userName); } <strong>@CachePut(value="accountCache",key="#account.getName()")// 更新 accountCache 快取</strong> public Account updateAccount(Account account) { return updateDB(account); } private Account updateDB(Account account) { System.out.println("real updating db..."+account.getName()); return account; } |
清單 21. Main.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); Account account = s.getAccountByName("someone"); account.setPassword("123"); s.updateAccount(account); account.setPassword("321"); s.updateAccount(account); account = s.getAccountByName("someone"); System.out.println(account.getPassword()); } |
如上面的程式碼所示,我們首先用 getAccountByName 方法查詢一個人 someone 的賬號,這個時候會查詢資料庫一次,但是也記錄到快取中了。然後我們修改了密碼,呼叫了 updateAccount 方法,這個時候會執行資料庫的更新操作且記錄到快取,我們再次修改密碼並呼叫 updateAccount 方法,然後通過 getAccountByName 方法查詢,這個時候,由於快取中已經有資料,所以不會查詢資料庫,而是直接返回最新的資料,所以列印的密碼應該是“321”
清單 22. 執行結果
1 2 3 4 |
real querying db...someone real updating db...someone real updating db...someone 321 |
和分析的一樣,只查詢了一次資料庫,更新了兩次資料庫,最終的結果是最新的密碼。說明 @CachePut 確實可以保證方法被執行,且結果一定會被快取。
@Cacheable、@CachePut、@CacheEvict 註釋介紹
通過上面的例子,我們可以看到 spring cache 主要使用兩個註釋標籤,即 @Cacheable、@CachePut 和 @CacheEvict,我們總結一下其作用和配置方法。
表 1. @Cacheable 作用和配置方法
@Cacheable 的作用 | 主要針對方法配置,能夠根據方法的請求引數對其結果進行快取 | |
---|---|---|
@Cacheable 主要的引數 | ||
value | 快取的名稱,在 spring 配置檔案中定義,必須指定至少一個 | 例如: @Cacheable(value=”mycache”) 或者 @Cacheable(value={”cache1”,”cache2”} |
key | 快取的 key,可以為空,如果指定要按照 SpEL 表示式編寫,如果不指定,則預設按照方法的所有引數進行組合 | 例如: @Cacheable(value=”testcache”,key=”#userName”) |
condition | 快取的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行快取 | 例如: @Cacheable(value=”testcache”,condition=”#userName.length()>2”) |
表 2. @CachePut 作用和配置方法
@CachePut 的作用 | 主要針對方法配置,能夠根據方法的請求引數對其結果進行快取,和 @Cacheable 不同的是,它每次都會觸發真實方法的呼叫 | |
---|---|---|
@CachePut 主要的引數 | ||
value | 快取的名稱,在 spring 配置檔案中定義,必須指定至少一個 | 例如: @Cacheable(value=”mycache”) 或者 @Cacheable(value={”cache1”,”cache2”} |
key | 快取的 key,可以為空,如果指定要按照 SpEL 表示式編寫,如果不指定,則預設按照方法的所有引數進行組合 | 例如: @Cacheable(value=”testcache”,key=”#userName”) |
condition | 快取的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才進行快取 | 例如: @Cacheable(value=”testcache”,condition=”#userName.length()>2”) |
表 3. @CacheEvict 作用和配置方法
@CachEvict 的作用 | 主要針對方法配置,能夠根據一定的條件對快取進行清空 | |
---|---|---|
@CacheEvict 主要的引數 | ||
value | 快取的名稱,在 spring 配置檔案中定義,必須指定至少一個 | 例如: @CachEvict(value=”mycache”) 或者 @CachEvict(value={”cache1”,”cache2”} |
key | 快取的 key,可以為空,如果指定要按照 SpEL 表示式編寫,如果不指定,則預設按照方法的所有引數進行組合 | 例如: @CachEvict(value=”testcache”,key=”#userName”) |
condition | 快取的條件,可以為空,使用 SpEL 編寫,返回 true 或者 false,只有為 true 才清空快取 | 例如: @CachEvict(value=”testcache”, condition=”#userName.length()>2”) |
allEntries | 是否清空所有快取內容,預設為 false,如果指定為 true,則方法呼叫後將立即清空所有快取 | 例如: @CachEvict(value=”testcache”,allEntries=true) |
beforeInvocation | 是否在方法執行前就清空,預設為 false,如果指定為 true,則在方法還沒有執行的時候就清空快取,預設情況下,如果方法執行丟擲異常,則不會清空快取 | 例如: @CachEvict(value=”testcache”,beforeInvocation=true) |
基本原理
和 spring 的事務管理類似,spring cache 的關鍵原理就是 spring AOP,通過 spring AOP,其實現了在方法呼叫前、呼叫後獲取方法的入參和返回值,進而實現了快取的邏輯。我們來看一下下面這個圖:
上圖顯示,當客戶端“Calling code”呼叫一個普通類 Plain Object 的 foo() 方法的時候,是直接作用在 pojo 類自身物件上的,客戶端擁有的是被呼叫者的直接的引用。
而 Spring cache 利用了 Spring AOP 的動態代理技術,即當客戶端嘗試呼叫 pojo 的 foo()方法的時候,給他的不是 pojo 自身的引用,而是一個動態生成的代理類
如上圖所示,這個時候,實際客戶端擁有的是一個代理的引用,那麼在呼叫 foo() 方法的時候,會首先呼叫 proxy 的 foo() 方法,這個時候 proxy 可以整體控制實際的 pojo.foo() 方法的入參和返回值,比如快取結果,比如直接略過執行實際的 foo() 方法等,都是可以輕鬆做到的。
擴充套件性
直到現在,我們已經學會了如何使用開箱即用的 spring cache,這基本能夠滿足一般應用對快取的需求,但現實總是很複雜,當你的使用者量上去或者效能跟不上,總需要進行擴充套件,這個時候你或許對其提供的記憶體快取不滿意了,因為其不支援高可用性,也不具備持久化資料能力,這個時候,你就需要自定義你的快取方案了,還好,spring 也想到了這一點。
我們先不考慮如何持久化快取,畢竟這種第三方的實現方案很多,我們要考慮的是,怎麼利用 spring 提供的擴充套件點實現我們自己的快取,且在不改原來已有程式碼的情況下進行擴充套件。
首先,我們需要提供一個 CacheManager 介面的實現,這個介面告訴 spring 有哪些 cache 例項,spring 會根據 cache 的名字查詢 cache 的例項。另外還需要自己實現 Cache 介面,Cache 介面負責實際的快取邏輯,例如增加鍵值對、儲存、查詢和清空等。利用 Cache 介面,我們可以對接任何第三方的快取系統,例如 EHCache、OSCache,甚至一些記憶體資料庫例如 memcache 或者 h2db 等。下面我舉一個簡單的例子說明如何做。
清單 23. MyCacheManager
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package cacheOfAnno; import java.util.Collection; import org.springframework.cache.support.AbstractCacheManager; public class MyCacheManager extends AbstractCacheManager { private Collection<? extends MyCache> caches; /** * Specify the collection of Cache instances to use for this CacheManager. */ public void setCaches(Collection<? extends MyCache> caches) { this.caches = caches; } @Override protected Collection<? extends MyCache> loadCaches() { return this.caches; } } |
上面的自定義的 CacheManager 實際繼承了 spring 內建的 AbstractCacheManager,實際上僅僅管理 MyCache 類的例項。
清單 24. MyCache
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
package cacheOfAnno; import java.util.HashMap; import java.util.Map; import org.springframework.cache.Cache; import org.springframework.cache.support.SimpleValueWrapper; public class MyCache implements Cache { private String name; private Map<String,Account> store = new HashMap<String,Account>();; public MyCache() { } public MyCache(String name) { this.name = name; } @Override public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public Object getNativeCache() { return store; } @Override public ValueWrapper get(Object key) { ValueWrapper result = null; Account thevalue = store.get(key); if(thevalue!=null) { thevalue.setPassword("from mycache:"+name); result = new SimpleValueWrapper(thevalue); } return result; } @Override public void put(Object key, Object value) { Account thevalue = (Account)value; store.put((String)key, thevalue); } @Override public void evict(Object key) { } @Override public void clear() { } } |
上面的自定義快取只實現了很簡單的邏輯,但這是我們自己做的,也很令人激動是不是,主要看 get 和 put 方法,其中的 get 方法留了一個後門,即所有的從快取查詢返回的物件都將其 password 欄位設定為一個特殊的值,這樣我們等下就能演示“我們的快取確實在起作用!”了。
這還不夠,spring 還不知道我們寫了這些東西,需要通過 spring*.xml 配置檔案告訴它
清單 25. Spring-cache-anno.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:p="http://www.springframework.org/schema/p" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd"> <cache:annotation-driven /> <bean id="accountServiceBean"/> <!-- generic cache manager --> <bean id="cacheManager"> <property name="caches"> <set> <bean p:name="accountCache" /> </set> </property> </bean> </beans> |
注意上面配置檔案的黑體字,這些配置說明了我們的 cacheManager 和我們自己的 cache 例項。
好,什麼都不說,測試!
清單 26. Main.java
1 2 3 4 5 6 7 8 9 10 11 |
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); Account account = s.getAccountByName("someone"); System.out.println("passwd="+account.getPassword()); account = s.getAccountByName("someone"); System.out.println("passwd="+account.getPassword()); } |
上面的測試程式碼主要是先呼叫 getAccountByName 進行一次查詢,這會呼叫資料庫查詢,然後快取到 mycache 中,然後我列印密碼,應該是空的;下面我再次查詢 someone 的賬號,這個時候會從 mycache 中返回快取的例項,記得上面的後門麼?我們修改了密碼,所以這個時候列印的密碼應該是一個特殊的值
清單 27. 執行結果
1 2 3 |
real querying db...someone passwd=null passwd=from mycache:accountCache |
結果符合預期,即第一次查詢資料庫,且密碼為空,第二次列印了一個特殊的密碼。說明我們的 myCache 起作用了。
注意和限制
基於 proxy 的 spring aop 帶來的內部呼叫問題
上面介紹過 spring cache 的原理,即它是基於動態生成的 proxy 代理機制來對方法的呼叫進行切面,這裡關鍵點是物件的引用問題,如果物件的方法是內部呼叫(即 this 引用)而不是外部引用,則會導致 proxy 失效,那麼我們的切面就失效,也就是說上面定義的各種註釋包括 @Cacheable、@CachePut 和 @CacheEvict 都會失效,我們來演示一下。
清單 28. AccountService.java
1 2 3 4 5 6 7 8 9 |
public Account getAccountByName2(String userName) { return this.getAccountByName(userName); } @Cacheable(value="accountCache")// 使用了一個快取名叫 accountCache public Account getAccountByName(String userName) { // 方法內部實現不考慮快取邏輯,直接實現業務 return getFromDB(userName); } |
上面我們定義了一個新的方法 getAccountByName2,其自身呼叫了 getAccountByName 方法,這個時候,發生的是內部呼叫(this),所以沒有走 proxy,導致 spring cache 失效
清單 29. Main.java
1 2 3 4 5 6 7 8 9 10 |
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); s.getAccountByName2("someone"); s.getAccountByName2("someone"); s.getAccountByName2("someone"); } |
清單 30. 執行結果
1 2 3 |
real querying db...someone real querying db...someone real querying db...someone |
可見,結果是每次都查詢資料庫,快取沒起作用。要避免這個問題,就是要避免對快取方法的內部呼叫,或者避免使用基於 proxy 的 AOP 模式,可以使用基於 aspectJ 的 AOP 模式來解決這個問題。
@CacheEvict 的可靠性問題
我們看到,@CacheEvict 註釋有一個屬性 beforeInvocation,預設為 false,即預設情況下,都是在實際的方法執行完成後,才對快取進行清空操作。期間如果執行方法出現異常,則會導致快取清空不被執行。我們演示一下
清單 31. AccountService.java
1 2 3 4 |
@CacheEvict(value="accountCache",allEntries=true)// 清空 accountCache 快取 public void reload() { throw new RuntimeException(); } |
注意上面的程式碼,我們在 reload 的時候丟擲了執行期異常,這會導致清空快取失敗。
清單 32. Main.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext( "spring-cache-anno.xml");// 載入 spring 配置檔案 AccountService s = (AccountService) context.getBean("accountServiceBean"); s.getAccountByName("someone"); s.getAccountByName("someone"); try { s.reload(); } catch (Exception e) { } s.getAccountByName("someone"); } |
上面的測試程式碼先查詢了兩次,然後 reload,然後再查詢一次,結果應該是隻有第一次查詢走了資料庫,其他兩次查詢都從快取,第三次也走快取因為 reload 失敗了。
清單 33. 執行結果
1 |
real querying db...someone |
和預期一樣。那麼我們如何避免這個問題呢?我們可以用 @CacheEvict 註釋提供的 beforeInvocation 屬性,將其設定為 true,這樣,在方法執行前我們的快取就被清空了。可以確保快取被清空。
清單 34. AccountService.java
1 2 3 4 5 |
@CacheEvict(value="accountCache",allEntries=true,beforeInvocation=true) // 清空 accountCache 快取 public void reload() { throw new RuntimeException(); } |
注意上面的程式碼,我們在 @CacheEvict 註釋中加了 beforeInvocation 屬性,確保快取被清空。
執行相同的測試程式碼
清單 35. 執行結果
1 2 |
real querying db...someone real querying db...someone |
這樣,第一次和第三次都從資料庫取資料了,快取清空有效。
非 public 方法問題
和內部呼叫問題類似,非 public 方法如果想實現基於註釋的快取,必須採用基於 AspectJ 的 AOP 機制,這裡限於篇幅不再細述。
其他技巧
Dummy CacheManager 的配置和作用
有的時候,我們在程式碼遷移、除錯或者部署的時候,恰好沒有 cache 容器,比如 memcache 還不具備條件,h2db 還沒有裝好等,如果這個時候你想除錯程式碼,豈不是要瘋掉?這裡有一個辦法,在不具備快取條件的時候,在不改程式碼的情況下,禁用快取。
方法就是修改 spring*.xml 配置檔案,設定一個找不到快取就不做任何操作的標誌位,如下
清單 36. Spring-cache-anno.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:p="http://www.springframework.org/schema/p" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/cache http://www.springframework.org/schema/cache/spring-cache.xsd"> <cache:annotation-driven /> <bean id="accountServiceBean"/> <!-- generic cache manager --> <bean id="simpleCacheManager" > <property name="caches"> <set> <bean p:name="default" /> </set> </property> </bean> <!-- dummy cacheManager --> <bean id="cacheManager" > <property name="cacheManagers"> <list> <ref bean="simpleCacheManager" /> </list> </property> <property name="fallbackToNoOpCache" value="true" /> </bean> </beans> |
注意以前的 cacheManager 變為了 simpleCacheManager,且沒有配置 accountCache 例項,後面的 cacheManager 的例項是一個 CompositeCacheManager,他利用了前面的 simpleCacheManager 進行查詢,如果查詢不到,則根據標誌位 fallbackToNoOpCache 來判斷是否不做任何快取操作。
清單 37. 執行結果
1 2 3 |
real querying db...someone real querying db...someone real querying db...someone |
可以看出,快取失效。每次都查詢資料庫。因為我們沒有配置它需要的 accountCache 例項。
如果將上面 xml 配置檔案的 fallbackToNoOpCache 設定為 false,再次執行,則會得到
清單 38. 執行結果
1 2 3 4 5 |
Exception in thread "main" java.lang.IllegalArgumentException: Cannot find cache named [accountCache] for CacheableOperation [public cacheOfAnno.Account cacheOfAnno.AccountService.getAccountByName(java.lang.String)] caches=[accountCache] | condition='' | key='' |
可見,在找不到 accountCache,且沒有將 fallbackToNoOpCache 設定為 true 的情況下,系統會丟擲異常。
小結
總之,註釋驅動的 spring cache 能夠極大的減少我們編寫常見快取的程式碼量,通過少量的註釋標籤和配置檔案,即可達到使程式碼具備快取的能力。且具備很好的靈活性和擴充套件性。但是我們也應該看到,spring cache 由於急於 spring AOP 技術,尤其是動態的 proxy 技術,導致其不能很好的支援方法的內部呼叫或者非 public 方法的快取設定,當然這都是可以解決的問題,通過學習這個技術,我們能夠認識到,AOP 技術的應用還是很廣泛的,如果有興趣,我相信你也能基於 AOP 實現自己的快取方案。