iT邦幫忙

2022 iThome 鐵人賽

DAY 19
0
Software Development

軟體架構師的自我修養系列 第 19

[Day 19] 乾淨架構實戰三部曲

  • 分享至 

  • xImage
  •  

這篇文章是三部曲的終局,在首部曲時,我們介紹資料導向設計,於此同時我們發現資料導向設計的諸多問題。因此在二部曲時,我們提供了一個完整的設計流程,既參照了領域驅動設計也使用乾淨架構,更重要的是,沒有教科書般的生硬,透過單純的步驟就可以一步一步拆解需求。

今天,我們會接續昨天的流程,並擴展資料庫的相關設計。但我們會用一個新的例子,來更完整講述資料庫設計的相關議題。

本次我們依然會用個生活化的例子,實作一個線上扭蛋機,並透過與MySQL的結合來了解資料庫在軟體設計中扮演什麼角色。

使用者故事

就像我們之前做的,首先從描寫使用者故事開始,並透過故事來瞭解這次的功能需求。

  • 我們有一間線上扭蛋店,其內有許多扭蛋機。每一台機器都可以有不同的商品組合。
  • 為了讓慷慨的顧客可以一次包排,我們提供一個單次抽多個扭蛋的選項。
  • 當一台機器內的庫存耗盡,我們必須馬上填充。
  • 當顧客一次抽很多扭蛋,但中途把機器抽完了,在我們立刻填充後,他能接著繼續抽。

這樣的故事情境其實與現實中的扭蛋機相同,在我們有了清晰描述後,我們可以知道該如何為領域模型建模。

一般來說,我們需要兩個模型,一個是扭蛋機,一個是扭蛋本身。

使用案例

接著,我們要根據使用者故事定義更精準的使用案例。

不像是使用者故事,使用案例會仔細描述發生了什麼以及系統該如何反應。

  • 因為這是一間線上扭蛋店,所以有可能同時會有很多使用者一起抽,但就像實體機器一樣,每個人必須排隊,即便一次抽取數個也不會出現交錯的情況。
  • 在填充扭蛋的過程中,使用者不能抽,必須等到全部扭蛋都填充完畢才能繼續。
  • 當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指的就是扭蛋機,並且提供poprefill兩個方法。另一方面,gachas是一群扭蛋gacha的列表。

在我們進一步之前,有一件重要的事必須被確立。那就是poprefill都必須要是原子性的,為了避免競爭條件(racing condition),當許多顧客同時抽的時候,這兩個方法都要是原子性且不可強佔(preempt)。

資料庫建模

我們已經能夠為machinegacha這兩個模型建模,這兩個模型對於熟悉物件導向開發的人來說應該很單純,但是他們該如何與資料庫整合?

Patterns of Enterprise Application Architecture這本書提到,有三種在資料庫上描述領域邏輯的方式:

  1. Transaction Script
  2. Table Module
  3. Domain Model

書中個別解釋了他們的優缺點,然而在我的經驗中,我偏好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和其他細節,例如主題是侏羅紀公園和扭蛋種類是雷克斯霸王龍。

更有趣的模型是machinemachine必須要定義許多屬性,首先,他需要在建構子能指定id,接著有兩個原子性的方法,poprefill。我們會專注在這兩個方法的實作。

原子性的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

這張表也可以作為原本GachaTablemachine_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,最後我們實際把資料插入。如果沒有重新確認,那仍然有可能會有重複充填。

這邊有一些延伸的討論。

  1. 為什麼需要一個額外的MachineTable?難道不能鎖在原本的GachaTable上嗎?因為MySQL在Repeatable Read無法避免寫入偏斜產生的幻讀。
  2. 當鎖了MachineTable,難道不需要在計數的時候接著鎖GachaTable嗎?不必要,因為會進入充填流程一定是因為扭蛋被抽光了,而所有人都在等待充填,因此不需要擔心pop會偷跑。

結論

在這篇文章中,我們透過一個實際的例子解釋了資料庫在領域驅動開發中扮演的角色。

在完整的設計中包含兩個領域物件:GachaMachine,同時資料庫也有儲存這兩個物件的對應資料表:GachaTableMachineTable。在Machine中,所有的操作都是原子性的。

根據我們之前描述的設計流程,我們首先需要定義正確的使用者故事和使用案例,接著開始領域物件建模,最後是實作。

與一般應用程式開發不同,資料庫本身是一個大型的Singleton,因此我們也需要更好地整合資料庫設計。

為了減少在整個設計過程中資料庫產生的影響,我們必須建立正確的領域模型。當然,在這篇文章中我們使用Table Module作為資料庫的領域設計方案。

Table Module有其優缺點。優點是,透過使用Machine,我們可以模擬現實的扭蛋機行為,並且為所有使用者提供一個統一的介面以同步彼此操作。將扭蛋機的行為包裝進Machine,未來也可以更容易為扭蛋機添加新功能,且所有GachaTableMachineTable的操作都封裝在同一個物件內。

但這樣的做法也有缺點。缺點是,Machine其實包含了兩張資料表的操作,這對於物件導向的信徒來說是很粗糙的。當有更多人一起參與扭蛋的專案,每個人對於領域物件和資料表的認知會開始出現分歧,進一步導致設計崩潰。

對於一個大型組織來說,Table Module有他的範圍限制,要能跨部門合作必須依賴完整的文件和設計審閱,這都會影響每個人的生產力。

在這篇文章中我們並沒過多著墨在設計流程,更多的是專注在領域物件上結合資料庫設計。且有許多資料庫的細節並沒有被很好的解釋,就讓我們把這些資料庫細節留到明天和後天吧!


上一篇
[Day 18] 乾淨架構實戰二部曲
下一篇
[Day 20] 如何在MySQL避免競爭條件
系列文
軟體架構師的自我修養31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言