l

2021年3月18日 星期四

領域驅動設計學習筆記(14):透過合約讓領域模型與通用語言更精確

March 18 21:18~23:13

【圖1】ezKanban的design-level event storming(部分)


工商服務

自學好久的領域驅動設計(Domain-Driven Design;DDD)卻連一個完整的小功能都做不出來嗎?覺得DDD要學得東西太多,總是抓不到重點嗎?想要透過事件風暴(event storming)來塑模業務流程並找出bounded context與aggregate,卻不知如何下手嗎?

你需要真實世界的DDD + Event Storming + Clean Architecture 教學與範例,歡迎參加2021年4月2、3、4日(五、六、日)的【領域驅動設計與簡潔架構入門實作班】課程(已確定開課)。

***

前言

在〈用合約彌補測試案例的不足(上):撰寫合約〉與〈用合約彌補測試案例的不足(下):透過合約發現系統行為缺陷〉這兩篇文章中,Teddy談到依合約設計(Design By Contract)在ezKanban系統中的幾個實際應用案例,這一集Teddy要談如何透過撰寫合約找到領域模型與通用語言的行為漏洞。

***

問題敘述

幾天前Teddy去客戶家上DDD與Event Storming企業內訓課程,沒有和ezKanban團隊mobbing。今天mobbing的時候團隊問Teddy一個關於Aggregate行為的問題,請先參考圖1:

  • WorkflowCard是兩個Aggregate,前者代表看板的工作流程,後者代表一項工作。
  • Workflow包含垂直的工作階段—Stage,與水平的工作階段—SwimLane
  • 使用者新增Card之後,Card被放在Workflow的Stage或SwimLane裡面,使用者可以調整卡片的順序(order),該順序紀錄於Stage和SwimLane身上。
  • 由於Workflow與Card分屬不同的Aggregate,因此當使用者移動卡片(Move Card)之後,Card必須通知Workflow卡片已經移動,以同步Workflow身上關於卡片位置與順序的狀態。


團隊遇到的情況是:他們在寫Card.move() 的前置條件(如圖2),原本希望除了檢查參數不是null,以及order >= 0 以外,還能夠檢查order不會大於該卡片所要加入的那個Stage/SwimLane的卡片總數。例如,使用者在To Do這個Stage加上一張卡片,To Do原本已經有2張卡片,move收到的order參數是8,則前置條件檢查失敗。團隊希望能在Card.move()的前置條件檢查這個限制。


【圖2】Card.move方法的前置條件

但是,因為一個Stage/SwimLane身上有多少張卡片的資料是儲存在Stage/SwimLane身上,Card並不知道,所以也就沒辦法在Card.move()方法加上這一條前置條件。

於是團隊問Teddy:是不是應該把move方法移到Workflow上面,這樣就可以在前置條件中檢查Card的順序?」

***

思考邏輯

這個問題,可以從以下兩點來思考:

  1. Single source of truth:Card與Workflow分屬不同的Aggregate,兩者之間透過領域事件達到狀態最終一致性。因此,第一個思考點就是:「關於一張卡片移動至哪個Stage/SwimLane這件事,擁有第一手資訊的人是誰?Workflow還是Card?」Teddy覺得應該是Card。
  2. 關於檢查Card順序這件事,除了原本的Card.move()方法以外,另外一個地方就是Workflow.commitCardInLane這個方法,請參考圖3第270~271行,檢查了卡片的順序不能大於現有Stage/SwimLane裡面所擁有的卡片數量。

【圖3】Workflow.commitCardInLane()方法的前置條件


現在假設一個情況:

  • 一張卡片要移到To Do上面的第8個位置。
  • To Do身上只有2張卡片。

因此,圖3第270~271行的前置條件會失敗,丟出PreconditionViolationException並且終止程式執行,代表呼叫它的人有bug。呼叫commitCardInLane的人是誰?是CardMoved領域事件發生後的Policy,簡單來說就是Card有bug,這個bug很可能是前端傳過來錯誤的order。

***

如果不希望因為卡片順序有問題就終止整個系統執行,另一種可能性是放寬Workflow.commitCardInLane()的前置條件,把270~271行拿掉,改成在程式裏面(commitCardInLane的method body)判斷,如果條件不成立則丟出一個CardCommittedFailed領域事件,並通知Card執行交易補償(因為Card已經把它自己的狀態改成移動到新的To Do裡面,但Workflow判斷卡片所移動的順序是不合法的,所以請卡片移回它原本的位置)。

如果選擇這種作法,則原本圖1要再加上CardCommittedFailed領域事件,如圖4所示。


【圖4】讓領域事件以及Workflow Aggregate與Card Aggregate的行為更加完整。

***

結論

無論是選擇上述兩種方式的哪一種,系統的行為都因為撰寫合約而變得更精確。最後團隊與Teddy選擇了第二種方式--交易補償,因為這種行為模式容錯能力比較好。

看到這裡如果還沒跑開的鄉民,應該可以發現以下結論:

透過撰寫合約讓領域模型與通用語言更加完整。

***

友藏內心獨白:寫合約可以找到系統行為的漏洞。

2021年3月10日 星期三

用合約彌補測試案例的不足(下):透過合約發現系統行為缺陷

March 10 14:28~16:05

【圖1】ezKanban領域模型(部分)


前情提要

上集〈用合約彌補測試案例的不足(上):撰寫合約〉提到兩個撰寫合約的例子,今天介紹透過撰寫合約發現ezKanban系統bug的實際案例。

***

commitCardInLane的測試案例

在講解透過合約找到系統行為bug之前先介紹ezKanban的Board bounded context的部分領域模型,如圖1所示。圖1包含兩個Aggregate,Workflow與Card:

  • Workflow是Aggregate Root,它包含多個Lane。
  • Lane有兩種,垂直的叫做Stage,水平的稱為Swimlane
  • Card是Aggregate Root,它代表看板上面的一張卡片。Card放在某個Lane上面,兩者之間的關係用CommittedCard表示。

Workflow身上有一個commitCardInLane方法,表示某張卡片被放入某個Lane,其介面如下:

public void commitCardInLane(CardId cardId, LaneId laneId, int order, String userId)
  • cardId:要放入的card Id
  • laneId:要放在哪個lane上面的lane id
  • order:順序
  • userId:執行該操作的使用者 id

ezKanban採用TDD方式開發,針對commitCardInLane已經有一個測試案例如下:

【圖2】commitCardInLane的單元測試


由於workflow.commitCardInLane是一個很簡單的方法,它的實作類似以下兩行程式:

Lane lane = getLaneById(cardCommitted.getLaneId()).get();
lane.addCommittedCard(cardCommitted.getCardId(), cardCommitted.getOrder());

因為卡片實際上是放在lane上面,因此還需要幫lane.addCommittedCard寫一個測試,如圖3所示。


【圖3】這個測試案例規範一個行為—卡片的順序等於它被加入lane的順序。

寫完這兩個測試案例,感覺就差不多了,程式跑起來也都沒什麼問題。

***

commitCardInLane被疏忽的行為

再看一次圖4的commitCardInLane介面:

【圖4】commitCardInLane介面


當ezKanban團隊與Teddy幫commitCardInLen撰寫合約的時候,首先寫出以下幾個沒爭議precondition (前置條件):

  • car id is not null
  • lane id is not null
  • order >= 0
  • user is is not null
  • 要加入的那個lane必須存在

接著想到,既然卡片是要放到某個lane裡面,因此還需要一條:

  • 要加入的card 不存在lane id所屬的那個lane裡面

上述precondition只規範了同一張卡片不能同時存在同一個lane裡面。寫完之後又想到另一個問題:「卡片可以同時存在不同的lane裡面嗎?」不行,一張卡片只能出現在一個lane裡面,所以剛剛那條precondition寫得不夠精確,要改成:

  • 要加入的card 不存在所有的lane裡面


最後的precondition如下圖所示:

【圖5】commitCardInLane的preconditions


加上圖5的preconditions之後,就可以確保在這些前置條件滿足的情況之下,加入一張卡片之後,它就一定會依據正確的順序被放入正確的lane裡面。

最後,補寫一個單元測試用來確認剛剛最後一條前置條件所規範的行為,參考圖6。

【圖6】補寫測試案例來記錄由合約所發現的系統行為

***

討論

看到這裡,有經驗的鄉民可能會說:

從TDD/Specification by Example(實例化規格)的角度,「要加入的card 不存在所有的lane裡面」的這一個系統行為也是可以被找出來啊,只要在實例化規格的時候列舉出這個例子不就好了?根本不需要寫合約也可以發現。

這樣講「理論上」是沒錯的,但實際上:

  • 在實例化規格「找例子」的時候,也是有優先順序之分。團隊通常會把與領域專家開會的寶貴時間拿來討論最重要的系統行為。像是commitCardInLane(把一張卡片放到lane裡面)這種有點重要又不是太重要的行為,能夠想出「卡片要成功放入lane」以及「卡片的順序要和放入的順序一致」這兩個例子,已經算及格了。在有限的時間與壓力之下,在想例子的當下不見得很容易就想到「一張卡片不能重複放在多個lane」這個例子。
  • 寫合約就不一樣,因為人只要講到「簽訂合約」,立刻會從「君子模式」切換到「小人模式」。針對合約內容,能多挑剔就多挑剔,除了保護自己不要上當以外,還要幫自己取得最大利益。因此,光是從method介面,就很容易可以想出很多系統行為的「邊界條件」。

寫合約還有一個好處,它不需要像測試案例一樣,為了讓測試案例跑起來,需要:

  • 準備測試資料
  • 執行待測程式
  • 比對測試資料

寫合約只需要……寫合約。就是用一段程式碼描述程式的行為即可,這段程式碼一開始甚至可以是空的,只需要有名稱即可。例如圖5的205行card_not_committed_to_any_lay()用來代表「一張卡片不能重複放在多個lane」的前置條件。先把合約的名稱(要描述的系統行為)想好,在撰寫合約的檢查內容即可。

***

工商服務

在套用領域驅動設計的時候,你的物件導向領域模型都生不出來嗎?針對領域模型的狀態正確性感到很棘手嗎?你想透過撰寫合約來增加領域模型的正確性嗎?

你需要補充基本的物件導向養分,歡迎參加2021年4月20日(週二)的【敏捷開發懶人包:物件導向技能】一日課程。

***

友藏內心獨白:合約就是用規格取代舉例。

用合約彌補測試案例的不足(上):撰寫合約

March 09 23:05~March 10 00:55


工商服務

在套用領域驅動設計的時候,你的物件導向領域模型都生不出來嗎?針對領域模型的狀態正確性感到很棘手嗎?你想透過撰寫合約來增加領域模型的正確性嗎?

你需要補充基本的物件導向養分,歡迎參加2021年4月20日(二)的【敏捷開發懶人包:物件導向技能】課程。

***

前言

前一陣子Teddy與ezKanban團隊將ezKanban系統重構成能夠支援event sourcing,在重構的過程中,不免破壞了一些原本正常執行的測試案例。由於被影響的測試案例數量太多,雖然都是一些介面修改的小地方,但是數量一多還是很費工夫。

這個現象讓Teddy注意到原本的測試案例可讀性有點降低的趨勢,於是花了幾天重構測試案例。在這個過程中,Teddy發現雖然目前ezKanban系統一共有380個測試方法(test method),但還是有一些程式行為沒有涵蓋到。

另一方面,有些測試案例感覺又過於簡單,簡單到幾乎不會出錯,好像也沒什麼撰寫的必要,但是不寫又覺得心不安。例如,修改Board名稱的單元測試,最原始的程式碼就只有:

this.name = name;

簡單到不會出錯的程式就可以不用寫自動化測試,但是,因為採用領域驅動設計的方式開發ezKanban,所以除了this.name=name以外,還要發出BoardRenamed領域事件,這也增加了另一個需要測試它的動機。

因此,Teddy想到很多年前學過的Design By Contract(DBC;依合約設計),透過撰寫合約可以定義程式的行為,並在runtime自動驗證這些合約是否成立。若合約被違反,便代表程式有bug。合約撰寫越完整,可以大幅減少撰寫測試案例的數量。

這兩集Teddy要談在ezKanban中撰寫合約的方式,以及如何透過撰寫合約找到新的bug,之後再談如何簡化單元測試。

***

準備撰寫合約工具

合約有三種:precondition(前置條件)postcondition(後置條件)class invariant(類別不變量)。要在程式中撰寫合約需要程式語言支援。但可惜的是知名的商用程式語言除了Eiffel以外,Teddy就沒有聽過其他語言有內建支援合約。

退而求其次,如果有工具、框架支援,也是可以。但支援DBC的框架雖然有好幾個,但感覺都是實驗性質的成分比較多,而且很多都已經不再維護。

結論就是,離開Eiffel語言以外,先不要在其他程式語言追求完整的DBC支援。先求有,再求好。Teddy就是秉持這個精神,開始在ezKanban中撰寫合約。

一條合約就一個assertion,中文翻成斷言,也就是結果為真的敘述。講白了合約就是一個結果為true的if條件式。

例如,以下敘述都是assertion:

  • Teddy是泰迪軟體創辦人
  • 台灣中南部現在缺水
  • Pascal是Teddy收編的貓
  • 海水是鹹的
  • 你走路的速度小於光速


所以只需要把一個if條件式包在代表precondition與postcondition的method裡面,就可以用最陽春的方式撰寫合約,如圖1所示。

【圖1】土炮撰寫合約的Contract類別

***

合約範例1

【圖2】Board類別的rename method


先看一個簡單的範例:幫Board類別的rename method寫合約,如圖2所示。因為ezKanban套用了event sourcing,54~59行代表發出BoardRenamed領域事件,真正設定board name = newName 的程式碼在另一個私有方法handleBoardRename身上,如圖3所示。


【圖3】BoardRenamed領域事件的處理程式

圖3是rename方法的實作細節,訂合約的時候不用去理會他,把焦點放在圖2,幫rename撰寫合約:

precondition:

    requireNotNull("Board name cannot be null", newName);

postcondition:

ensure(format("Board name is '%s'", newName), getName().equals(newName));


這段合約表示:如果呼叫rename給定一個非null的字串newName(precondition的內容),則rename執行完畢後,Board name就會等於newName(postcondition的內容)。這也就是rename的語意

看到這裡Teddy要請問一個問題:「Board name可否為空字串?!

依據precondition,board name是可以接受空字串。如果你們的商業邏輯是規定board name不能是空的,就需要再加上一條precondition:

require("Board name cannot be empty", 
!newName.isEmpty());

***

合約範例2

最後看一個複雜一點的合約,Workflow類別的moveLane方法,如圖4所示。一個看板的Workflow(工作流程)可以有多個Lane,每個Lane代表一個工作步驟,它底下可以有多個sub lane。


【圖4】Workflow類別的moveLane方法

moveLane的:

precondition

  • lane id不能是null
  • new parent id不能是null
  • order是正整數
  • User id不能是null
  • 要移動的lane必須存在workflow裡面
  • 要移動的新位置(new parent)也必須存在workflow裡面
  • 不能移動到自己身上,或是自己的子節點

postcondition

  • lane的parent id等於new parent id(確定有移動到新的節點之下)
  • lane的order等於new order (移動後的順序是對的)
  • lane從原本的parent移除(確定lane已經不存在原本的parent,是移動而非複製)
  • 新parent的子節點都已排序
  • 舊parent的子節點都已排序

以上,如果合約訂得夠完整足以代表程式的行為,測試案例只要去呼叫這些method即可,不需要在測試案例裡面寫太多assertion,因為合約正確就代表程式行為正確

但前提是,你的合約要寫對。因為合約也是程式碼,也可能會寫錯XD。

***

友藏內心獨白:既使沒有程式語言的支援,合約也是一種很棒的設計方法。