將DDD應用到資料庫設計中 - lazypro

banq發表於2022-07-08

本文將介紹如何將域驅動設計和資料庫組合在一起的另一個示例。接下來,我們將提供一個帶有 MySQL 資料庫的普通街頭gashapon(扭蛋娃娃機)店的真實設計。

使用者故事
正如我們之前所做的那樣,我們從描述使用者故事開始,並通過該故事瞭解我們的需求。

  • 會有很多機器,每臺機器都有不同的物品組合。
  • 為了讓“慷慨”的客戶一次購買所有物品,我們提供了一次抽取大量物品的選項。
  • 當我們用完物品時,我們需要立即補充它們。
  • 如果一個人一次畫了很多物品,如果物品用完了,我們會立即補充物品,以便他們繼續畫畫。

這樣的故事其實是每個扭蛋娃娃機店都會發生的場景,描述清楚後,我們就知道如何構建領域模型了。
一般來說,我們需要對扭蛋機和扭蛋實體進行建模。

用例
然後,我們根據使用者故事定義更精確的用例。與故事不同,用例將清楚地描述發生了什麼以及系統應該如何反應。

  • 因為這是一家線上扭蛋店,所以可以多人同時抽獎。但就像實體機器一樣,每個人都必須按順序繪製。
  • 在加卡的過程中,使用者必須等到所有的加卡都加滿後才能進行抽獎。
  • 當A和B各同時抽50,但機器裡只有70時,其中一個會得到批次中的50,而另一個先抽20,然後等到補完再抽30。

有了用例,我們既可以畫流程圖,也可以根據它編寫流程。在此示例中,我選擇將流程編寫Python如下。

def draw(n, machine):
    gachas = machine.pop(n)
    
    if len(gachas) < n:
        machine.refill()
        gachas += draw(n - len(gachas), machine)
    
    return gachas


在使用者故事中,我們提到我們將對扭蛋機和扭蛋進行建模。所以machine在這個例子中的程式碼是扭蛋機,它提供了pop和refill方法。另一方面,gachas 是一個列表gacha。
在我們進一步討論之前,必須說明一件非常重要的事情。pop和方法都refill必須是原子的。為了避免競速條件,這兩種方法都必須是原子的,不能被搶佔,同時會有多個使用者同時繪製。

資料庫建模
我們已經有了machine和gacha,這兩個物件對於熟悉物件導向程式設計的開發者來說是非常簡單的,但是它們應該如何與資料庫整合呢?

正如在《企業應用程式架構模式》一書中提到的,有三個選項可以描述資料庫上的域邏輯。

  1. 事務指令碼
  2. 表模組
  3. 領域模型

這本書分別解釋了這三種選擇的優缺點,根據我的經驗,我更喜歡使用Table Module。原因是,資料庫是一個獨立的元件,而對於應用程式來說,資料庫實際上是一個Singleton。為了能夠控制對這個Singleton的併發訪問,必須有一個單一的、統一的和公共的介面。
使用Transaction Script,對資料庫的訪問分佈在整個原始碼中,當應用程式變大時幾乎無法管理。另一方面,Domain Model過於複雜,因為它為表的每一行建立一個特定的例項,這在實現原子操作方面非常複雜。所以我選擇了折衷表模組作為與資料庫互動的公共介面。

以上述machine為例。
由於我們已經完成了域物件的構建,讓我們定義表的模式GachaTable。

將DDD應用到資料庫設計中 - lazypro

在表格中,我們可以看到有兩臺機器,一臺以侏羅紀公園Jurassic Park為主題,另一臺以冰河世紀Ice Age為主題,兩者都有兩個單獨的扭蛋。侏羅紀公園的機器看起來像三個,但實際上已經繪製了一個。
Gacha的領域模型直截了當,gacha_id或者更詳細,可能是主題加專案,比如:侏羅紀公園和霸王龍。
一個更有趣的話題是machine. machine需要定義幾個屬性。首先,它應該可以在建構函式中指定一個 id,其次,有兩個原子方法,pop和refill. 我們將在以下部分重點介紹這兩種方法。

原子性的POP
實現一個原子性彈出哇哇POP並不難:先按序號排序,然後取出第一n行,最後設定is_drawn為true. 我們以侏羅紀公園機器為例。

START TRANSACTION;
SELECT * FROM GachaTable WHERE machine_id = 1 AND is_drawn = false ORDER BY gacha_seq LIMIT n FOR UPDATE;
UPDATE GachaTable SET is_drawn = true WHERE gacha_seq IN ${resultSeqs};
COMMIT;


為了避免丟失更新,MySQL 提供了三種方法。在這個例子中,要實現原子更新,最簡單的方法是新增FOR UPDATE到末尾SELECT以搶佔這些行。
更新完成後,SELECT可以將 的結果包裝到Gacha例項中並返回。這樣,呼叫者將能夠獲得抽出的扭蛋並知道抽出了多少個。

原子性Refill
另一種原子方法是refill. 在填充過程中不被打斷很簡單。COMMIT因為只有在條件下,其餘客戶端才會讀取 MySQL 事務Repeatable Read。
START TRANSACTION; for gacha in newGachaPackage(): INSERT INTO GachaTable VALUES ${gacha}; COMMIT;
這就是全部?不,不是。
當兩個使用者都畫n,但沒有足夠的n扭蛋來draw時,就會出現這個問題。

將DDD應用到資料庫設計中 - lazypro
上面的順序圖顯示,當A和B同時抽籤時,A會抽到r個gachas,而B不會,正如我們預期的那樣。然而,A和B會一起refill,導致同一批嘎查被refill兩次。
通常情況下,這不會造成任何重大問題。因為我們按序列號排列流行,我們可以保證第二批只有在第一批被抽乾後才會被抽出。但是如果我們想改變專案,那麼新的專案就會比我們預期的晚放出來。
另一方面,兩個人同時refill,那麼冗餘度就變成了兩倍,如果系統同時有非常多的使用者,那麼冗餘度可能會變成幾倍,佔用大量的資源。
如何解決這個問題?在《解決MySQL中的幻象讀取》一文中提到,我們可以將衝突具體化。換句話說,我們新增一個外部同步機制來調解所有併發的使用者。

在此示例中,我們可以新增一個新表MachineTable.

將DDD應用到資料庫設計中 - lazypro
此表還允許原始machine_id的GachaTable具有附加的外來鍵引用目標。當我們這樣做時refill,我們必須先鎖定這臺機器,然後才能對其進行更新。

START TRANSACTION;
SELECT * FROM MachineTable WHERE machine_id = 1 FOR UPDATE;
SELECT COUNT(*) FROM GachaTable WHERE machine_id = 1 AND is_drawn = false;
if !cnt:
    for gacha in newGachaPackage():
        INSERT INTO GachaTable VALUES ${gacha};
    
COMMIT;


首先,我們獲得獨佔鎖,然後重新確認GachaTable是否需要重新refill,最後,我們實際將資料插入其中。如果沒有重新確認,那麼仍有可能重複refill。
這裡有一些擴充套件的討論。
  1. 為什麼我們需要一個額外的MachineTable?我們不能鎖定原來的GachaTable嗎?由於幻象讀取,MySQL的可重複讀取不能避免在新資料情況下的幻象讀取帶來的寫偏移。
  2. 鎖定MachaTable時,從GachaTable獲取計數時不需要鎖定GachaTable嗎?實際上,這是沒有必要的。因為它會進入補給過程,一定是因為Gacha已經被抽走了,大家都在等待補給,所以不用擔心彈出的問題。


結論
在這篇文章中,我們通過一個真實的例子來解釋在將領域驅動設計與資料庫設計相結合時需要考慮的問題。
最終的結果將包含兩個物件,Gacha和Machine,而資料庫也將包含兩個表,GachaTable和MachineTable。在Machine中的所有方法在本質上都是原子性的。

正如我們在之前的設計步驟中所描述的,我們需要首先定義正確的使用者故事和用例,然後開始建模,最後是實現。與普通的應用程式實現不同,資料庫是作為一個大的Singleton存在的,所以我們需要將資料庫的設計也更好地整合到我們的領域設計中。
為了儘量減少資料庫對整個設計的影響,正確建立領域模型是至關重要的。當然,在這篇文章中,我們採用了表模組的方法來進行領域設計,這有它的優點和缺點。

優點是通過使用Machine領域模型,我們能夠模擬出扭蛋娃娃機Machine的真實外觀,併為所有使用者提供一個共同的介面來同步處理。通過將娃娃機行為封裝到Machine中,未來的娃娃機擴充套件可以很容易地進行。GachaTable和MachineTable的所有操作也將由一個單一物件控制。

缺點是Machine裡面實際上包含了娃娃機機器和gacha表,對於嚴格的物件導向的宗教來說,這太粗糙了。當更多的人蔘與到專案中時,每個人對物件和資料表的理解開始出現分歧,導致設計崩潰。對於一個大型組織來說,表模組有它的範圍,更好的跨部門協作依賴於完整的文件和設計審查,這會影響每個人的工作效率。

相關文章