l

2022年7月6日 星期三

事件溯源(9):套用CQRS簡化領域模型

July 02 18:35~19:12;23:15~24:00;July 03 00:00~13:07

▲圖1:ezKanban Core Domain Model(簡化版)

 

前言

上一集提到CQRS可以簡化設計,這一集以ezKanban core domain為例,說明套用CQRS之後如何簡化原本領域模型之間Aggregate(聚合)的關係。

 

***

雙向關聯造成不必要的複雜度

圖1是ezKanban套用CQRS之前Core Domain的領域模型簡化版,一共有四個Aggregate:Board、Workflow、Card、Tag。其中有兩對Aggregate保持雙向關聯,分別是:

  • Board與Workflow:一個Board可以有多個Workflow,而且必須記錄每一個Workflow在Board上面的順序(order)。此外,Workflow身上紀錄boardId,讓它知道自己屬於哪一個Board。為了記錄Board身上每一個Workflow的順序關係,Board身上有List<CommittedWorkflow>屬性,其中CommittedWorkflow是一個association class,身上有boardId, workflowId, order這三個屬性。
  • Workflow的Lane與Card:一個Lane上面有多張Card,而且必須記錄每一張Card的順序(order)。此外,Card身上也紀錄著workflowId與laneId,讓它知道自己屬於哪一個Workflow的哪個Lane。為了記錄Lane身上每一張Card的順序,Lane身上有List<CommittedCard>屬性,CommittedCard也是一個association class,身上有cardId, laneId, order這三個屬性。

 

接下來以Board與Workflow的關係為例,說明這個雙向關聯對於領域模型造成什麼影響。請參考圖2,為了維持這個雙向關聯,Workflow與Board之間需要狀態同步,CreateWorkflow之後,需要通知Board在它身上加入這個Workflow。在DDD中,Aggregate之間的狀態同步是「狀態最終一致性」。換句話說,為了維持這個雙向關聯,領域模型的實作變得比較複雜(需要維持狀態最終一致性)。

 

▲圖2:為了維持雙向關聯Workflow與Board必須達成狀態最終一致性

 

仔細想一想,為什麼Workflow與Board之間需要維持雙向關聯?因為Board需要知道它身上有多少個Workflow,以及這些Workflow的順序。繼續追問下去,那麼為什麼Board需要知道它身上的Workflow與順序?是Board的業務邏輯需要這些資訊嗎?完全沒有。這些資料是為了顯示用途而存在。如圖3所示,ezKanban的GetBoardContent顯示Board裡面有3個Workflow。也就是說,為了顯示(查詢)用途而導致領域模型增加不必要的複雜度。

 

▲圖3:包含三個Workflow的Board畫面

 

***

Eric Evans怎麼說

在《Domain-Driven Design: Tackling Complexity in the Heart of Software》書中作者Eric Evans提到:

It is important to constrain relationships as much as possible. A bidirectional association means that both objects can be understood only together. When application requirements do not call for traversal in both directions, adding a traversal direction reduces interdependence and simplifies the design. Understanding the domain may reveal a natural directional bias. (盡可能地限制關係很重要。雙向關聯意味著兩個物件只能一起理解。當應用程式不需要雙向遍歷時,採用單向遍歷可以減少相互依賴並簡化設計。了解(問題)領域可能會揭示一種自然的方向偏差。)

如果在問題領域中單向依賴就可以解決問題,在領域模型中就不需要維持雙向依賴。一開始ezKanban並沒有套用CQRS,所以它的領域模型很自然地需要同時滿足寫入(Workflow身上有boardId)與讀取(Board身上有List<CommittedWorkflow>屬性)的需求。在傳統物件導向分析與設計(OOAD)中,因為沒有Aggregate的觀念,所以這種雙向關係並不會造成什麼大問題。因為Board與Workflow直接可以透過記憶體參考而存取對方,因此Workflow狀態改變時Board立即可得知,不需要透過領域事件做到狀態最終一致性。但是在DDD中,因為ezKanban把Board與Workflwo設計成兩個不同的Aggregate,所以這種混合寫入與讀取的單一領域模型,從寫入的角度來看,便產生不必要(透過領域事件達到最終一致性)的複雜度。

Eric Evans在書中提到一些簡化關聯的做法,但是並沒有從讀寫分離的角度來探討如何簡化領域模型的關聯。

***

套用CQRS簡化模型複雜度

Teddy剛剛分析過,Board身上的List<CommittedWorkflow>是為了查詢而存在,寫入模型並不需要這個資料結構。套用CQRS之後,圖1中ezKanban領域模型的兩個為了讀取模型而存在的association class(CommittedWorkflow與CommittedCard)就可以直接拿掉,如圖4所示。

簡化後的寫入模型,省去了不必要的關聯,也去除了不必要的狀態最終一致性。

 


▲圖4:ezKanban套用CQRS之後的寫入模型

 

但是問題來了,ezKanban還是需要知道Board身上有多少個Workflow,以及這些Workflwo的順序。在寫入模型中拿掉List<CommittedWorkflwo>之後,這個資料要從哪裡來?這就要靠CQRS的Query Model來記錄這個關聯性,如圖5所示。

 

▲圖5:ezKanban套用CQRS之後的讀取模型

 

現在剩下最後一個問題:「怎麼產生讀取模型所需的資料?」請參考圖6,在讀取模型中必須撰寫一支用來在讀取資料庫中產生Read Model所需資料的Projector程式。在它會監聽Write Model所發出的領域事件,然後依據這些領域事件在Read Database中投影出Read Model所需的資料。這種資料被稱為「非正規化」或「物質化」資料,為了快速讀取可以允許重複的資料存在。

以ezKanban為例,GetBoardContent查詢所需的資料是一個代表整個Board所有資料的JSON物件,存在PostgreSQL資料庫的jsonb欄位。GetBoardContent查詢只需要下一個SQL指令就可把整個Read Model所需的資料從PostgreSQL讀出來,不需要下任何的join條件,所以查詢的速度很快。

 

▲圖6:ezKanban套用CQRS之後的架構圖,箭頭方向代表data flow

 

在這裡有兩個重點要注意,首先Write Database與Read Database之間的狀態是最終一致性,也就是說Read Model不一定會有最新的資料,這一點在系統設計時被需要考量進去,否則可能會造成使用者體驗不佳。例如,使用者剛剛才下一筆訂單(存在於Write Database中),但在訂單查詢畫面(從Read Database讀取)中卻查不到這筆訂單的資料。所以Teddy常說CQRS雖然簡化寫入與讀取模型內部的複雜度,但卻把複雜度轉換成兩個模型之間狀態同步的問題。至於如何取捨,就要看實際的業務需求與應用情境而定。

第二個重點是,這支Projector程式雖然是屬於Read Model,但它做的工作是「產生Read Model」,也就是說它是負責寫入Read Model的人。它的程式邏輯,可能有部分,甚至很多,和Write Model的Aggregate身上的邏輯互相重複。另外,因為它可能收到重複的領域事件(在分散式系統中,事件傳遞通常只能滿足at least once,不容易做到exactly once),因此它需要滿足idempotent,否則可能投影出錯誤的Read Model。最後,因為Read Model可能因為某些原因導致本身的狀態錯誤或是被刪除,因此Projector必須有能力能夠從頭重建Read Model,通常是藉由replay所有相關的領域事件來達到此功能。

 

***

下集預告

看完CQRS的概念說明,下一集將介紹ezKanbana如何實作Projector,在PostgreSQL資料庫儲存Read Model。

***

友藏內心獨白:頭快爆炸了嗎XD。

沒有留言:

張貼留言