前言
事件溯源是一種以事件為中心的編寫業務邏輯和持久化領域物件的方法。事件溯源可以消除一些可能的程式設計錯誤,因為這項技術可以保證在建立或更新聚合時一定會發布事件。
這是一本關於微服務架構設計方面的書,這是本人閱讀的學習筆記。下面對一些符號做些說明:
()為補充,一般是書本里的內容;
[]符號為筆者筆注;
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編排器。你可以編寫專門使用事件儲存庫的應用程式;