更新於 2023/07/15閱讀時間約 13 分鐘

閒談軟體設計:Repository

前言

我想用過 Spring Data JPA 的開發者對 repository 應該都蠻熟悉,只要宣告個 介面,加上完備的 JPA annotation,Spring Data JPA 會自動 inject 實作,十分方便。我則是在 Martin Fowler 的《Patterns of Enterprise Application Architecture》(後面以 PoEEA 代表) 書中看到 Repository,當初覺得跟 DAO 很像啊,沒花太多心思在比較兩者的差異,後來在建構旅遊 app 內容管理平台時,開始思考該怎麼設計才好?回頭翻書找 Repository 的描述
Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.
簡單說,Repository 提供像是 array 或是 dictionary 的容器,對程式來說,就好像記憶體中有所有的物件,不用去想物件其實是從資料庫來的。作法是在 data mapping layer 之上再提供一個 collection 的介面來存取物件,將資料庫的細節從商業邏輯層抽離。data mapping 很複雜,PoEAA 書中有個獨立的 pattern:Data Mapper 說明這件事,容我找個時間另外寫一篇,這邊先簡單用例子說明,假設是 RDBMS,Data Mapper 便是處理 SQL statement 的生成,以及查詢後將結果轉換成 domain object 的一個專屬物件。

Collection-like interface

為什麼是 collection-like 呢?因為所有語言 (即便不是物件導向語言) 的基礎函式庫都有 array 及 dictionary 容器,將資料寫入資料庫類比成將資料放進容器,從資料庫讀取資料類比成從容器拿取物件,將資料從資料庫刪除則類比成從容器中移除物件,對沒接觸過資料庫的開發者,也能用熟悉的 array 及 dictionary 來操作。
就拿 Java 來說,Java 有一個非常完整 (針對使用情境有不同的最佳化實作) 的 Java Collections Framework,而 Spring Data 的 CrudRepository 介面幾乎和 Java 的 Map 近似,名稱雖不同,但意思幾乎是一樣的。
Figure 1 - Comparison between Java Collection interfaces and CrudRepository
Figure 1 - Comparison between Java Collection interfaces and CrudRepository
至於 Repository 的介面該不該有 save 或 update (saveOrUpdate) 函式,老實說,過去我都用過,但在看過 PoEAA 書中的說明後,若要新寫一個,我可能會宣告 GenericRepository 像下面這樣:
get 用 ID 從資料庫取得特定物件,put 則是將物件存入資料庫,至於這物件是要 save 或是 update 則是由 repository 的實作決定 (PoEAA 有提到,可以用 Identity Map 判斷資料庫是否已經有該物件),remove 則是從資料庫移除該物件,contains 則是判斷資料庫中是否有這個物件,後面兩個要不要提供能給予 entity 及 ID 的 overloading methods 我是覺得都可以,大多數 Map 或 Dictionary 的介面都有類似的作法,clear 是清除整個資料庫 (表),最後 count 是詢問資料庫有多少這個類型的物件。
有幾個 methods 是可選的,考量到 performance,keys 和 values 大多數情況下不會呼叫,畢竟把所有資料載入到記憶體當中不太實際,反而 putAll 和 removeAll 則是為了批次寫入,而特定加進去的,而且在多數 collection 介面中也會看到類似的 method,並沒有破壞 collection-like 的原則。
反而最容易破壞 collection-like 介面的是例外 (exception),辛辛苦苦把資料庫的細節封裝在 repository 內部,但資料庫就是有可能無法連線、斷線或是發生 I/O 錯誤,這時候都會拋出例外 (好吧~有些語言可能不是以例外的形式),總不能把底層的例外直接拋出去,那不同資料庫就會有不同例外型別,原則上是將底層例外包裝再一次,例如 RepositoryException,至於在 Java 環境中這個例外該是 checked 還是 unchecked (介面會好看一點) 的,就不在這討論了,畢竟這問題已經有太多人 (激烈) 討論過了 (參閱閒談軟體設計:例外處理 )。

Complex Query & Batch Update

剛剛有討論到,即使用了 Repository,抽象溢漏來是會發生,會拋出例外是一個例子,不適合呼叫 values 也是一個例子,一般來說,應用程式不太可能只有一筆一筆取資料的形式,很多情況會是撈取符合特定條件的物件,另外還會考慮記憶體使用量,會使用分頁,還好這種情境,可以抽象是從容器過濾 (filter) 出符合條件的物件,只是條件該用什麼形式表達,在 PoEAA 建議用 Query Object 以物件的方式描述,詳細的內容參考另一篇《閒談軟體設計:Query Object》吧!
複雜的查詢能用 Query Object 解決,但批次更新怎麼辦?我覺得應該還是可以用 Query Object 來處理,因為 SQL 的 query statement 是可以做批次處理的,擴充 query object 把批次處理放進去,但 repository 的介面要叫什麼?filter(batchUpdateQuery) 怪怪的,我曾想過 perform(BatchOperation),不太像 collection 原生的操作 ,但我覺得還不算太差。
說實話,我過去並沒有實作類似 solution 的經驗,但概念是從 CoreData 的 NSBatchUpdateRequest 借來的。BatchOperation 包含一個 Query Object 用來限定修改的範圍,然後包含一個描述修改的 Update Action (其實就是 Query Object,只是描述的都是修改),最後 repository 能將兩者結合並轉換成實際的 SQL statement 或是該資料庫對應的模型。
如此一來,剛剛的 removeAll(entities) 就可以在 interface 用 perform 提供預設的實作:
到目前為止,都是以一個物件或多個物件為單位進行操作,但實務上還是有可能只改某個屬性,這時候,就會變成從 repository 取出一個物件,然後修改屬性後再存回去 (嚴格來說,真正存在記憶體中的物件,是不用再呼叫一次 put,容器中的物件就應該能反映最新的狀態回資料庫,若要做到這種程度,repository 回傳的物件需是一個經過包裝過的 proxy,任何的 setter 的呼叫會將物件記為 dirty,當整個 transaction 要結束前,會自己將變動存入資料庫,這要自己做太複雜了,大多要靠 framework 幫忙),這做法沒什麼問題,但 data mapper 必須要能處理資料可能會 conflict 的情況。有趣的是,這情況反而是用修改單一欄位的 SQL statement 不會發生。
特別是在 server 端,多個 API requests 想改同一個物件的不同屬性時,就可能會出問題。例如 API request A 要改 entity.x,另一個 API request B 要改 entity.y,他們各自從 repository 取得了一個 entity 物件,修改完後,A 取得的 entity.y 仍是原先的值,而 B 取得的 entity.x 亦是 (一般來說,不同 thread 取得的都是不同物件實體),如果 repository 中的 data mapper 沒意識到這情況,若寫入順序是先 A 後 B,那 A 的修改就會被覆蓋掉,其他細節就留到介紹 Data Mapper 的文章中吧!

Transaction

既然提到 conflict,有人會想到 RDBMS 有提供交易 (transaction) 機制,確保資料的完整性和一制性,那 repository 要管理 transaction 的 life-cycle 嗎?例如:repository 每個需要寫入的 method 內先呼叫 beginTransaction,寫入後呼叫 commit,或是發生例外時呼叫 rollback 嗎?
我目前的想法是 transaction 應該跟著 business logic scope 走,repository 本身並不知道自己所處的 scope,資訊不足以管理 transaction life-cycle。假設有個 API 是檢查註冊帳號的驗證碼,如果驗證碼正確,會將帳號 (account) 的狀態 (state) 改為啟用 (active),然後產生一個認證 (authentication) 並回傳。
以這例子來說,會用到 AccountRepository 和 AuthenticationRepository,一個完整的 business logic scope 是兩個操作 (改狀態和產生認證) 都完成才算是完成,任何一個失敗,都不該保留個別的結果,也就是說 transaction 的管理在使用 repository 的人身上,當然,這可以推到其他層,把跟資料庫有關的抽象溢漏抽離到 business logic 之外,這時候,不太想依賴框架的我就很喜歡 Spring framework 提供的 @Transactional。
商業邏輯層的 AuthenticationManager 大概會像這樣,只有 10 行不到的程式應該很好懂吧,裡面沒有任何 transaction 管理的邏輯,讓 validateAuthCode 聚焦在驗證上。
但使用 AuthenticationManager 的人就要注意 transaction,以這個例子來說,使用者就是 AuthenticationWebService,這個類別其實做的事也很單純,就是負責 HTTP request,把請求的內容轉成 domain 需要的資訊,呼叫 manager 做事,然後再把 domain object 轉成 HTTP response 所需的內容。
如此 transaction 的 scope 和 AuthenticationWebService 的 validateAuthCode 是一致的,如果有任何 exception 從這個 method 拋出,Spring framework 的 transaction management request filter 會攔截這個 exception 然後 rollback,這樣一來 AuthenticationManager 確實就不用知道 transaction 的管理,當資料庫的種類跟來源不止一個時,Spring framework 提供的跨來源 transaction management 工具就更加方便了。

結語

其實 Repository 只是希望提供一個 collection-like 的抽象層,但要實作時,有很多菱菱角角的問題,每個人對於這些問題,在不同的 context 下,決策不完全一樣。例如, Repository 要不要支援 reactive programming or async?畢竟這兩個是最近很流行的技術,是增加 server 處理 requests 數量的方式之一,但這樣的設計對一個 collection 的抽象是否合適呢?我說不上來。又或者是,抽象層到底要做到多乾淨,為了這個乾淨,要付出多少工程上的代價,我只能說這都是每個軟體設計師每天進行的修練。
就以說明 Repository 的例子來說,程式範例可以再更精簡一點,例如不一定要用六角架構來設計 web service 和 manager,但後來想想,這樣的例子應該要更好讀才對,就維持當初構想的設計了,所以應該還好吧?

在 Medium 的版本中,有一個小節 Cache/Sharding with Decorator 並沒有被放進來,主要是因為最近想把這一節改寫成獨立的一篇文章,加上目前這文章也有五千多字了,就請等待之後獨立的文章吧!
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.