這篇文章是三部曲的終局,在首部曲時,我們介紹資料導向設計,於此同時我們發現資料導向設計的諸多問題。因此在二部曲時,我們提供了一個完整的設計流程,既參照了領域驅動設計也使用乾淨架構,更重要的是,沒有教科書般的生硬,透過單純的步驟就可以一步一步拆解需求。
今天,我們會接續昨天的流程,並擴展資料庫的相關設計。但我們會用一個新的例子,來更完整講述資料庫設計的相關議題。
本次我們依然會用個生活化的例子,實作一個線上扭蛋機,並透過與MySQL的結合來了解資料庫在軟體設計中扮演什麼角色。
就像我們之前做的,首先從描寫使用者故事開始,並透過故事來瞭解這次的功能需求。
這樣的故事情境其實與現實中的扭蛋機相同,在我們有了清晰描述後,我們可以知道該如何為領域模型建模。
一般來說,我們需要兩個模型,一個是扭蛋機,一個是扭蛋本身。
接著,我們要根據使用者故事定義更精準的使用案例。
不像是使用者故事,使用案例會仔細描述發生了什麼以及系統該如何反應。
有了使用案例,我們既可以畫出流程圖也可以寫出整個流程。在上次我們使用的是流程圖,在這個範例中,我選擇用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
都必須要是原子性的,為了避免競爭條件(racing condition),當許多顧客同時抽的時候,這兩個方法都要是原子性且不可強佔(preempt)。
我們已經能夠為machine
和gacha
這兩個模型建模,這兩個模型對於熟悉物件導向開發的人來說應該很單純,但是他們該如何與資料庫整合?
在Patterns of Enterprise Application Architecture這本書提到,有三種在資料庫上描述領域邏輯的方式:
書中個別解釋了他們的優缺點,然而在我的經驗中,我偏好Table Module。理由是,資料庫可以視為一個獨立單元,對於應用程式來說,資料庫其實就是Singleton設計模式。
為了能夠在Singleton上控制同步存取,那麼就必要定義一個統一且公開的介面。
對於Transaction Script,存取資料的邏輯會散佈在整個程式碼,在應用程式變得複雜後,管理存取點基本上是不可能的任務。
另一方面,Domain Model非常複雜,他會為資料庫中的每一行建立一個特定的物件實體(instance),這在實作原子性操作時難度倍增。
因此我習慣選擇位於光譜中間的Table Module作為整合資料庫的統一公開介面。
以上述的machine
為例。
讓我們來定義資料表GachaTable
的綱要(schema)。
gacha_seq | machine_id | gacha_id | is_drawn |
---|---|---|---|
1 | 1 | jurassic-park-t-rex | true |
2 | 1 | jurassic-park-t-rex | false |
3 | 1 | jurassic-park-velociraptor | false |
4 | 2 | ice-age-mammoth | false |
5 | 2 | ice-age-mammoth | false |
在這張表中,我們可以看到有兩台扭蛋機,其中一台的主題是侏羅紀公園,而另一台是冰河歷險記。他們有著各自的扭蛋種類。侏羅紀公園的扭蛋機看起來有三個扭蛋,但其中一個已經被抽走了。
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有三種不同的實踐,在這個範例中我們選擇最簡單的做法:在SELET
的後面加上FOR UPDATE
來避免這些行被強佔。
註:其他方法我們會留到明天在詳細解釋。
當更新完成,SELET
的結果會被包裝成Gacha
實體並回傳。呼叫者就可以拿到轉出的扭蛋,並且知道抽了多少。
refill
另一個原子性的方法是refill
。在執行過程中要不被中斷也很單純,因為MySQL在Repeatable Read
的隔離等級下,寫入的資料要直到交易被COMMIT
才能夠被讀取。
START TRANSACTION;
for gacha in newGachaPackage():
INSERT INTO GachaTable VALUES ${gacha};
COMMIT;
這樣就結束了嗎?不,還沒。
當兩個使用者同時抽n
次但機器內卻不足n
個扭蛋時會出問題。
從時序圖可以看到,當A和B同時抽,A可以抽出r
個扭蛋,但B抽不到,這正如我們預期。但是,A和B都會呼叫refill
,就結果來說,相同批次的扭蛋會被填入兩次。
通常,這不會造成什麼大問題,因為我們會按照順序抽,因此可以保證第二批扭蛋會在第一批抽完後才被抽到。但是,如果我們想要更換品項,那新品發布的時間會比我們預期的更晚,因為機器裡有一批扭蛋是被額外充填的。
另一方面,當兩個人同時抽,所以重複充填發生兩次,若是這是一個高人次的系統,同時會有許多人一起抽,那麼重複充填就會發生非常多次並且佔用大量資源。
那該如何解決這個問題?
我們可以使用「衝突外部化」來解決,換言之,我們透過一個外部的同步機制來仲裁所有同時的使用者們。
註:衝突外部化也是MySQL一個常用的手段,預計在後天會詳細介紹
在這例子中,我們加入一張新的表MachineTable
。
machine_id | machine_model |
---|---|
1 | APTX4869 |
2 | APTX4870 |
這張表也可以作為原本GachaTable
中machine_id
的外鍵。當我們要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
,最後我們實際把資料插入。如果沒有重新確認,那仍然有可能會有重複充填。
這邊有一些延伸的討論。
MachineTable
?難道不能鎖在原本的GachaTable
上嗎?因為MySQL在Repeatable Read
無法避免寫入偏斜產生的幻讀。MachineTable
,難道不需要在計數的時候接著鎖GachaTable
嗎?不必要,因為會進入充填流程一定是因為扭蛋被抽光了,而所有人都在等待充填,因此不需要擔心pop
會偷跑。在這篇文章中,我們透過一個實際的例子解釋了資料庫在領域驅動開發中扮演的角色。
在完整的設計中包含兩個領域物件:Gacha
和Machine
,同時資料庫也有儲存這兩個物件的對應資料表:GachaTable
和MachineTable
。在Machine
中,所有的操作都是原子性的。
根據我們之前描述的設計流程,我們首先需要定義正確的使用者故事和使用案例,接著開始領域物件建模,最後是實作。
與一般應用程式開發不同,資料庫本身是一個大型的Singleton,因此我們也需要更好地整合資料庫設計。
為了減少在整個設計過程中資料庫產生的影響,我們必須建立正確的領域模型。當然,在這篇文章中我們使用Table Module作為資料庫的領域設計方案。
但Table Module有其優缺點。優點是,透過使用Machine
,我們可以模擬現實的扭蛋機行為,並且為所有使用者提供一個統一的介面以同步彼此操作。將扭蛋機的行為包裝進Machine
,未來也可以更容易為扭蛋機添加新功能,且所有GachaTable
和MachineTable
的操作都封裝在同一個物件內。
但這樣的做法也有缺點。缺點是,Machine
其實包含了兩張資料表的操作,這對於物件導向的信徒來說是很粗糙的。當有更多人一起參與扭蛋的專案,每個人對於領域物件和資料表的認知會開始出現分歧,進一步導致設計崩潰。
對於一個大型組織來說,Table Module有他的範圍限制,要能跨部門合作必須依賴完整的文件和設計審閱,這都會影響每個人的生產力。
在這篇文章中我們並沒過多著墨在設計流程,更多的是專注在領域物件上結合資料庫設計。且有許多資料庫的細節並沒有被很好的解釋,就讓我們把這些資料庫細節留到明天和後天吧!