JAVA快取規範 —— 雖遲但到的JCache API與天生不俗的Spring Cache

2022-11-15 15:00:31

大家好,又見面了。


本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會通過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。


有詩云「紙上得來終覺淺,絕知此事要躬行」,在上一篇文章《手寫本地快取實戰2—— 打造正規軍,構建通用本地快取框架》中,我們一起論證並逐步實現了一套簡化版本的通用本地快取框架,並在過程中逐步剖析了快取設計關鍵要素的實現策略。本篇文章中,我們一起來聊一聊快取框架實現所需要遵循的規範。

為何需要規範

上一章中構建的最簡化版本的快取框架,雖然可以使用,但是也存在一個問題,就是它對外提供的實現介面都是框架根據自己的需要而自定義的。這樣一來,專案整合了此快取框架,後續如果想要更換快取框架的時候,業務層面的改動會比較大。 —— 因為是自定義的框架介面,無法基於里氏替換原則來進行靈活的更換。

在業界各大廠商或者開源團隊都會構建並提供一些自己實現的快取框架或者元件,提供給開發者按需選擇使用。如果大家都是各自閉門造車,勢必導致業務中整合並使用某一快取實現之後,想要更換快取實現元件會難於登天。

千古一帝秦始皇統一天下後,頒佈了書同文、車同軌等一系列法規制度,使得所有的車輛都遵循統一的軸距,然後都可以在官道上正常的通行,大大提升了流通性。而正所謂「國有國法、行有行規」,為了保證快取框架的通用性、提升專案的可移植性,JAVA行業也迫切需要這麼一個快取規範,來約束各個快取提供商給出的快取框架都遵循相同的規範介面,業務中按照標準介面進行呼叫,無需與快取框架進行深度耦合,使得快取元件的更換成為一件簡單點的事情。

在JAVA的快取領域,流傳比較廣泛的主要是JCache APISpring Cache兩套規範,下面就一起來看下。

雖遲但到的JSR107 —— JCache API

提到JAVA中的「行業規矩」,JSR是一個繞不開的話題。它的全稱為Java Specification Requests,意思是JAVA規範提案。在該規範標準中,有公佈過一個關於JAVA快取體系的規範定義,也即JSR 107規範(JCache API),主要明確了JAVA中基於記憶體進行物件快取構建的一些要求,涵蓋記憶體物件的建立查詢更新刪除一致性保證等方面內容。

JSR107規範早在2012年時草案就被提出,但卻直到2014年才正式披露首個規範版本,也即JCache API 1.0.0版本,至此JAVA領域總算是有個正式的關於快取的官方規範要求。

揭祕JSR107 —— JCache API內容探究

JSR107規範具體的要求形式,都以介面的形式封裝在javax.cache包中進行提供。我們要實現的快取框架需要遵循該規範,也就是需要引入javax.cache依賴包,並實現其中提供的相關介面即可。對於使用maven構建的專案中,可以在pom.xml中引入javax.cache依賴:

<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
    <version>1.1.1</version>
</dependency>

JCache API規範中,定義的快取框架相關介面類之間的關係邏輯梳理如下:

我們要實現自己的本地快取框架,也即需要實現上述各個介面。對上述各介面類的含義介紹說明如下:

介面類 功能定位描述
CachingProvider SPI介面,快取框架的載入入口。每個Provider中可以持有1個或者多個CacheManager物件,用來提供不同的快取能力
CacheManager 快取管理器介面,每個快取管理器負責對具體的快取容器的建立與管理,可以管理1個或者多個不同的Cache物件
Cache Cache快取容器介面,負責儲存具體的快取資料,可以提供不同的容器能力
Entry Cache容器中儲存的key-value鍵值對記錄

作為通用規範,這裡將CachingProvider定義為了一個SPI介面Service Provider Interface,服務提供介面),主要是藉助JDK自帶的服務提供發現能力,來實現按需載入各自實現的功能邏輯,有點IOC的意味。這樣設計有一定的好處:

  • 對於框架

需要遵循規範,提供上述介面的實現類。然後可以實現熱插拔,與業務解耦。

  • 對於業務

先指定需要使用的SPI的具體實現類,然後業務邏輯中便無需感知快取具體的實現,直接基於JCache API通用介面進行使用即可。後續如果需要更換快取實現框架,只需要切換下使用的SPI的具體實現類即可。

根據上述介紹,一個基於JCache API實現的快取框架在實際專案中使用時的物件層級關係可能會是下面這種場景(假設使用LRU策略儲存部門資訊、使用普通策略儲存使用者資訊):

那麼如何去理解JCache API中幾個介面類的關係呢?

幾個簡單的說明:

  1. CachingProvider並無太多實際邏輯層面的功能,只是用來基於SPI機制,方便專案中整合插拔使用。內部持有CacheManager物件,實際的快取管理能力,由CacheManager負責提供。

  2. CacheManager負責具體的快取管理相關能力實現,範例由CachingProvider提供並持有,CachingProvider可以持有一個或者多個不同的CacheManager物件。這些CacheManager物件可以是相同型別,也可以是不同型別,比如我們可以實現2種快取框架,一種是基於記憶體的快取,一種是基於磁碟的快取,則可以分別提供兩種不同的CacheManager,供業務按需呼叫。

  3. Cache是CacheManager負責建立並管理的具體的快取容器,也可以有一個或者多個,如業務中會涉及到為使用者列表和部門列表分別建立獨立的Cache儲存。此外,Cache容器也可以根據需要提供不同的Cache容器型別,以滿足不同場景對於快取容器的不同訴求,如我們可以實現一個類似HashMap的普通鍵值對Cache容器,也可以提供一個基於LRU淘汰策略的Cache容器。

至此呢,我們釐清了JCache API規範的大致內容。

插敘 —— SPI何許人也

按照JSR107規範試編寫快取具體能力時,我們需要實現一個SPI介面的實現類,然後由JDK提供的載入能力將我們擴充套件的快取服務載入到JVM中供使用。

提到API我們都耳熟能詳,也就是我們常規而言的介面。但說起SPI也許很多小夥伴就有點陌生了。其實SPI也並非是什麼新鮮玩意,它是JDK內建的一種服務的提供發現載入機制。按照JAVA的物件導向編碼的思想,為了降低程式碼的耦合度、提升程式碼的靈活性,往往需要利用好抽象這一特性,比如一般會比較推薦基於介面進行編碼、而儘量避免強依賴某個具體的功能實現類 —— 這樣才能讓構建出的系統具有更好的擴充套件性,更符合物件導向設計原則中的裡式替換原則。SPI便是為了支援這一訴求而提供的能力,它允許將介面具體的實現類交由業務或者三方進行獨立構建,然後載入到JVM中以供業務進行使用。

為了這一點,我們需要在resource/META-INF/services目錄下新建一個檔案,檔名即為SPI介面名稱javax.cache.spi.CachingProvider,然後在檔案內容中,寫入我們要注入進入的我們自己的Provider實現類:

這樣,我們就完成了將我們自己的MyCachingProvider功能注入到系統中。在業務使用時,可以通過Caching.getCachingProvider()獲取到注入的自定義Provider

public static void main(String[] args) {
    CachingProvider provider =  Caching.getCachingProvider();
    System.out.println(provider);
}

從輸出的結果可以看出,獲取到了自定義的Provider物件:

com.veezean.skills.cache.fwk.MyCachingProvider@7adf9f5f

獲取到Provider之後,便可以進一步的獲取到Manager物件,進而業務層面層面可以正常使用。

JCache API規範的實現

JSR作為JAVA領域正統行規,制定的時候往往考慮到各種可能的靈活性與通用性。作為JSR中根正苗紅的JCache API規範,也沿襲了這一風格特色,框架介面的定義與實現也非常的豐富,幾乎可以擴充套件自定義任何你需要的處理策略。 —— 但恰是這一點,也讓其整個框架的介面定義過於重量級。對於快取框架實現者而言,遵循JCache API需要實現眾多的介面,需要做很多額外的實現處理。

比如,我們實現CacheManager的時候,需要實現如下這麼多的介面:

public class MemCacheManager implements CacheManager {
    private CachingProvider cachingProvider;
    private ConcurrentHashMap<String, Cache> caches;
    public MemCacheManager(CachingProvider cachingProvider, ConcurrentHashMap<String, Cache> caches) {
        this.cachingProvider = cachingProvider;
        this.caches = caches;
    }
    @Override
    public CachingProvider getCachingProvider() {
    }
    @Override
    public URI getURI() {
    }
    @Override
    public ClassLoader getClassLoader() {
    }
    @Override
    public Properties getProperties() {
    }
    @Override
    public <K, V, C extends Configuration<K, V>> Cache<K, V> createCache(String s, C c) throws IllegalArgumentException {
    }
    @Override
    public <K, V> Cache<K, V> getCache(String s, Class<K> aClass, Class<V> aClass1) {
    }
    @Override
    public <K, V> Cache<K, V> getCache(String s) {
    }
    @Override
    public Iterable<String> getCacheNames() {
    }
    @Override
    public void destroyCache(String s) {
    }
    @Override
    public void enableManagement(String s, boolean b) {
    }
    @Override
    public void enableStatistics(String s, boolean b) {
    }
    @Override
    public void close() {
    }
    @Override
    public boolean isClosed() {
    }
    @Override
    public <T> T unwrap(Class<T> aClass) {
    }
}

長長的一摞介面等著實現,看著都令人上頭,作為快取提供商,便需要按照自己的能力去實現這些介面,以保證相關快取能力是按照規範對外提供。也正是因為JCache API這種不接地氣的表現,讓其雖是JAVA 領域的正統規範,卻經常被束之高閣,淪落成為了一種名義規範。業界主流的本地快取框架中,比較出名的當屬Ehcache了(當然,Spring4.1中也增加了對JSR規範的支援)。此外,Redis的本地使用者端Redisson也有實現全套JCache API規範,使用者可以基於Redisson呼叫JCache API的標準介面來進行快取資料的操作。

JSR107提供的註解操作方法

前面提到了作為供應商想要實現JSR107規範的時候會比較複雜,需要做很多自己的處理邏輯。但是對於業務使用者而言,JSR107還是比較貼心的。比如JSR107中就將一些常用的API方法封裝為註解,利用註解來大大簡化編碼的複雜度,降低快取對於業務邏輯的侵入性,使得業務開發人員可以更加專注於業務本身的開發。

JSR107規範中常用的一些快取操作註解方法梳理如下面的表格:

註解 含義說明
@CacheResult 將指定的keyvalue對映內容存入到快取容器中
@CachePut 更新指定快取容器中指定key值快取記錄內容
@CacheRemove 移除指定快取容器中指定key值對應的快取記錄
@CacheRemoveAll 字面含義,移除指定快取容器中的所有快取記錄
@CacheKey 作為介面引數前面修飾,用於指定特定的入參作為快取key值的組成部分
@CacheValue 作為介面引數前面的修飾,用於指定特定的入參作為快取value

上述註解主要是新增在方法上面,用於自動將方法的入參與返回結果之間進行一個對映與自動快取,對於後續請求如果命中快取則直接返回快取結果而無需再次執行方法的具體處理,以此來提升介面的響應速度與承壓能力。

比如下面的查詢介面上,通過@CacheResult註解可以將查詢請求與查詢結果快取起來進行使用:

@CacheResult(cacheName = "books")
public Book findBookByName(@CacheKey String bookName) {
    return bookDao.queryByName(bookName);
}

Book資訊發生變更的時候,為了保證快取資料的準確性,需要同步更新快取內容。可以通過在更新方法上面新增@CachePut介面即可達成目的:

@CachePut(cacheName = "books")
public void updateBookInfo(@CacheKey String bookName, @CacheValue Book book) {
    bookDao.updateBook(bookName, book);
}

這裡分別適用了@CacheKey@CacheValue指定了需要更新的快取記錄key值,以及需要將其更新為的新的value值。

同樣地,藉助註解@CacheRemove可以完成對應快取記錄的刪除:

@CacheRemove(cacheName = "books")
public void deleteBookInfo(@CacheKey String bookName) {
    bookDao.deleteBookByName(bookName)
}

愛屋及烏 —— Spring框架制定的Cache規範

JSR 107(JCache API)規範的誕生可謂是一路坎坷,拖拖拉拉直到2014年才釋出了首個1.0.0版本規範。但是在JAVA界風頭無兩的Spring框架早在2011年就已經在其3.1版本中提供了快取抽象層的規範定義,並藉助Spring的優秀設計與良好生態,迅速得到了各個軟體開發團體的青睞,各大快取廠商也陸續提供了符合Spring Cache規範的自家快取產品。

Spring Cache並非是一個具體的快取實現,而是和JSR107類似的一套快取規範,基於註解並可實現與Spring的各種高階特性無縫整合,受到了廣泛的追捧。各大快取提供商幾乎都有基於Spring Cache規範進行實現的快取元件。比如後面我們會專門介紹的Guava CacheCaffeine Cache以及同樣支援JSR107規範的Ehcache等等。

得力於Spring在JAVA領域無可撼動的地位,造就了Spring Cache已成為JAVA快取領域的「事實標準」,深有「功高蓋主」的味道。

Spring Cache使用不同快取元件

如果要基於Spring Cache規範來進行快取的操作,首先在專案中需要引入此規範的定義:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

這樣,在業務程式碼中,就可以使用Spring Cache規範中定義的一些註解方法。前面有提過,Spring Cache只是一個規範宣告,可以理解為一堆介面定義,而並沒有提供具體的介面功能實現。具體的功能實現,由業務根據實際選型需要,引入相應快取元件的jar庫檔案依賴即可 —— 這一點是Spring框架中極其普遍的一種做法。

假如我們需要使用Guava Cache來作為我們實際快取能力提供者,則我們只需要引入對應的依賴即可:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>

這樣一來,我們便實現了使用Guava cache作為儲存服務提供者、且基於Spring Cache介面規範進行快取操作。Spring作為JAVA領域的一個相當優秀的框架,得益於其優秀的封裝設計思想,使得更換快取元件也顯得非常容易。比如現在想要將上面的Guava cache更換為Caffeine cache作為新的快取能力提供者,則業務程式碼中將依賴包改為Caffeine cache並簡單的做一些細節設定即可:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.1</version>
</dependency>

這樣一來,對於業務使用者而言,可以方便的進行快取具體實現者的替換。而作為快取能力提供商而言,自己可以輕易的被同類產品替換掉,所以也鞭策自己去提供更好更強大的產品,鞏固自己的地位,也由此促進整個生態的良性演進

Spring Cache規範提供的註解

需要注意的是,使用Spring Cache快取前,需要先手動開啟對於快取能力的支援,可以通過@EnableCaching註解來完成。

除了@EnableCaching,在Spring Cache中還定義了一些其它的常用註解方法,梳理歸納如下:

註解 含義說明
@EnableCaching 開啟使用快取能力
@Cacheable 新增相關內容到快取中
@CachePut 更新相關快取記錄
@CacheEvict 刪除指定的快取記錄,如果需要清空指定容器的全部快取記錄,可以指定allEntities=true來實現

具體的使用上,其實和JSR107規範中提供的註解用法相似。

當然了,JAVA領域快取事實規範地位雖已奠定,但是Spring Cache依舊是保持著一個兼收幷蓄的姿態,並積極的相容了JCache API相關規範,比如Spring4.1起專案中可以使用JSR107規範提供的相關注解方法來操作。

小結回顧

好啦,關於JAVA中的JSR107規範以及Spring Cache規範,以及各自典型代表,我們就聊到這裡。

那麼,關於本文中提及的快取規範的內容,你是否有自己的一些想法與見解呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。