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也是寫了好幾天。


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。

2022年7月5日 星期二

事件溯源(8):什麼是CQRS?

July 02 06:35~08:20

▲圖1:ezKanban套用CQRS架構圖

 

前言

CQRSCommand Query Responsibility Segregation的縮寫,中文翻成「命令與查詢責任分離」。今天介紹CQRS的涵義以及它可以解決什麼問題。

 

***

起源

CQRS是由Greg Young所提出的設計模式,它的概念很簡單:分開設計以下兩種操作:會改變系統狀態但不會回傳值的操作,稱之為Command,以及不會改變狀態但會回傳值的操作,稱為Query 。Greg Young同時也是Event Sourcing的提倡者,他在2011年開發EventStoreDB這個直接支援Event Sourcing與CQRS的資料庫,Teddy在<事件溯源(3):將Aggregate儲存至EventStoreDB>介紹如何用這個資料庫來做儲存領域事件。

***

CQRS有時又稱為簡稱讀寫分離,此概念並非Greg Young首創,最早由Eiffel語言與Design By Contract(DBC)發明人Bertrand Meyer所提出,稱為Command–Query Separation(CQS)。CQS的應用對象是物件,而CQRS則將範圍拓展到整個系統,包含API、Use Case、Domain Model、Database,通通可以讀寫分離。

這些資料網路上隨便查一下就找得到,聽過CQRS的鄉民可能都知道,但你知道Bertrand Meyer為什麼要提出CQS嗎?

CQS和DBC有關。在撰寫合約的時候,不管是preconditions或postconditions,都可能需要呼叫物件的method來做為狀態驗證。圖2是ezKanban系統中,Workflow Aggregate的deleteLaneById方法。ezKanban有套用DBC,第117行~119行是preconditions,第131行是postcondition。在第119行與131行中,呼叫getLaneById()方法檢查某個lane是否存在,getLaneById() 就是query,它回傳boolean但不會改變物件狀態。因為合約撰寫在物件身上,如果物件設計沒有遵守CQS,那麼你怎麼知道在合約中呼叫getLaneById()方法會不會不小心改變了系統狀態?如果沒有CQS,DBC就玩不下去了。

CQS提出至今已有30幾年,為什麼沒有大紅大紫,大部分的鄉民都是因為CQRS才知道CQS?答案很簡單,因為CQS的應用與DBC緊密相關,而大部分的開發人員並沒有實際應用DBC的機會,所以才會沒聽過CQS。

 

 

▲圖2:ezKanban系統Workflow的deleteLaneById方法

 

CQRS和DBC脫鉤,趕上分散式計算、微服務架構的大環境,拓展其應用的機會,所以才流行起來。

***

CQRS的好處

CQRS有以下三個主要的優點,簡稱為3S:

  • Simplicity:CQRS將系統分成Write Model與Read Model,這兩種模型的行為與責任大不相同。從單一責任原則(Single Responsibility Principle;SRP)的角度來看,CQRS進一步簡化不同模型之內的複雜度。
    • 寫入模型:
      • Strong consistency
      • normalized data model
      • one-way dependency
    • 讀取模型:
      • Eventually consistency
      • de-normalized data model (materialized view)
      • any-way dependency
  • Scalability:很多系統的讀取頻率遠大於寫入,例如在電子商務系統中大部分的使用者都在瀏覽資料,少部分的操作才會改變系統狀態。在這種情況下,套用CQRS可以單獨針對讀取部分加以拓展,如圖3所示。
  • Speed (Performance):綜合上述兩個優點,CQRS可以提升系統反應速度與效能,因為開發人員可以分別針對寫入端與讀取端採取不同的優化策略。例如,寫入端採取Event Sourcing簡化與加速寫入操作,讀取端因為不會改變狀態,可以用各種快取工具加快讀取。圖4是ezKanban團隊成員杜奕萱在她的碩士論文《套用命令與查詢責任分離以簡化聚合依賴:以 ezKanban 為例》中針對套用CQRS之後ezKanban的GetBoardContent查詢所做的效能測試,可以發現套用CQRS之後有著非常巨大的讀取效能提升。

 

▲圖3:CQRS可分別拓展寫入與讀取服務

 


▲圖4:杜奕萱碩士論文所做的ezKanban GetBoardContent 效能測試

 

***

沒有缺點嗎?

以上把CQRS講的好像很神,它有沒有缺點?當然有。

雖然CQRS的套用可以先簡單的從API層與Use Case層開始,不一定要做到Domain Model甚至是資料庫的讀寫分離。但依據ezKanban這兩年套用CQRS的經驗,最終還是走向Domain Model與資料庫讀寫分離。將系統在各個階層「精細地」分成讀取模型與寫入模型,雖然個別模型內責任單一,變得比較簡單,但跨模型之間還是有相依性,要如何管理這些相依性就變成一個挑戰

例如,如圖1所示,ezKanban在資料庫端也套用CQRS,因此讀取資料庫與寫入資料庫之間的狀態同步是最終一致性。為了維持最終一致性,就會有新的設計工作產生:同步的訊息就需要考慮順序(ordering)與at least once等議題,而負責產生讀取資料庫的Projector(投影器)設計則須考慮idempotent與replay events等問題。另外,如何撰寫Projector也是一個問題。

換句話說,CQRS走到底的技術門檻會比較高。

這也是為什麼許多文章或書籍會建議不要為了套CQRS而套CQRS,要看自己的業務需求沒有有強烈到需要CQRS所帶來的這些好處。

***

下集預告

說明完CQRS基本概念,下一集先介紹ezKanbana套用CQRS之後對於簡化領域模型所達到的效果,之後再介紹ezKanbana如何實作Projector以及套用CQRS對於架構的影響。

***

友藏內心獨白:活用之後其實也沒有那麼難。

2022年7月4日 星期一

事件溯源(7):樂觀鎖

June 30 22:15~24:00;July 01 00:00~00:59

▲圖1:不同Aggregate就不用鎖了

 

前言

假設有兩個使用者同時拿到同一個Tag並且將它改名然後儲存,系統要如何避免資料衝突?這是一個並行控制(Concurrency Control)的問題。在Event Sourcing系統中,一般採用樂觀鎖(Optimistic Locking)或稱為樂觀並行控制(Optimistic Concurrency Control)來解決這個問題。

今天介紹在領域驅動設計(Domain-Driven Design;DDD)與Event Sourcing的情境下,如何實作樂觀鎖定。

 

***

樂觀鎖

樂觀鎖的概念很簡單,以上述兩個使用者拿到同一個Tag並將其改名為例,首先Tag在資料庫中保存一個版本號碼,從資料庫讀出的Tag身上帶著這個版本號碼,然後在儲存的時候比對資料庫中的版本號與此時所存入的Tag版本號是否相同。若相同,則表示當初Tag從資料庫讀出之後,沒有其他人修改該Tag,因此可以直接寫入,寫入之後資料庫中該Tag版本號會被加1代表資料被更新過。如果寫入時Tag的版本號與資料庫中的版本號不同,則表示該筆Tag讀出後有人異動過它的資料,因此寫入失敗(不能用舊資料覆蓋掉比較新的資料),丟出樂觀鎖失敗的例外,使用者必須要重新載入新的資料,改修後再次儲存。

***

實作Aggregate與Repository以支援樂觀鎖

請參考圖2,ezKanban的AggregateRoot類別身上的version屬性(第16行)就是用來支援樂觀鎖,它的初始值為-1,表示尚未被寫入到資料庫中。

 

▲圖2:AggregateRoot支援樂觀鎖

 

當Aggregate被寫入資料庫以及從資料庫讀出的時候,Repository會負責設定它身上的version欄位。相關程式碼之前介紹Repository實作的時候已經看到,但當時Teddy並沒有解釋。現在再看一次,請參考圖3,首先看到GenericEventSourcingRepository的save方法,第52行透過eventSourcingStore儲存aggregateRootData之後,eventSourcingStore會更新aggregateRootData身上的version欄位。在第53行接著更新aggregate身上的version欄位。

接著看findById方法,第36行產生aggregate之後,第37行設定它的version欄位。


▲圖3:GenericEventSourcingRepository更新Aggregate的version欄位

 

在資料庫層面,EventStoreDB本身就支援樂觀鎖,回憶一下圖4的程式碼,第36~36行設定expectedRevision,如此一來EventStoreDBClient在寫入資料的時候就會啟動樂觀鎖定檢查。


▲圖4:EventStoreDB寫入時設定樂觀鎖定程式碼

 

至於以PostgreSQL所實做的Message DB也支援樂觀鎖定,但是如果把Message DB當成Outbox使用,則除了儲存領域事件以外,還需要儲存ORM資料,而ORM資料也有樂觀鎖設定的問題。在ezKanban中,把PostgreSQL當成Outbox使用的情況下,採用ORM的樂觀鎖機制,至於寫入領域事件則不做樂觀鎖檢查。

最後看一下ORM要如何設定樂觀鎖,請參考圖5。在TagData身上加上long version屬性(第28行),然後幫它貼上@Version annotation(第26行),如此便可啟動資料庫的樂觀鎖機制。

 

▲圖5:TagData在version術性加上@Version即可自動啟動樂觀鎖

 

***

 

測試

講了老半天怎麼知道樂觀鎖到底有沒有作用?寫個測試案例就知道了,請參考圖6。第106行、107行讀出同一個Tag,分別放在tagV1與tagV2變數。第108行修改tagV1的名稱,然後儲存tagV1。此時資料庫中該Tag的版本已經被加1,但是記憶體中tagV2的版本號碼還是舊的。因此第112行儲存tagV2便會丟出樂觀鎖失敗例外。這個測試案例分別注入Event Sourcing與Outbox repository,執行結果都通過,如圖7所示。

 


▲圖6:樂觀鎖測試案例

 

▲圖7:測試案例執行結果

 

***

下集預告

Event Sourcing的基本知識介紹得差不多了,下一集開始介紹CQRS,等CQRS介紹完畢後在回頭談Event Sourcing的進階議題,例如快照與事件版本異動。

***

友藏內心獨白:終於要進入CQRS。

2022年7月3日 星期日

事件溯源(6):透過Projection查詢Event Store

June 30 22:15~23:38

▲圖1:EventStoreDB的Projections畫面

 

前言

在領域驅動設計中套用Event Sourcing,一個Aggregate instance的資料透過Repository儲存至Event Store的event stream,讀取的時候也是以單一個Aggregate instance為基本單位。但這樣顯然不足以應付大多數系統需要查詢資料的需求,在進一步套用CQRS解決查詢問題之前,這一集先介紹如何將原本主要用來負責寫入的Event Store也拿來當作查詢資料庫使用。

***

Projection (投影)

接下來Teddy將以EventStoreDB為例,說明如何使用Event Store來查詢資料。之前Teddy提到一般使用Aggregate-ID當作event stream的名稱把Aggregate instance的領域事件存入EventStoreDB。例如一個Tag Aggregate instance,它的id等於4cc11cc7-707b-476a-801c-1b6a22a69169,那麼它的領域事件將儲存在EventStoreDB裡面名稱為Tag-4cc11cc7-707b-476a-801c-1b6a22a69169的event stream。

EventStoreDB中,除了這種代表Aggregate instance的event stream,系統還有一些特殊的event stream:

  • $all:所有系統所產生的事件都可以在$all stream找到。
  • System projections:系統依據某些內建特定條件自動「投影」產生的stream,比較常用的有:
    • By Category($ce-):Category就是Aggregate Type,EventStoreDB會將不同Aggregate Tyee的事件投影至 $ce-[Aggregate] stream中。例如,可以從$ce-Tag stream讀到所有Tag instance的事件。
    • By Event Type($et-):將每一種event type投影成一個event stream,例如可以從 $et-TagEvents$TagCreated stream讀到所有的TagEvents$TagCreated領域事件。

 

***

使用Projection查詢資料

以下用查詢某一個Board有多少個Tag當成範例說明如何使用projection來查詢資料。

在ezKanban中Tag與Board靠著Tag身上的board id維持單向依賴,Board並不知道它身上一共有多少個Tag,所以無法從BoardRepository獲得Board身上有多少個Tag。而基本的TagRepository也只能依據tag id找到某一個Tag,無法找出屬於某一個Board的全部Tag。

參考圖2,要找出某個Board有多少個Tag,只要:

  1. $et-TagEvents$TagCreated stream讀取所有的事件,然後依據事件身上的board id當作過濾條件,就可以找到一個List<TagCreated>用來代表某個Board身上所有 Tag 的id。
  2. 跑一個for each迴圈,依據步驟1找到的List<TagCreated>呼叫TagRepository就可以找出所有這個Board裡面的所有Tag。

 

▲圖2:getTagsByBoardId程式碼

 

***

 

EventStoreDB Projection的優缺點

EventStoreDB的Projection是一個很強大的功能,它讓原本主要用來支援Write Model的資料庫,也可以同時做為Read Model資料庫。當然這種View Model本質上還是保有Event Sourcing的特性,也就是說要得到領域物件的「目前狀態」還是需要讀出該Read Model裡面所有的領域事件並replay它們。速度可能沒有像是採用CQRS之後,用NoSQL資料庫當作Read Model只要下一個查詢條件就可以直接把整個Read Model讀到記憶體裡面那麼快。

但是,所謂「速度比較慢(或很慢)」也許沒有人類想像中的那麼慢,還是要依據應用程式的實際狀況來判斷,在很多情況之下這種速度已經可以被接受。畢竟直接使用EventStoreDB的Projection可以很方便的產生Read Model,而套用CQRS還需要另外撰寫Projector用以在讀取端資料庫投影出Read Model,這可是一個不小的工作負擔。

另外還有一點要注意,EventStoreDB是採用非同步的方式去投影出這些Projection,也就是說這些Projection與原始寫入的領域事件之間的狀態同步是最終一致性。因此你可能會發現,為什麼有時候領域事件已經寫入資料庫中,但在是Projection裡面還讀不到相關領域事件。

***

下集預告

介紹完Event Sourcing的基本寫入以及讀取操作之後,下一集談在多人同時讀取與寫入資料的情況下,如何透過樂觀鎖定來避免資料衝突。

***

友藏內心獨白:不能下Select查詢資料一開始會不太習慣。

2022年7月2日 星期六

事件溯源(5):如何儲存事件型別

June 30 18:41~19:43

▲圖1:每一筆領域事件都需要儲存它的型別(Type)資料

 

前言

無論是Event Sourcing或是Outbox,領域事件必須被「序列化(serialize)」之後方可儲存到資料庫,從資料庫讀出則是經過「反序列化(deserialize)」之後變成領域事件物件。這個序列化、反序列化的過程,必須知道領域物件的型別才可以完成。因此,領域物件儲存到Event Store必須記錄事件型別,如圖1所示。今天要討論的問題是:「在Event Store中要如何記錄領域物件型別?」

***

紀錄Package Name + Class Name

這個問題乍看之下好像很簡單,啊不就儲存Domain Event的完整型別就好了?例如儲存TagEvents.TagCreated這個領域事件,只要呼叫:

TagEvents.TagCreated.class.getCanonicalName()

把下列回傳值儲存到資料庫的event type欄位就好了。

ntut.csie.sslab.ezkanban.kanban.tag.entity.TagEvents.TagCreated

反序列化的時候就用 class.forName() 就可以產生領域事件物件。

如果你永遠都不會修改領域事件或是package名稱,也不會把領域事件移動到不同的package,那麼直接儲存領域事件的完整型別是沒問題的。但是,只要將領域事件改名或是移動到不同的package,那麼讀取儲存在Event Store的舊領域事件就會發生 class not found例外。

看到這裡你可能會想:「我去更新資料庫中event type欄位的資料不就好了?」但是Event Sourcing系統有一個特性,就是基本上開發人員不應該去修改已經存在的領域事件,一般而言不建議用這種方式。

 

***

型別對照(Type Mapping)

一般的作法會建議採用型別對照的方式,序列化的時候針對每一種領域事件儲存一個唯一且固定不變的字串到資料庫中,反序列化則是依據這個固定的字串去「查表」,找出相對應的領域事件型別。

實作方法很簡單,首先宣告一個DomainEventTypeMapper介面,如圖2。

 

▲圖2:DomainEventTypeMapper介面

 

接下來針對每一個Aggregate的所有領域事件,寫一個DomainEventTypeMapper。圖3是給Tag使用的DomainEventTypeMapper,針對TageCreated、TagRenamed、TagColorChanged、TagDeleted這四個領域物件,宣告四個唯一且不變的字串做為寫入到Event Store的事件型別(第55~58行)。

第60行~66行設定這四個領域物件型別字串分別代表哪一個真正的Java領域物件類別,也就是建立型別對照所需要的表格。


▲圖3:Tga的DomainEventTypeMapper

 

ezKanban在序列化、反序列化的過程會呼叫圖4中的DomainEventMapper類別,第28行toMappintType()方法將領域事件轉成固定的型別字串,第38行則是在反序列化的時候透過領域物件型別字串找到真正的領域物件。


▲圖4:DomainEventMapper類別

***

下集預告

Event Sourcing對於寫入操作非常方便,不需要做OR-Mapping只需寫入領域事件。但是,要查詢資料的時候怎麼辦?例如,TagRepository只可以依據tagId找到單一個Tag,如果要找出某一個Board裡面的全部Tag,要怎麼辦?下一集討論這個問題。

***

友藏內心獨白:這一集簡單很多。

2022年7月1日 星期五

事件溯源(4):將Aggregate儲存至Outbox Store

June 30 10:41~12:02;12:56~15:24

▲圖1:Outbox儲存方式,資料庫中包含State Sourcing與Transactional Outbox所需的資料表


前言

這一集要用傳統State Sourcing方式將Tag Aggregate儲存到PostgreSQL關聯式資料庫中,除了透過ORM工具將Tag Aggregate資料儲存到資料庫表格中,還需要套用Transactional Outbox在儲存Tag的同一個交易中一併將它身上的領域事件儲存到資料庫的領域事件表格中。我們將這種同時在同一個交易中儲存現有狀態以及領域事件的儲存方式簡稱為Outbox。

***

準備環境

如圖1所示,採用Outbox的資料庫需要有一個用來儲存領域事件的預設表格,Teddy使用Message DB這個開源軟體(https://github.com/message-db/message-db)。它已經設計好用來儲存事件的資料庫表格(表格名稱叫messages),詳細使用方法請參考它的官方網站。

圖2為ezKanban使用Message DB的資料庫畫面,其中messages表格由Message DB所建立,其它像是board、 board_content、board_member、card等表格則是ORM自動建立(ezKanban使用JPA來自動產生這些表格)。


▲圖2:ezKanban採用Outbox的資料庫筆格(部分畫面)

***

TagOutboxRepository實作

上一集<事件溯源(3):將Aggregate儲存至EventStoreDB>已經談過Repository的設計如何同時支援Event Sourcing與Outbox這兩種資料儲存方式,這一集就直接實作TagOutboxRepository類別。程式如圖3所示,它的實作方式和TagEventSourcingRepository類似,差別在於TagEventSourcingRepository將工作委託給GenericEventSourcingRepository,而TagOutboxRepository則是委託給GenericOutboxRepository

 

▲圖3:TagEventSourcingRepository程式碼

 

圖4為GenericOutboxRepository實作,首先看到11行,它接受兩個泛型參數:AggregateRootOutboxData。前者用來表示該GenericOutboxRepository是給哪一個Concreate Aggregate使用,後者則是該Concreate Aggregate透過Outbox方式儲存到資料庫所需的資料。

 

▲圖4:GenericOutboxRepository類別

 

圖5為TagData類別,基本上它身上有Tag Aggregate所有需要儲存到資料庫的屬性(第17~25行),加上@Id與@Column這些JAP的annotation。第14行的streanName與第16行的domainEventDatas這兩個屬性是用來保存領域事件的資料,最後會被儲存至messages這個資料庫表格。最後26~28行的version是用來支援樂觀鎖定所使用的屬性。

 

▲圖5:TagData類別

 

繼續看到圖4第23行的findById,在第25行它透過OutboxStore介面依據Aggregate id從資料庫中直接找出代表該Aggregate的Outbox Data物件。以Tag Aggregate,為例,這個Outbox Data物件的實作就是TagData。接著第27行將這個Outbox Data物件透過OutboxMapper轉成Aggregate並回傳;以Tag為例,將TagData轉成Tag然後回傳Tag給findById的呼叫者。

接下來看到第33行的save方法,首先在第35行將傳入的Aggregate轉成Outbox Data,然後第36行透過OutboxStore介面將Outbox Data儲存至資料庫。儲存完畢後第37行重設Aggregate版本,然後在第38行清除Aggregate身上的領域事件(因為Aggregate的狀態已經儲存到資料庫,所以要清除它身上的領域事件,否則相同Aggregate若再儲存一次會儲存重複的領域事件)。

在這裡有一個重點,就是第36行的store.save()方法。以Tag Data為例,它會先把Tag Data儲存到資料庫中的Tag Table,然後在把它身上的領域事件儲存至資料庫中的messages Table。這兩個儲存動作會被放在同一個交易中執行,以確保Tag的狀態正確。

***

PostgresOutboxStore實作

上面提到的上OutboxStore介面在ezKanban中有一個PostgresOutboxStore類別實作它,圖如6所示。可以看出來PostgresOutboxStore又把工作委託給PostgresOutboxStoreClient,真正和資料庫打交道的程式就寫在它身上。



 ▲圖6:PostgresOutboxStore類別

 

圖7為PostgresOutboxStoreClient程式碼,它透過OrmStoreClientPostgrresMessageStoreClient分別將資料儲存到ORM表格與messages表格。ezKanban底層採用SpringBoot框架,把交易處理交給SpringBoot管理(第22行與第36行的@Transactional annotation)。

第23行的save方法先呼saveAndUpdateVersion方法儲存ORM的資料,接著第25行呼叫saveDomainEventsWithoutVersion儲存領域事件。

第36行的delete方法,先呼叫deleteById刪除ORM表格中的資料,然後再呼叫saveDomainEventsWithoutVersion儲存領域事件。



▲圖7:PostgresOutboxStoreClient類別

 

最後看到OrmStoreClientPostgrresMessageStoreClient實作,如圖8與圖9。前者繼承SpringBoot的CrudRepository,程式碼很簡單Teddy就不多做說明。

 

▲圖8:OrmStoreClinet類別

 

PostgresMessageStoreClient是ezKanban幫用Message DB所撰寫Java客戶端驅動程式,Message DB其實只有設計messages Table scheam以及撰寫了幾隻PostgreSQL資料庫的functions讓客戶端程式可以用來寫入與讀取領域事件,但它並沒有提供Java的驅動程式讓Java客戶端可以直接讀寫資料庫。圖9第28行的writeMessage方法就是ezKanban幫它所撰寫的驅動程式,只要直接呼叫這個方法就可以把領域事件寫入Message DB



▲圖9:PostgresMessageStoreClient類別

 

***

執行測試案例

實作完成TagOutboxRepository之後,改寫上一集<事件溯源(3):將Aggregate儲存至EventStoreDB>的測試案例,將TagRepository注入TagOutboxRepository,如圖10所示。

 

▲圖10:將測試案例中的tagRepository換成TagOutboxRepository實作

 

 

執行完測試案例打開PostgreSQL管理畫面,看到tag Table與messages Table分別新增一筆資料,如圖11所示。



▲圖11:從PostgreSQL管理畫面看到新增的資料

 

***

下集預告

Event Sourcing與Outbox這兩種儲存方式都搞定了,但還有一個小細節沒有說明,就是在資料庫中如何儲存「領域事件型別(Event Type)」,下一集討論這個問題。

***

友藏內心獨白:很多細節要處理。