于振:如何通過倉儲,對實體進行持久化處理?

韓楠發表於2022-07-19





責編 | 韓楠

約 3376 字 | 7 分鐘閱讀









 以下,Enjoy~ 




關於值物件和實體更多的細節,感興趣的同學可以有空的時候回過頭去看一下。

這些物件在建立出來後,總不能一直被儲存在記憶體當中,因此,就需要在某個時刻持久化到資料庫。

在DDD中,負責持久化的元件是倉儲,也叫資源庫。

簡單來說,倉儲就是用來持久化聚合根的,但它跟我們平時使用的DAO(Data Access O bject)又有所不同。

DAO 是對具體資料庫的直接操作,是跟資料庫型別強相關的,而倉儲只服務於聚合根,而且它也只是在概念上規定了對聚合根的持久化,不關心具體存到哪裡 以及如何存的問題。

在《Clean Architecture》一書中,馬丁大叔提到了這樣一個觀點:

從系統架構的角度來看,資料庫並不重要 —— 它只是一個實現細節,在系統架構中並不佔據重要角色。如果就資料庫與整個系統架構的關係打個比方,它們之間就好比是門把手和整個房屋架構的關係。

這個說法多多少少有些極端,但是我認為作者只是想表達這樣一個觀點,就是底層的儲存是不穩定的,在未來是存在變化的可能的。

如何應對變化呢?

架構設計原則告訴我們,可以通過介面來隔離具體實現細節。

對於介面和實現來說,有一個很奇妙的關係:當我們去修改一個介面時,也一定會去修改對應的具體實現,但是反過來,當我們修改具體實現時,卻很少去修改相應的抽象介面。

因此,我們可以認為,介面比實現更穩定。

既然介面更穩定,那我們如果想要設計一個靈活的系統,就要 多引用抽象型別,而非具體實現。

所以,倉儲在程式碼層面的實現中,通常採用的是獨立介面的形式。也即在領域層定義倉儲的介面,在基礎設施層對該介面進行實現。


01   介面的定義

▶︎   在領域層定義介面

倉儲介面的定義,是跟領域物件放在一起的,但是兩者不應該放到同一個包下。我們一般會採用技術層面的劃分方式,比如下面是對領域層的目錄結構的進一步劃分:

因為 domain 層原則上不能依賴任何其他層,因此,domain 下所有檔案裡都不應該 import 任何其他層的程式碼。

這也就意味著, 我們在 repo 中定義的所有 Repository 介面的入參、出參都應當是領域層的結構體或者是 Golang 裡的簡單型別。

▶︎   倉儲方法的命名

因為 Repository 不關心底層具體的儲存到底是什麼,所以我們在命名方法時,應當避免使用帶有明顯技術色彩的詞語,比如inser、update、select、delete這種。 通常建議使用save、find、remove這類更加籠統的詞彙。

▶︎   通用的倉儲介面定義

前面在介紹實體時,提到了可以通過在倉儲中定義一個 NextIdentity 方法來生成實體的唯一 標識。

 綜合上面的論述,一個 Repository 介面,至少應該包含下面幾個方法:

其中,我們沒有明確區分新增和更新操作,而是隻定義了一個 Save 方法。

站在 Repository 的角度來看,它的職責只是將領域模型儲存起來,到底是新增還是更新,是技術層面需要關心的,而不是它。

最後再來看一下 FindNonNil 這個方法, 它跟 Find 比較類似,當指定id對應的實體不存在時,Find 會返回 nil, nil。而 FindNonNil 會認為這是一個錯誤,error 會返回 NotFound, 這在某些場景下會非常有用。

除了這幾個基本介面,各個業務可以在這個基礎上 根據自身場景進行適當的擴充套件。


02   介面的實現


▶︎   在基礎設施層組織倉儲的實現

倉儲介面定義在domain層,而具體實現是技術細節,所以是定義在基礎設施層的。

為了區分基礎設施層不同的功能模組,可以對基礎設施層進一步劃分,而倉儲相關的實現程式碼 可以統一放到 infra.persistence 這個包下。

在上面的程式碼結構中,repo 包下的 OrderRepository 介面對應的實現 OrderDBRepository 放在 order_repo_impl.go 檔案中。

DAL 中放置的是具體的對資料庫表的訪問。

converter 這個包可能存在,也可能不存在,其主要作用是對領域模型和資料模型進行互轉。當領域模型與資料模型的欄位一致時,可以退化為只使用領域模型,也即領域模型兼顧了資料模型的職責。

但是這樣的話,所有模型欄位必須要是大駝峰法。 這個時候就要注意不要在領域之外直接修改欄位值,這也是模型退化後我們需要承擔的風險。

為了說明倉儲的實現,我們先從最簡單的情況說起。

▶︎   單表場景下的倉儲實現

現在我們假設 Order 這個聚合中的屬性,跟 orders 資料庫表的欄位是一一對應的。

在 OrderRepository 中需要引用到 IdGeneratorClient 和 OrderDal,具體實現如下:

對於上面程式碼,有幾點說明:

•  Save 方法同時兼具了新增和更新功能,具體的邏輯是在 dal 中通過 Upsert 實現的;

•  Dal 中方法的命名帶有明顯的 sql 特徵;

•  在應用服務中獲取某個聚合根時,通常都要判斷下聚合根是否存在,我們這裡提供的 FindNonNil 方法,將這一個常規操作進行了封裝;

•  Order 是定義在領域層的聚合根,而不是資料庫表的資料模型。在 Order 中的屬性跟 orders 資料庫表的欄位是一一對應的這個假設下,我們省略了對資料模型的定義。所以,這裡的 Order 是身兼數職,但其主要職責還是領域模型,只是為了程式碼實現上的便利,才妥協同時承擔了資料模型的職責。

一起思考下, 當上面的假設不成立時,又該怎麼辦呢?

▶︎   多表場景下的倉儲實現

比如一個 Order 中存在多個 Item,Order 存到 orders 表,Item 存到 order_items 表,通過 order_id 進行關聯,其結構如下所示:

這種場景下,我們的問題在於, 如果某次對 Order 的修改只是更改了某個 Item 的資訊,我們要如何執行 Save 方法?

首先,如果我們可以接受將 Items 欄位   序列化為 json 字串,在 orders 表中新增這樣一個 items 欄位來儲存 json,這樣也可以解決問題,但是當需要對 Item 進行查詢時就不太方便了。

另外一種簡單粗暴的方式是,不管三七二十一將 Order 中的資訊都更新一遍,這樣做的缺點也很明顯,就是會多出很多無用的 DB 操作:

在上面程式碼中,converter 的作用是負責領域模型與資料模型之間的轉化,資料模型我們一般用 PO (Persistant Object)表示。

converter 存在的價值在於,資料模型與領域模型並非是完全一致的,converter 負責管理了彼此之間的對映關係。

對於聚合根不是特別複雜的情況,上面的實現方式雖然存在無用 DB 操作,但也還能接受。

在對聚合的設計中有一條規則是要設計小聚合,其原因也在於此。

那如果很不幸我們有一個很大的聚合,無法接受全量更新,要怎麼辦呢?

通常有兩種方法:

•  一種是基於 Snapshot 的,當聚合根取出後,在記憶體中先儲存一份snapshot,在聚合根寫入時,將其跟snapshot做一下diff;

•  另一種是將聚合根上可以修改的屬性設定成私有的,然後通過類似Setter的方法來進行賦值,這樣,在setter被呼叫時我們就知道哪裡被修改了。

業界使用較多的,包括在其他語言中,都是採用第一種 Snapshot 的形式,其實現起來相對簡單,副作用較少。


03   使用Sn apshot對變更進行追蹤


▶︎   如何儲存Snapshot

使用 Snapshot 首先要解決的問題,是這個 Snapshot 要儲存在哪裡?

由於在 Go 中不支援類似 Java 裡的 ThreadLocal,並且在 Go 裡也不是很建議使用 goroutine local storage,所以對於這個 Snapshot 的儲存就不那麼方便了。

一種辦法是將 Snapshot 放到 Context 中,比如 context 包下有一個 WithValue 方法,但是這個方法是返回一個裝飾後的 Context,我們還是無法更改全域性的 Context。

因此,我們這裡採用了一種妥協的做法,即將 Snapshot 置於對應的聚合根內:

▶︎   通過Sanpshot進行Diff

之後,對 Repository 的實現邏輯進行相應的修改:

這裡主要的改動點在於 Save 方法。

我們首先呼叫了 DetectChanges 方法,這個方法會返回一個 OrderDiff 的例項,通過 OrderDiff ,可以判斷出是否有新增/更新/刪除 OrderItems ,是否需要更新 orders 表等。

同時,在Find方法裡,如果成功獲取到了 Order 例項,還要手動呼叫 Attach 方法,這個方法的主要作用是生成當前 Order 例項的一個快照,後續對 Order 的修改是不會影響到這個快照的,因此,在 Save 的時候就可以拿當前的 Order 跟快照做一下 Diff,從而判斷出都做了哪些改動。


04   結語


今天,我與你一同探討了如何通過倉儲,對實體進行持久化處理,在這裡,你需要關注下圖中的幾點:

在實際執行中,我們會通過將實體轉化為資料物件的形式來進行持久化。實體物件因為跟資料物件不具有一一對應的關係,因此,這中間就需要用到 converter 來做一個轉化。

其次,倉儲裡的方法在命名上要避免使用類似SQL中的一些詞語,而應該使用更加籠統一些的詞彙,比如Save、Find、Remove等。

最後,為了最小化DB操作,在每次Save的時候,還需要知道實體都做了哪些改動,我們通過 Snapshot 這種方式來實現。

在上面的例子中,Snapshot 的實現還是有些複雜,業務在實際編碼時仍然存在不小的工作量。在後面的幾講裡,我們還會繼續說一下如何提煉一個 SDK 用以簡化 Snapshot 的 Diff 操作。

到這裡,貌似我們已經完成了 DDD 中的大部分功能,但其實不然。

DDD作為一個方法論,其要面對的是各種各樣複雜的業務問題,隨著複雜度變高,就一定存在某些只依賴實體和值物件無法解決的問題。

那這些問題要如何解決呢?可以停下來思考下,最好可以帶著或多或少的疑問,到時我們在下一篇文章裡就有針對性地來說說領域服務。老樣子,我們再延申思考下。

▶︎  延伸思考

在一些Java實現的、相對古老的系統中,我們經常會看到這種寫法,就是先定義一個介面:

之後再定義一個實現了該介面的Impl:

通常情況下,這個 XXXServiceImpl 就是 XXXService 的唯一實現方。這種看似是面向介面程式設計的方法,實則非常沒有必要,也讓程式碼變得冗餘。

介面的作用主要是用來隔離變化,像上面這種情況,直接定義一個 public class XXXService 就好。

很多時候,我們從書本中看到一些觀點、原則,都不應該盲目地去套用,而應該在充分理解底層原理的基礎上靈活運用。

正所謂盡信書則不如無書,說的即是。


THE END 

轉載請聯絡ITPUB官方公眾號獲得授權

—————————————————————————————————

歡迎各領域技術人員投稿

投稿郵箱 |     hannan@it168.com


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70016482/viewspace-2906191/,如需轉載,請註明出處,否則將追究法律責任。

相關文章