前言
事件溯源是一種以事件為中心的編寫業務邏輯和持久化領域對象的方法。事件溯源可以消除一些可能的編程錯誤,因為這項技術可以保證在創建或更新聚合時一定會發布事件。
這是一本關于微服務架構設計方面的書,這是本人閱讀的學習筆記。下面對一些符號做些說明:
()為補充,一般是書本里的內容;
[]符號為筆者筆注;
1. 使用事件溯源開發業務邏輯概述
事件溯源模式:使用一系列便是狀態更改的領域事件來持久化聚合。
1.1 傳統持久化技術的問題
- 對象與關系的“阻抗失調”:關系型數據的表格結構模式,與領域模型及其復雜關系的圖狀結構之間,存在基本的概念不匹配的問題;
- 缺乏聚合歷史:聚合更新后,其先前的狀態將丟失;
- 實施升級功能將非常繁瑣且容易出錯:耗時,負責記錄審計日志的代碼可能會和業務邏輯代碼發生偏離;
- 事件發布凌駕于業務邏輯之上:無法把自動發布消息作為更新數據事務的一部分;
1.2 事件溯源通過事件來持久化聚合
事件溯源采用基于領域事件的概念來實現聚合的持久化;它將每個聚合持久化為數據庫中的一系列事件,稱為事件存儲。
圖解:
- 事件溯源不是將每個Order作為一行存儲在ORDER表中,而是將每個Order聚合持久化為EVENTS表中的一行或多行;
- 應用程序創建或更新聚合時,它會將聚合發出的事件插入到EVENTS表中;
- 應用程序通過從事件存儲中檢索并重放事件來加載聚合(如Eventuate Client框架),加載聚合的步驟:
- 加載聚合的事件;
- 使用其默認構造函數創建聚合實例;
- 調用apply()方法遍歷事件;
- 事件溯源通過加載事件和重放事件來重建聚合的內存狀態;
1.3 事件溯源對領域事件提出的新需求
- 事件代表狀態的改變;
- 聚合方法都和事件相關;
1.4 事件代表狀態的改變
- 在事件溯源情況下,聚合主要決定事件及其結構;
- 包括創建在內的每一個聚合狀態變化,都由領域事件表示;
- 每當聚合的狀態發生改變時,它必須發出一個事件;
- 事件中必須包含聚合執行狀態變化所需的數據;
- 聚合的狀態由構成聚合對象的字段值組成;
1.5 聚合方法都和事件相關;
- 基于事件溯源的應用程序中的命令方法通過生成事件來處理對聚合更新的請求;
- 調用聚合命令方法的結果是一系列事件,表示必須進行的狀態更改;
- 生成事件并應用(apply)事件的做法將導致對業務邏輯的重構;事件溯源將命令方法重構為兩個或更多個方法;
- 第一個方法
process()
接收命令對象參數,該參數表示具體的請求,并確定需要自行哪些狀態更改;它驗證命令對象的參數,并且在不更改聚合狀態的情況下,返回表示狀態更改的事件列表;如果無法執行該命令,則此方法通常會引發異常; - 其他方法
apply()
都將特定事件類型作為參數來更新聚合;這些方法與聚合產生的事件類型一一對應;重要的是要注意執行這些方法不會出現失敗,因為這些事件代表了一個已經發生的狀態變化;每個方法都會根據事件更新聚合; - 一個例子如下:
- 第一個方法
圖解:
-
reviseOrder()
方法被process()
方法和apply()
方法替代; -
process()
方法將ReviseOrder命令作為參數; -
process()
方法要么返回OrderRevisionProposed事件,要么拋出異常;- 如時間太晚已不能修改訂單或建議訂單修訂不滿足訂單最小值的時候;
-
OrderRevisionProposed事件的
apply()
方法將Order的狀態更改為REVISION_PENDING;
1.6 創建與更新聚合的步驟
創建聚合的步驟:
- 使用聚合的默認構造函數實例化聚合根;
- 調用process()以生成新事件;
- 遍歷新生成的事件并調用apply()來更新聚合的狀態;
- 將新事件保存在事件存儲庫中;
更新聚合的步驟:
- 從事件存儲庫加載聚合事件;
- 使用其默認構造函數實例化聚合根;
- 遍歷加載的事件,并在聚合根上調用apply()方法;
- 調用其process()方法以生成新事件;
- 遍歷新生成的事件并調用appply()來更新聚合的狀態;
- 將新事件保存在事件儲存庫中;
1.7 基于事件溯源的Order聚合
- 業務邏輯通過命令來實現,這些命令發出事件并應用那些更新其狀態的事件;
- 創建或更新基于JPA的聚合的每個方法,如
createOrder()
和reviseOrder()
,在事件溯源版本中都由process()
和apply()
方法替代;
- 基于JPA聚合的修改訂單業務邏輯由三個方法組成:
reviseOrder()
、confirmRevision()
和rejectRevision()
; - 事件溯源版本使用三個
process()
方法和一些apply()
方法替代這三個的方法;
1.8 使用樂觀鎖處理并發更新
指兩個或多個請求同時更新同一聚合的情況;
- 樂觀鎖通常使用版本列(映射到VERSION列)來檢測聚合自讀取以來是否已更改;
- 每當更新聚合時,VERSION列的值會增加;
- 兩個有兩個事物讀取相同的聚合,第一個成功,第二個不成功;因為版本號已更改;
1.9 事件溯源和發布事件
- 使用輪詢發布事件;
-
如下圖所示:
使用輪詢發布事件
-
- 使用事務日志拖尾技術來可靠地發布事件;
- 本篇第2點詳解;
1.10 使用快照提升性能
長生命周期的聚合可能會有大量事件;隨時間推移,加載和重放這些事件會變得越來越低效;常見解決方法是定期持久保存聚合狀態的快照;
1.11 冪等方式的消息處理
使用相同的消息多次安全地調用消息接收方,則消息接收方是冪等的;具體實現方式取決于事件儲存庫是關系型數據庫還是NoSQL數據庫;
-
基于關系型數據庫事件儲存庫的冪等消息處理;
- 可以將消息ID插入PROCESSED_MESSAGES表,作為插入EVENTS表的事件的事務的一部分 [相同消息被接受時,若數據庫中已經存在ID,則忽略該消息請求];
-
基于非關系型數據庫事件儲存庫的冪等消息處理;
- 往往功能有限,需要使用不同的機制來實現冪等消息處理;
- 消息接收方必須以某種原子化的方式同時完成事件持久化和記錄消息ID;
- 解決方案:消息消費者把消息的ID存儲在處理它時生成的事件中,它通過驗證聚合的所有事件中是否包含該消息ID來做重復驗證;
-
該解決方案的問題:一些消息的處理可能不會生成任何事件;
- 一個解決方案:始終發布事件;如果聚合不發出事件,則應用程序僅保存記錄消息ID的偽事件;事件接收方必須忽略這些偽事件;
1.12 領域事件的演化
事件溯源應用程序的結構分三個層次:
- 由一個或多個聚合組成;
- 定義每個聚合發出的事件;
- 定義事件的結構;
每個級別可能發生的不同類型的更改:
- 服務的領域模型隨著時間的推移而發展,這些變化會自然發生;
- 不向后兼容段更改都需要更改該事件類型的消費者;
通過向上轉換(Upcasting)來管理結構的變化:
- 事件溯源框架不是將事件遷移到新的版本,而是在從事件存儲庫加載事件時執行轉換;
- 通常用稱為“向上轉換”的組件將各個事件從舊版本更新為更新的版本;
1.13 事件溯源的好處與弊端
好處:
- 可靠地發布領域事件;
- 保留聚合的歷史;
- 最大限度地避免對象與關系的“阻抗失調”問題(持久化事件而不是聚合本身);
- 為開發者提供一個“時光機”;
弊端:
- 這類編程模式有一定的學習曲線;
- 基于消息傳遞的應用程序的復雜性;
- 指處理非等冪事件時;
- 解決方法:為每個事件分配單調遞增的ID;
- 處理事件的演化有一定難度;
- 指事件和快照的結構隨時間推移變得臃腫;
- 解決方法:從事件存儲庫加載事件時,將事件升級到最新版本;即將事件版本處理與聚合的代碼分開,簡化聚合;
- 刪除數據存在一定難度;
- 歐洲的GDPR給予用戶對其數據的擦除權,而用戶信息可能嵌入在事件結構中,如郵箱作為聚合的主鍵,應用程序必須在不刪除事件的情況下清除特定用戶信息;
- 解決方法:使用加密密鑰;使用假名技術(如:UUID令牌代替電子郵箱作為聚合ID);
- 查詢事件存儲庫非常有挑戰性;
- 指可能會使用嵌套的更為復雜的且可能低效的查詢;
- 解決方法:參考《第7章 CQRS方法實現查詢》;
2. 實現事件存儲庫
使用事件溯源的應用程序將事件存儲在事件存儲庫中;事件存儲庫是數據庫和消息代理功能的組合;它表現為數據庫和消息代理;
- 實現事件存儲庫有多種方法,一種是實現自己的事件存儲庫和事件溯源代碼框架;另一種是使用專用事件存儲庫;
- 專用事件存儲庫通常提供豐富的功能集、更好的性能和可擴展性;
- 如:Event Store、Lagom、Axon、Eventuate SaaS;
2.1 Eventuate Local事件存儲庫的工作原理
Eventuate Local的事件數據庫結構:
-
events:存儲事件(最核心);
- 與本篇1.2點的圖類似;
-
entities:每個實體一行;
- 儲存每個實體的當前版本;用于實現樂觀鎖;
-
snapshots:存儲快照;
- 儲存每個實體的快照;
- 其支持find()、create()、update()三個操作;
通過訂閱Eventuate Local的事件代理接受事件:
- 服務通過訂閱事件代理來使用事件,事件代理具有每個聚合類型的主題;
- 主題是分區的消息通道,使接收方能夠在保持消息排序的同時進行水平擴展;
Eventuate Local的事件中繼把事件從數據庫傳播到消息代理:
- 事件中繼將插入事件數據庫的事件傳播到事件代理;
- 它盡可能使用事務日志拖尾,或輪詢其他數據庫;
- 事件部署為獨立進程;
2.2 針對Java語言的Eventuate Client框架提供的主要類和接口
Eventuate Client框架使開發人員能夠使用Eventuate Local事件存儲庫編寫基于事件溯源的應用程序;它為開發基于事件溯源的聚合、服務和事件處理程序提供了框架基礎;
圖解:
-
通過ReflectiveMutableCommandProcessingAggregate類定義聚合;
- 該類是聚合的基類,是一個泛型類;
- 有兩個類型參數:具體的聚合類、聚合命令類的超類;
- 使用反射將命令和事件分別分派給process()和apply()方法;
-
定義聚合命令;
- 聚合的命令類必須擴展特定于聚合的基接口,該接口本身必須擴展Command接口;
- 如:Order聚合的命令擴展了Ordercommand;
-
定義領域事件;
- 聚合的事件類必須擴展Event接口,這是一個沒有方法的標識接口;
-
使用AggregateRepository類創建、查找和更新聚合;
- 該類是一個泛型類,它接收的參數是聚合類和聚合的基命令類;
- 提供三種重載方法:save()創建聚合、find()查找聚合、update()更新聚合;
- 主要由服務使用,在服務響應外部請求時創建和更新聚合;
-
訂閱領域事件;
- Eventuate Client框架還提供了用于編寫事件處理程序的API,如:
-
@EventSubscriber
注解指定持久化訂閱方的ID; -
@EventHandlerMethod
注解將creditReserved()方法標識為事件處理程序;
3. 同時使用Saga和事件溯源
事件溯源可以輕松使用基于協同式的Saga;將事件溯源的業務邏輯與基于編排的Saga相結合更具挑戰性;
3.1 使用事件溯源實現協同式Saga
- 事件溯源的事件驅動屬性使得實現基于協同式的Saga非常簡單;
- 當聚合被更新時,它會發出一個事件;不同聚合的事件處理程序可以接受該事件,并更新該聚合;事件溯源框架自動使每個事件處理程序具有冪等性;
- 事件溯源代碼提供了Saga所需的機制,包括基于消息傳遞的進程間通信、消息去重,以及原子化狀態更新和消息發送;
-
弊端:事件體現雙重目的性,即事件溯源使用事件來表示狀態更改,但使用事件實現Saga協同,需要聚合即使沒有狀態更改也必須發出事件;
- 解決方法:使用編排式來實現復雜的Saga;
3.2 創建編排式Saga
Saga編排器由服務的方法創建,會執行創建和更新聚合兩項操作,該服務必須保證則兩個操作在同一個事物中完成;因此取決于使用的事件數據庫類型;
當關系型數據庫作為事件存儲庫時,應該如何創建Saga編排器:
- 比較簡單,使用
@Transactional
注解,使Eventuate Local框架與Eventuate Tram Saga框架在同一個ACID事務中更新時間存儲庫并創建Saga編排器即可;
當非關系型數據庫作為事件存儲庫時,應該如何創建Saga編排器:
- 由于NoSQL數據庫的事務模型功能有限,應用程序將無法以原子方式創建或更新兩個不同的對象;
- 服務必須具有一個事件處理程序,該事件處理程序將創建Saga編排器來響應聚合發出的領域事件;
3.3 使用事件處理程序創建Saga編排器的案例
好處:保證松耦合,因為OrderService之類的服務不再明確地實例化Saga;
問題:如何處理重復事件保證冪等性,解決方法如下:
- 從事件的唯一屬性中導出Saga的ID;有多種選擇,其中一種是使用發出事件的聚合的ID作為Saga的ID,適用于為響應聚合創建事件而創建的Saga;
- (有效)使用事件ID作為Saga ID;因為事件ID的唯一性能保證Saga ID也是唯一的;
3.4 實現基于事件溯源的Saga參與方
-
命令式消息的冪等處理;
- 很容易解決:Saga參與方在處理消息時生成的事件中記錄消息ID;在更新聚合之前,Saga參與方通過在事件中查找消息ID來驗證它之前是否處理過該消息;
-
以原子方式發送回復消息;
- 解決方法:讓Saga參與方繼續向Saga編排器的回復通道發送回復消息;
- 當Saga命令處理程序創建或更新聚合時,它會安排將SagaReplyRequested偽事件與聚合發出的實際事件一起保存在事件存儲庫中;
- SagaReplyRequested偽事件的事件處理程序使用事件中包含的數據構造回復消息,然后將其寫入Saga編排器的回復通道;
- 如3.5點圖所示;
- 解決方法:讓Saga參與方繼續向Saga編排器的回復通道發送回復消息;
3.5 基于事件溯源的Saga參與方的例子
下圖顯示了Accounting Service如何處理Saga發送的Authorize Command;Accounting Service使用Eventuate Saga框架,該框架用于編寫使用事件溯源的Saga;
3.6 實現基于事件溯源的Saga編排器
-
使用事件溯源持久化Saga編排器;
- 可以使用以下事件持久化Saga:
- SagaOrchestratorCreated:Saga編排器已創建;
- SagaOrchestratorUpdated:Saga編排器已更新;
- 可以使用以下事件持久化Saga:
-
可靠地發送命令式消息;
- 關鍵在于如何以原子方式更新Saga的狀態并發送命令;
- 對于NoSQL事件存儲庫,關鍵在于持久化SagaCommandEvent,它表示要發送命令;然后事件處理程序訂閱SagaCommandEvents并將每個命令式消息發送到適當的通道;
- 詳情請見下圖:
-
確保只處理一次回復消息;
- 類似前面描述的機制;編排器將回復消息的ID存儲在處理回復時發出的事件中;
4. 本章小結
- 事件溯源將聚合作為一系列事件持久化保存。每個事件代表聚合的創建或狀態更改。應用程序通過重放事件來重建聚合的當前狀態。事件溯源保留領域對象的歷史記錄,提供準確的審計日志,并可靠地發布領域事件;
- 快照通過減少必須重放的事件數來提高性能;
- 事件存儲在事件存儲庫中,該存儲庫是數據庫和消息代理的混合。當服務在事件存儲庫中保存事件時,它會將事件傳遞給訂閱者;
- Eventuate Local是一個基于MySQL和Apache Kafka的開源事件存儲庫。開發人員使用Eventuate Client框架來編寫聚合和事件處理程序;
- 使用事件溯源的一個挑戰是處理事件的演變。應用程序在重放事件時可能必須處理多個事件版本。一個好的解決方案是使用向上轉換,當事件從事件存儲庫加載時,它會將事件升級到最新版本;
- 在事件溯源應用程序中刪除數據非常棘手。應用程序必須使用加密和假名等技術,以遵守歐盟GDPR等法規,確保在應用程序中徹底清除個人數據;
- 事件溯源可以很簡單實現基于協調的Saga。服務具有事件處理程序,用于監聽基于事件溯源的聚合發布的事件;
- 我們也可以使用事件溯源技術實現Saga編排器。你可以編寫專門使用事件存儲庫的應用程序;