l

2022年7月7日 星期四

事件溯源(10):實作Projector

July 03 09:38~12:01


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

 

前言

上一集介紹在ezKanban中套用CQRS簡化領域模型的例子,這一集將說明ezKanban如何實作Projector以便在PostgreSQL資料庫中產生Read Model所需的資料。

 

***

那些查詢需要快取?

讀取資料庫中專門為特定查詢所準備的資料,其實就是一種快取(Cache)。這些快取資料由Projector所產生,因此在資料庫層套用CQRS,必須要先決定:「需要幫那些查詢產生Read Model所需的快取資料?」

以ezKanbna為例,有兩個主要且比較複雜的查詢畫面,分別是圖2的GetDashboard,以及圖3的GetBoardContent。前者是使用者登入ezKanban之後看到屬於他個人的所有Team(畫面最左方)、每一個Team裡面的Project,以及每個專案中有那些Board的畫面。後者是使用者進入某一個Board之後,所看到屬於該Board的所有Workflow、Card與Tag。

由於GetBoardContent需要滿足多人線上協同合作的需求,讀取頻率相對來說很高,需要考慮讀取效率的問題。因此ezKanban幫GetBoardContent在讀取資料庫中產生快取資料,以優化效能並達到簡化領域模型(寫入模型)的目的。

至於GetDashboard是使用者個人看到的畫面,沒有多人線上協同合作的需求,比較沒有讀取效率的問題。因此ezKanban目前並沒有幫GetDashboard在讀取資料庫中產生快取資料,它的資料是即時從寫入資料庫中所產生。

從軟體架構的角度來看,Teddy之前提過可以在不同架構階層套用CQRS,例如API層(Adapter層)、使用案例層、領域模型層與資料庫層。以上述GetBoardContent與GetDashboard為例,GetBoardContent的CQRS套用到資料庫層,而GetDashboard只套用到使用案例層(GetDashboard本身是一個查詢使用案例)

在這裡Teddy提醒一點套用CQRS很容易誤會的點,就是在資料庫層套用CQRS是一個逐案探討(case by case)的情境,也就是說不是所有的查詢都需要在資料庫中準備一份快取資料。因為幫特定查詢準備快取資料這件事,需要撰寫特殊的Projector程式,而這件事也是一個開發成本。所以只有針對特別講求效能的查詢,或是為了簡化寫入模型這兩個目的,才需要在資料庫中產生快取資料(在ezKanban中目前只遇到這兩種情況)。

    

 ▲圖2:ezKanban的GetDashboard畫面

 

     

  ▲圖3:ezKanban的GetBoardContent畫面

 

***

NotifyBoardContent Projector

在ezKanban中產生GetBoardContent所需讀取快取的Projector稱為NotifyBoardContent,程式碼如圖4所示。由於NotifyBoardContent負責投影出整個Board裡面的所有資料,因此它需要去聽Board、Workflow、Card、Tag這四個Aggregate的領域事件,加起來一共有34個。

收到這些領域事件之後,NotifyBoardContent透過BoardContentStateRepository從資料庫中讀出原本的快取資料,然後更新這份快取資料,最後把快取資料寫回資料庫。參考圖4第60行~67行,當NotifyBoardContent收到WorkflowCreated領域事件之後,它先產生一個workflwoState物件代表這個新增的Workflow(第61行),然後以領域物件做為參數,重新apply一次這個事件,以便設定workflwoState的值(第62行)。

接著從資料庫中讀出BoardContentState(GetDashboard所需的快取資料,如圖5所示),將workflwoState加入BoardContentState,然後把BoardContentState回存到資料庫中,更新快取資料。如此便完成一次投影讀取資料的操作。

 


▲圖4:產生GetDashboard所需讀取快取的NotifyBoardContent Projector程式

 

圖5中的BoardContentState,不屬於DDD裡面的領域模型物件(不是Aggregate、不是Entity、不是Value Object也不是Domain Service),它是放在Use Case層的View Model物件,專門服務某特定畫面所需的資料結構。從程式碼中可以看出,第17行BoardState主要紀錄原本放在Board Aggregate的資料,第18行List<WorkflowState>記錄Board身上所有Workflow的資料,以及這些Workflow的順序。第19行儲存這個Board裡面所有Tag資料,第20行紀錄每一個Lane身上的所有Card資料。

由於BoardContentState是一個讀取模型,它的資料結構包含一整坨這個畫面所需要的全部資料都放在一起,因此讀取資料時只要下一個查詢條件就可以直接從資料庫讀出,加快讀取速度。

 

▲圖5:BoardContentStatet介面

 

BoardContentState轉成JSON(格式如圖6所示)存在PostgreSQL資料庫表格的jsonb欄位中,GetBoardContent讀出後直接傳給前端的React程式,React收到BoardContentState之後把它存在Redux並以此做為前端顯示資料的狀態。

 

▲圖6:BoardContentStatet的JSON檔案內容

***

Projector好難寫

從圖4可以看出NotifyBoardContent為了從34個領域物件投影出讀取模型,它的程式碼還挺複雜的,而且很多用來維持讀取模型狀態的程式碼和寫入模型中Board、Workflow、Card、Tag 這些Aggregate維持自身狀態的程式碼幾乎相同,所以會有重複程式碼壞味道的問題產生。

在ezKanbna中因為剛好套用DCI(Data Context Interaction)架構,因此NotifyBoardContent可以重複使用寫入模型中用來更新Aggregate狀態的函數,解決重複程式碼的問題。但是在一般軟體開發專案中,如何設計與撰寫Projector的確是一個需要注意的問題。

 

***

下集預告

除了本集所介紹的NotifyBoardContent這種接收寫入模型的領域事件然後在資料庫中產生Read Model的方式以外,還有其它不同的做法。例如,使用關聯式資料庫建立Indexed View就是非常簡單且常用的產生讀取模型作法。另外像是Database Replication(利用資料庫內建的複製功能,將主要資料庫複製到次要資料庫)也是一種分散讀取負載的做法(把次要資料庫當成Read Database使用)。還有就是使用Change Data Capture (CDC)工具,自動擷取出資料庫中異動的資料(不依靠領域事件產生資料異動,而是直接在資料庫層級攔截資料異動紀錄),然後再投影出讀模型。

以上三種方式屬於傳統關聯式資料庫所支援的產生讀取模型方法,和Event Sourcing比較無關。在下一集中,Teddy將介紹EventStoreDB所支援的另一種直接在事件溯源資料庫中產生讀取模型的方法。

***

友藏內心獨白:雖然套用了DCI,ezKanban的NotifyBoardContent也是寫了好幾天。


2 則留言: