2024-02-03|閱讀時間 ‧ 約 32 分鐘

閒談軟體設計:Cache, Repository style

這一篇是重新整理閒談軟體設計: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 等實作呢?

Decorator Pattern

開始之前,先仔細想一下我們要什麼?

想要不改變介面 (Repository 的抽象),但又想擴充功能 (支援快取)。

一般來說,很多人可能會想到繼承,但繼承在這個案例中其實很不好用,一個系統中通常會有多個 Repository,像 AccountRepositoryProductRepository 等,若每個都要繼承,是非常不方便的。有其他辦法嗎?有的,用 Decorator pattern 來幫忙。下面這張圖用一個很簡單的概念解釋 Decorator pattern:就是俄羅斯娃娃,一個套一個,每套一個就增加一點功能,但外表還是娃娃,而且可以任意套。


有什麼現成的例子可以參考嗎?有,Java 的 InputStream 就是一個不錯的設計 (當然也有人覺得很囉唆)。 Java 中,InputStream 是所有輸入串流的介面,提供 readclose 等函式,以多種的類別,為不同的裝置提供實作,例如 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 精簡比較好。


  • 視 use case 用不同的 repository 實體,這也是 decorator 的彈性,並沒有說非得用全部套上去的版本,而且通常額外加上的介面,通常只會在特定情境下使用,且快取也幫不上忙,此時也不用堅持要用有快取的 repository。尷尬的是,例如,在 use case 中會同時用到 getactiveUsers,選擇沒有 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 就會是一個不錯的地方更新快取,邏輯也就不會散亂在各處了。


延伸閱讀


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.