這一篇是重新整理閒談軟體設計:Repository時,說要獨立分出來的文章,但一直拖稿到現在,主要是最近都在玩 Final Fantasy 7 重製版,不太有時間寫文章,破關後,該開始來還債了 XD
我想不少人在上作業系統課程時,或多或少都會看過類似的金字塔,這邊沒有要幫大家上課,只簡單說一下,愈接近金字塔上方,速度愈快,速度的差異通常都是一個數量級 (10x) 以上的差異,例如,Apple M1 Ultra 剛出來時,記憶體號稱 400 GB/s 的速度,但 M.2 PCI 4.0 x4 SSD 最多 7800 MB/s 的速度,這個速度可是 50x 的差距。但速度愈快價格愈貴,因此數量也比較少,我們不可能把所有的資料都塞進記憶體。
為了減輕程式開發人員的負擔,MMU 則會管理資料 Memory 與 CPU Cache 的同步,作業系統會管理資料在 Disk 與 Memory 之間的同步,並提供一個抽象層給程式開發人員,除了非常少數需要確切知道資料的位置,一般程式開發人員並不需要管理資料在不同階層的同步,甚至也不需要知道資料在哪一層,反正程式就是會動,這就是抽象的威力 (參閱 閒談軟體設計:語意的抽象化)。
可能是我孤陋寡聞,這樣的抽象卻不常見於這幾年的後端開發中,Redis (或其他分散式記憶體快取技術) 充斥於程式的各個角落,好像 application/business domain 就應該要了解 Redis 該如何使用,快取的 expire time 應該要設成多少,甚至要知道如何更新快取,但真的是應該這樣嗎?
在閒談軟體設計:Repository中有提到,Repository 的目的就是提供一個抽象,讓您的 application/business domain 在讀取 domain objects 時,就像使用語言原生的 array 或 dictionary 一樣,因此 Repository 本身就是一個非常合適的抽象可以繼續沿用下去,但要怎麼加入 Redis 或 Local cache 等實作呢?
開始之前,先仔細想一下我們要什麼?
想要不改變介面 (Repository 的抽象),但又想擴充功能 (支援快取)。
一般來說,很多人可能會想到繼承,但繼承在這個案例中其實很不好用,一個系統中通常會有多個 Repository,像 AccountRepository
、ProductRepository
等,若每個都要繼承,是非常不方便的。有其他辦法嗎?有的,用 Decorator pattern 來幫忙。下面這張圖用一個很簡單的概念解釋 Decorator pattern:就是俄羅斯娃娃,一個套一個,每套一個就增加一點功能,但外表還是娃娃,而且可以任意套。
有什麼現成的例子可以參考嗎?有,Java 的 InputStream
就是一個不錯的設計 (當然也有人覺得很囉唆)。 Java 中,InputStream
是所有輸入串流的介面,提供 read
和 close
等函式,以多種的類別,為不同的裝置提供實作,例如 FileInputStream
,提供從檔案 read
內容。
下面的範例中,建立一個 FileInputStream
讀取 large-image.png.gz
,由於檔案很大,不太可能全讀進記憶體,於是用一個 BufferedInputStream
來增加緩衝的功能。這個 BufferedInputStream
就是一個 decorator,因為檔案是一個 GZip 檔案,於是又套了一個 GzipInputStream
,增加解開 GZip 的功能,但即便套了這麼多層,對 ImageIO.read
來說,傳入的仍然是一個 InputStream
的物件,完全不需要知道內容是怎麼來的,只需要透過 read
函式讀取內容並轉換成 Image 即可,這就是一個精巧的 Decorator pattern 的設計。
所以,就依樣畫葫蘆吧!剛剛已經提過 Repository 是個合適的抽象,所以這裡就建立一個 GenericRepository
的介面,這個版本比閒談軟體設計:Repository 的範例還要更精簡,原因待會再說。
此時,我們就可以提供一個 RedisCachedRepository
類別,有幾個重點:首先,一定要實作 GenericRepository
介面,我們希望可以像 InputStream
那樣,結果必須先是個 Repository,然後再看可以再套什麼別的功能;再來,建構子要能傳入一個 GenericRepository
的實作,作為資料來源。對了,下面的範例程式僅供說明概念,應該是無法成功編譯的。
事實上,這是簡化過的版本,這裡快取的同步採用最簡單的版本,只要有變更,一律清除 Redis 中的快取,等到下次要讀取時再去真正的 source 取得最新的版本,然後更新快取。主要是快取的同步演算法有超多種,但不是本篇的重點,有興趣的可以自行搜尋 write-through 和 write-back,可以找到很多文章說明,或是回去翻作業系統的課本。重點是,您可以提供多種不同版本的實作,例如 RedisWriteThroughRepository
或是 RedisWriteBackRepository
,視需求搭配使用。
既然都已經有 RedisCachedRepository
了,有沒有機會再建立 local cache 呢?當然可以,但就像 CPU 從單核心到多核心會遇到的問題一樣,local cache 有效率地同步是另一個麻煩的事情,local cache 簡單說就是一個 local copy,若有兩個 instances 都有某個物件的 local copy,當一個 instance 修改了,怎麼通知另一個 instance?
Redis 有提供 keyspace notification,簡單來說,在特定設定下,Redis 的每個 key-value 有一個 PubSub 可以訂閱,例如:在 users:1234
放了一個 user 的資料,就可以訂閱 __keyspace@0__:users:1234
,多數對 users:1234
的修改,Redis 都會發送通知,因此,可以提供一個類似的類別:
這邊,我偷懶一下,RedisLocalCachedRepository
不接受 GenericRepository
作為 source,而是指定 RedisCachedRepository
作為 source,這是因為若來源不是 Redis,那訂閱 keyspace notification 就不太有意義。這裡,在收到通知後,也採用最簡單的方式,直接清除 local cache。
總之,稍微修改一下,就可以像套俄羅斯娃娃那樣,替 RedisCachedRepository
再添加 local cache 的功能。當然,要讓 local cache 更加 generic 是可行的,把管理訂閱通知抽成一個類別,由外部傳入,就可以讓原先的實作不局限於 Redis 了,這就當成給大家的練習題了。
用 Repository 建立抽象並搭配 Decorator pattern 完成 cache,看起來是個不錯的選擇,但還是有些限制。不過,有意思的是這邊所列的限制,即便不使用 Repository 也還是要小心注意。
在建立抽象時有提到,Repository 的介面變得更精簡,除了方便說明外,有時因為特殊需求會繼承 GenericRepository
,建立新的介面,例如下面 UserRepository
的例子:
這時,有點尷尬了,雖然 RedisCachedRepository<String, User>
可以 (應該吧) 接受 UserRepository
作為 source,但使用時,卻會少了 getByEmail
或是 activeUsers
等函式。這裡有幾種做法:
RedisCachedUserRepository
實作 UserRepository
,建構子接受兩個 source,有 cached 的和沒有 cached 的,只要是 cached 的 source 能支援的函式,就 delegate 給 cahced 的 source,cached 的 source 不支援的就 delegate 給另一個 source,這時,會覺得 GenericRepository
精簡比較好。get
與 activeUsers
,選擇沒有 cache 的 repository 在效率上就會比較差一點。由於快取中不會有全部的資料,因此,即便 Redis 等技術都有提供類似 wildcard 的方式查詢資料,卻無法保證結果是完整的,因此,像是 count
或是 contains
等函式,若要實作,其實也只能委任 (delegate) 給 source。
和多筆查詢相同,在多筆或是所謂的批次更新時,除了只能委任給 source 外,還有另一個麻煩的地方,那就是更新快取。由於快取都是 key-value 的集合,但在批次更新資料庫時,不見得會回傳所有有被更動到的 ID,因此也導致無法回頭更新快取。
若很在意資料的準確性,那就是清空該資源的所有快取,這聽起來也許不合理,但實際上對效能的影響不一定很大。拉長時間來看,只要整個系統運行的時間跨度裡,cache 的 hit rate 足夠高,系統就能受惠於快取。
甚至,快取的資料準確性是能有某種程度的容忍值,例如數秒、數分鐘的容忍值,那可以透過 expire time 讓資料自動從快取中移除,等待下次讀取時更新,此時,是可以不用清除該資源的所有快取。
多處快取和剛剛提到的 local cache 很像,都會遇到難以同步的問題,理論上,就是希望透過 Repository 建立抽象後,避免到處都有建立快取的現象,但有時候為了加速 API 的 response time,可能會把查詢結果給快取起來,而且快取的內容可能還是多筆。此時 Repository 會無法知道該怎麼更新這些快取,並別說 key 的命名規則不同時,就更難處理了。不過,多處快取的問題在沒有 Repository 的抽象中應該會更嚴重。
最後,即便用了 Repository 抽象,仍無法處理工程師直接修改資料庫,有些資料庫有提供通知的功能,例如 Firebase Realtime Database,但仍然需要寫程式去更新快取。不過,對於一個營運中的系統,直接修改資料庫應該是大忌啊!
總於把拖很久的稿寫完了,我自己偏好用 Repository 搭配 decorator 來管理 cache,而不是在 controller 層或是到處都有快取的邏輯,如果程式都是透過 Repository 更新資料,Repository 就會是一個不錯的地方更新快取,邏輯也就不會散亂在各處了。