事件溯源
將應用程式狀態的所有變更擷取為一系列事件。
2005 年 12 月 12 日
這是 進一步的企業應用程式架構開發 寫作的一部分,我在 2000 年代中期進行。遺憾的是,自那時起,太多其他事情吸引了我的注意力,所以我沒有時間進一步研究它們,我也看不到在可預見的未來有太多時間。因此,這份資料很大程度上仍是草稿形式,在我能夠找到時間再次研究它之前,我不會進行任何更正或更新。
我們可以查詢應用程式的狀態以找出世界的當前狀態,這回答了很多問題。然而,有時我們不只是想看到我們在哪裡,我們還想知道我們是如何到達那裡的。
事件溯源 確保應用程式狀態的所有變更都儲存在一系列事件中。我們不僅可以查詢這些事件,還可以利用事件記錄重建過去的狀態,並作為自動調整狀態以應對追溯變更的基礎。
運作方式
事件溯源 的基本概念是確保應用程式狀態的每個變更都擷取在事件物件中,並且這些事件物件本身儲存在它們應用於與應用程式狀態本身相同的生命週期的順序中。
讓我們考慮一個與運送通知相關的簡單範例。在此範例中,我們在公海上有很多船隻,我們需要知道它們在哪裡。執行此操作的簡單方法是使用追蹤應用程式,其中包含允許我們在船隻抵達或離開港口時通知我們的方法。

圖 1:追蹤運送動態的簡單介面。
在這種情況下,當呼叫服務時,它會找到相關的船隻並更新其位置。船隻物件會記錄船隻當前已知的狀態。
引入 事件溯源 會在此過程中新增一個步驟。現在,服務會建立事件物件來記錄變更,並處理它以更新船隻。

圖 2:使用事件擷取變更。
僅查看處理,這只是一個不必要的間接層級。當我們查看在進行一些變更後應用程式中持續存在什麼時,就會發現有趣的差異。讓我們想像一些簡單的變更
- 船隻「King Roy」從舊金山出發
- 船隻「Prince Trevor」抵達洛杉磯
- 船隻「King Roy」抵達香港
使用基本服務時,我們只會看到船隻物件擷取的最終狀態。我將其稱為應用程式狀態。

圖 3:由簡單追蹤器追蹤的幾次移動後狀態。
使用事件溯源時,我們也會擷取每個事件。如果我們使用持續性儲存,事件會像船隻物件一樣持續。我認為將兩件不同的事情持續化很有用,即應用程式狀態和事件記錄。

圖 4:由事件溯源追蹤器追蹤的幾次移動後狀態。
使用事件溯源後,我們獲得的最明顯的優點是,我們現在有了所有變更的記錄。我們不僅可以看到每艘船隻在哪裡,還可以查看它去過哪裡。然而,這是一個小小的優點。我們也可以透過在船隻物件中保留過去港口的記錄,或是在船隻移動時寫入日誌檔案來做到這一點。這兩種方式都可以為我們提供足夠的記錄。
事件溯源的關鍵在於,我們保證所有對網域物件的變更都是由事件物件發起的。這會產生許多可以建構在事件記錄之上的功能
- 完整重建:我們可以完全捨棄應用程式狀態,並透過在空白應用程式上重新執行事件記錄中的事件來重建它。
- 時間查詢:我們可以判斷任何時間點的應用程式狀態。理論上,我們可以從空白狀態開始,並重新執行事件直到特定時間或事件。我們可以進一步考慮多個時間軸(類似於版本控制系統中的分支)。
- 事件重播:如果我們發現過去的事件不正確,我們可以透過反轉它和後續事件,然後重播新的事件和後續事件來計算後果。(或者乾脆捨棄應用程式狀態,並以正確的事件順序重播所有事件。)相同的技術可以處理以錯誤順序接收的事件,這是與非同步訊息傳遞系統通訊時常見的問題。
使用事件溯源的應用程式的一個常見範例是版本控制系統。此類系統經常使用時間查詢。每當您使用傾印和還原在儲存庫檔案之間移動資料時,Subversion 會使用完整重建。由於對該資訊不特別感興趣,因此我不曉得是否有任何系統執行事件重播。使用事件溯源的企業應用程式較為罕見,但我看過一些使用它的應用程式(或應用程式的一部分)。
應用程式狀態儲存
思考使用事件溯源的最簡單方法是從空白的應用程式狀態開始,然後套用事件來達到想要的狀態,以計算請求的應用程式狀態。同樣地,很容易看出這是一個緩慢的過程,特別是在有許多事件的情況下。
在許多應用程式中,請求最近的應用程式狀態更為常見,如果是這樣,更快的替代方案是儲存目前的應用程式狀態,如果有人想要事件溯源提供的特殊功能,那麼額外的功能就會建立在上面。
應用程式狀態可以儲存在記憶體或磁碟中。由於應用程式狀態純粹可以從事件記錄中衍生,因此您可以將其快取在任何您喜歡的地方。在工作日中使用的系統可以在一天開始時從隔夜快照啟動,並將目前的應用程式狀態保存在記憶體中。如果它崩潰,它會從隔夜儲存中重播事件。在工作日結束時,可以對狀態進行新的快照。可以在任何時候並行建立新的快照,而不會中斷正在執行的應用程式。
正式的記錄系統可以是事件記錄或目前的應用程式狀態。如果目前的應用程式狀態保存在資料庫中,則事件記錄可能只用於稽核和特殊處理。或者,事件記錄可以是正式記錄,並且可以根據需要隨時從中建立資料庫。
結構化事件處理器邏輯
關於將事件處理邏輯放在哪裡,有許多選擇。主要的選擇是將邏輯放在交易腳本或網域模型中。如同往常一樣,交易腳本比較適合簡單的邏輯,而網域模型則比較適合在事情變得更複雜時使用。
一般來說,我注意到一個趨勢,就是使用交易腳本與透過事件或命令驅動變更的應用程式。的確,有些人認為這是建構以這種方式驅動的系統的必要方法。然而,這是一個錯覺。
思考這一點的一個好方法是,這涉及兩個責任。處理網域邏輯是操作應用程式的商業邏輯。處理選擇邏輯是根據輸入事件選擇應該執行哪個處理網域邏輯區塊的邏輯。您可以將它們組合在一起,這基本上是交易腳本方法,但您也可以透過將處理選擇邏輯放入事件處理系統中來將它們分開,並且它會呼叫網域模型中包含處理網域邏輯的方法。
一旦你做出這個決定,接下來就是將處理選擇邏輯放入事件物件本身,或是有個獨立的事件處理器物件。處理器的問題在於它必然會根據事件類型執行不同的邏輯,這是任何優秀的物件導向程式設計師都會厭惡的類型切換。在其他條件相同的情況下,你會希望處理選擇邏輯在事件本身,因為那是會隨著事件類型而變化的東西。
當然,所有事情並不總是平等的。在有獨立處理器有意義的一個情況是,當事件物件是一個DTO,它會透過某些自動方式序列化和反序列化,禁止將程式碼放入事件中。在這種情況下,你需要為事件尋找選擇邏輯。我的傾向是盡可能避免這樣做,如果你不能,那就將DTO視為事件的隱藏資料持有者,並仍將事件視為常規多型物件。在這種情況下,值得做一些適度聰明的事情,使用組態檔或(更好的)命名慣例將序列化的事件 DTO 與實際事件相匹配。
如果不需要反轉事件,那麼很容易讓Domain Model忽略事件記錄。反轉邏輯讓這變得更加棘手,因為Domain Model需要儲存和檢索先前的狀態,這使得Domain Model更方便地了解事件記錄。
反轉事件
除了讓事件自己向前播放之外,讓它們能夠反轉自己通常也很有用。
當事件以差異的形式呈現時,反轉是最直接的。一個例子是「將 10 美元加到馬丁的帳戶」而不是「將馬丁的帳戶設定為 110 美元」。在前一種情況下,我可以透過只扣除 10 美元來反轉,但在後一種情況下,我沒有足夠的資訊來重新建立帳戶的過去值。
如果輸入事件不遵循差異方法,那麼事件應該確保它在處理期間儲存反轉所需的一切。你可以透過在任何已變更的值上儲存先前的值,或是在事件上計算和儲存差異來做到這一點。
當處理邏輯在網域模型中時,這個儲存需求會產生重大的後果,因為網域模型可能會以不應讓事件物件的處理可見的方式來改變其內部狀態。在這種情況下,最好設計網域模型以了解事件,並能夠使用它們來儲存先前的值。
值得記住的是,所有還原事件的功能都可以透過回復到過去的快照並重播事件串流來完成。因此,功能上絕對不需要還原。然而,它可能會對效率造成很大的影響,因為你常常會處於還原幾個事件比對大量事件使用前向播放更有效率的情況。
外部更新
處理不遵循此方法的外部系統(而且大多數都不遵循)是事件溯源中棘手的元素之一。當你將修改訊息傳送給外部系統以及當你從其他系統接收查詢時,你會遇到問題。
事件溯源的許多優點源自於可以隨意重播事件的能力,但如果這些事件導致更新訊息傳送給外部系統,那麼事情就會出錯,因為那些外部系統不知道真實處理和重播之間的差別。
為了解決這個問題,你需要用閘道器包裝任何外部系統。這本身並不容易,因為這在任何情況下都是一個徹底的好主意。閘道器必須更複雜一些,以便它可以處理事件溯源系統正在執行的任何重播處理。
對於重建和時間查詢,閘道器通常足以在重播處理期間停用。你希望以一種對網域邏輯不可見的方式執行此操作。如果網域邏輯呼叫 PaymentGateway.send,無論你是否處於重播模式,它都應該這樣做。閘道器應該透過參考事件處理器並在將外部呼叫傳遞到外部世界之前檢查它是否處於重播模式來處理該區別。
如果你正在使用追溯事件,外部更新會變得更加複雜,請參閱討論以取得血腥的詳細資訊。
您可能會在外部系統中看到另一種策略,即依時間緩衝外部通知。我們可能不需要立即發出外部通知,而只需在月底才發出。在這種情況下,我們可以在該時間出現之前更自由地重新處理。我們可以通過以下方式來處理此問題:擁有儲存外部訊息直到發布日期的閘道器,或透過通知網域事件來觸發外部訊息,而不是立即發出通知。
外部查詢
外部查詢的主要問題是它們回傳的資料會影響處理事件的結果。如果我在 12 月 5 日詢問匯率,並在 12 月 20 日重播該事件,我將需要 12 月 5 日的匯率,而不是後來的匯率。
外部系統可能會透過詢問某個日期的值來提供給我過去的資料。如果它可以,而且我們相信它很可靠,那麼我們可以使用它來確保一致的重播。我們也可能正在使用事件協作,在這種情況下,我們必須確保保留變更記錄。
如果我們無法使用那些簡單的計畫,那麼我們必須做一些更複雜的事情。一種方法是設計外部系統的閘道器,以便它記住其查詢的回應,並在重播期間使用它們。要完成這項工作,這表示需要記住每個外部查詢的回應。如果外部資料變更緩慢,那麼只在值變更時記住變更可能是合理的。
外部互動
查詢和更新外部系統都會對事件溯源造成許多複雜性。在涉及兩者的互動中,您會得到最糟糕的結果。這樣的互動可能是外部呼叫,它既會回傳結果(查詢),也會導致外部系統的狀態變更,例如提交訂單以傳遞該訂單的傳遞資訊。
程式碼變更
因此,此討論假設處理事件的應用程式保持不變。顯然情況並非如此。事件會處理資料變更,程式碼變更呢?
我們可以將此處的程式碼變更視為三種類型:新功能、缺陷修復和時間邏輯。
新功能基本上會新增系統的新功能,但不會使之前發生的事情失效。這些功能可以在任何時候自由新增。如果您想利用舊事件的新功能,您可以重新處理事件,新的結果就會出現。
使用新功能重新處理時,您通常會希望關閉外部閘道器,這是正常情況。例外情況是新功能涉及這些閘道器時。即使如此,您可能也不希望針對過去的事件發出通知,如果您這樣做,您需要在第一次重新處理舊事件時進行一些特殊處理。這會很笨拙,但您只需要執行一次。
當您查看過去的處理並發現它不正確時,就會發生錯誤修復。對於內部事務,這很容易修復,您只需要進行修復並重新處理事件即可。您的應用程式狀態現在已修復為它應有的狀態。在許多情況下,這真的很好。
外部閘道器再次帶來複雜性。基本上,閘道器需要追蹤錯誤發生時與未發生時的差異。這個概念類似於追溯事件所需要的。確實,如果需要考慮大量重新處理,那麼值得實際使用追溯事件機制來用事件本身取代事件,不過要這麼做,您需要確保事件可以正確地反轉錯誤事件和正確事件。
第三種情況是邏輯本身隨著時間而改變,例如「11 月 18 日前收取 10 美元,之後收取 15 美元」這類規則。這類內容實際上需要納入網域模型本身。網域模型應該能夠隨時執行事件,並使用事件處理的正確規則。您可以使用條件式邏輯來執行此操作,但是如果您有大量時序邏輯,這將會變得混亂。更好的方法是將策略物件掛接到時序屬性:類似於 chargingRules.get(aDate).process(anEvent)
。請參閱合約調度器以了解此類樣式。
當需要使用錯誤程式碼處理舊事件時,處理錯誤和時序邏輯之間可能會重疊。這可能會導致雙時序行為:「根據 10 月 1 日我們在 8 月 1 日制定的規則反轉此事件,並根據我們現在在 8 月 1 日制定的規則取代此事件」。很明顯,這會變得非常混亂,除非您真的需要,否則不要走這條路。
其中一些問題可以透過將程式碼放入資料中來處理。使用適應式物件模型,透過物件組態找出處理方式,就是一種執行此操作的方法。另一種方法可能是使用某些直接可執行語言將指令碼嵌入資料中,而這些語言不需要編譯,例如將 JRuby 嵌入 Java 應用程式。當然,這裡的危險在於保持適當的組態控制。我會傾向於透過確保處理指令碼的任何變更都以與任何其他更新相同的方式處理(透過事件)來執行此操作。(不過現在我肯定已經從觀察轉向推測了。)
事件和帳戶
我在會計系統的脈絡中看過一些特別強大的事件溯源範例(以及後續模式)。這兩者在需求(稽核對於會計系統非常重要)和實作方面都有非常好的協同效應。這裡的一個關鍵因素是,您可以安排所有事情,以便網域事件的所有會計後果都是建立會計分錄,並將這些分錄連結到原始事件。這為您提供了追蹤變更、反轉等事項的良好基礎。特別是簡化了各種調整技術。
何時使用
將應用程式的每個變更封裝為事件是一種介面樣式,並非所有人都能接受,而且許多人會覺得很尷尬。因此,這不是一個自然的選擇,使用它表示您期望獲得某種形式的回報。
一個明顯的回傳形式是,可以輕鬆地序列化事件,以建立稽核記錄。這種稽核追蹤對於稽核很有用,這一點並不令人意外,但也有其他用途。我曾與一位線上帳戶出現異常狀態的人聊天,他打電話尋求協助。他很驚訝,那位協助人員能夠精確地告訴他所做的動作,因此能夠找出解決方法。要提供這種功能,表示要向支援小組公開稽核追蹤,以便他們能夠逐步了解使用者的互動。儘管事件溯源是一種很好的做法,但也可以使用更常規的記錄機制來執行此動作,這樣就不必處理奇怪的介面。
這種完整的稽核記錄的另一種用途是協助除錯。當然,使用日誌檔案來除錯生產問題是老生常談了。但是事件溯源可以更進一步,讓您可以建立一個測試環境,並將事件重新播放到測試環境中,以查看確切發生了什麼事,而且能夠像在除錯器中執行測試時一樣,停止、倒轉和重新播放。在將升級套用至生產環境之前執行平行測試時,這項功能特別有價值。您可以在測試系統中重新播放實際事件,並測試是否獲得預期的答案。
事件溯源是平行模型或追溯事件的基礎。如果您想要使用這些模式中的任何一種,您將需要先使用事件溯源。事實上,這會擴展到很難將這些模式套用到未採用事件溯源建置的系統。因此,如果您認為系統日後有合理機會需要這些模式,那麼現在就建置事件溯源會比較明智。這似乎是那些不建議將此決策留待日後重構的案例之一。
事件溯源也會為您的整體架構帶來一些可能性,特別是如果您正在尋找非常可擴充的東西。近來對於「事件驅動架構」有相當大的興趣。這個術語涵蓋了相當廣泛的想法,但大多數都集中在透過事件訊息進行通訊的系統。此類系統可以在非常鬆散耦合的平行模式下運作,這提供了絕佳的橫向可擴充性和系統故障復原力。
這方面的範例是一個擁有大量讀取器和少數寫入器的系統。使用事件溯源可以將其傳遞為一個具備記憶體中資料庫的系統叢集,並透過事件串流彼此保持最新狀態。如果需要更新,可以將其導向單一主控系統(或圍繞單一資料庫或訊息佇列的更緊密伺服器叢集),它會將更新套用至記錄系統,然後將產生的事件廣播至更廣大的讀取器叢集。即使記錄系統是資料庫中的應用程式狀態,這也可能是一個非常有吸引力的結構。如果記錄系統是事件記錄檔,則有許多高性能選項,因為事件記錄檔是一個純粹的附加結構,需要最小的鎖定。
當然,這種架構並非完美無缺。讀取器系統可能會因為事件傳播時間不同而與主控系統(以及彼此)不同步。然而,這種廣泛的架構風格已被採用,而且我聽到的幾乎都是正面的評論。
像這樣使用事件串流也允許透過點選事件串流並填入自己的模型來輕鬆新增新的應用程式,這些模型不一定要對所有系統都相同。這是一種與整合的訊息傳遞方法非常契合的方法。
範例:追蹤船隻(C#)
以下是事件溯源的一個非常簡單的範例,用於傳達基本概念。對於這個範例,我故意極度簡化以作為一個起點 - 然後我將使用進一步的範例來探討一些更複雜的問題。
網域模型是一個簡單的模型,其中船隻載運貨物並在港口之間移動。

有四種類型的事件會影響模型
- 抵達:船隻抵達港口
- 離港:船隻離開港口
- 裝載:貨物裝載到船隻上
- 卸貨:貨物從船隻上卸下
我們來舉一個移動船隻的簡單範例。
class Tester...
Ship kr; Port sfo, la, yyv; Cargo refact; EventProcessor eProc; [SetUp] public void SetUp() { eProc = new EventProcessor(); refact = new Cargo ("Refactoring"); kr = new Ship("King Roy"); sfo = new Port("San Francisco", Country.US); la = new Port("Los Angeles", Country.US); yyv = new Port("Vancouver", Country.CANADA) ; }
[Test] public void ArrivalSetsShipsLocation() { ArrivalEvent ev = new ArrivalEvent(new DateTime(2005,11,1), sfo, kr); eProc.Process(ev); Assert.AreEqual(sfo, kr.Port); } [Test] public void DeparturePutsShipOutToSea() { eProc.Process(new ArrivalEvent(new DateTime(2005,10,1), la, kr)); eProc.Process(new ArrivalEvent(new DateTime(2005,11,1), sfo, kr)); eProc.Process(new DepartureEvent(new DateTime(2005,11,1), sfo, kr)); Assert.AreEqual(Port.AT_SEA, kr.Port); }
為了讓這些測試運作,我們只需要抵達和離港事件。事件處理器非常簡單。
class EventProcessor...
IList log = new ArrayList(); public void Process(DomainEvent e) { e.Process(); log.Add(e); }
每個事件都有處理方法。
class DomainEvent...
DateTime _recorded, _occurred; internal DomainEvent (DateTime occurred) { this._occurred = occurred; this._recorded = DateTime.Now; } abstract internal void Process();
抵達事件僅擷取資料,並有一個處理方法,該方法僅將事件轉發至適當的網域物件。
class DepartureEvent...
Port _port; Ship _ship; internal Port Port {get { return _port; }} internal Ship Ship {get { return _ship; }} internal DepartureEvent(DateTime time, Port port, Ship ship) : base (time) { this._port = port; this._ship = ship; } internal override void Process() { Ship.HandleDeparture(this); }
所以這裡的事件只執行處理選擇邏輯。處理網域邏輯是由船隻執行的。
類別 Ship...
public Port Port; public void HandleDeparture(DepartureEvent ev) { Port = Port.AT_SEA; }
離港事件只會將 Ship 的港口設定為 特殊情況。您會注意到我將事件傳遞到網域物件中。這裡有一個選擇,關於事件是否應只傳遞網域物件處理所需的資料,或傳遞事件本身。透過傳遞事件,事件不需要確切知道網域邏輯所需的資料。如果事件稍後取得其他資料,就不需要更新簽章。傳遞事件的缺點是網域邏輯現在會知道事件本身。
那個簡單的測試只顯示基本事件處理如何運作。現在我將展示一點網域邏輯,看看它如何運作。我們的船隻運輸書籍,以滿足我擁有整條航運路線來運送我的書籍到世界各地的幻想。我確定您知道,透過加拿大運送書籍非常危險,因為這樣會冒著書籍被大量「eh」污染的風險。我見過幾乎每個句子都有「eh」的書籍(較長的句子可能會有兩個或三個)。
因此,我完美的書籍運送系統應該能夠偵測貨物是否已通過加拿大。
class Tester...
[Test] public void VisitingCanadaMarksCargo() { eProc.Process(new LoadEvent(new DateTime(2005,11,1), refact, kr)); eProc.Process(new ArrivalEvent(new DateTime(2005,11,2), yyv, kr)); eProc.Process(new DepartureEvent(new DateTime(2005,11,3), yyv, kr )); eProc.Process(new ArrivalEvent(new DateTime(2005,11,4), sfo, kr)); eProc.Process(new UnloadEvent(new DateTime(2005,11,5), refact, kr)); Assert.IsTrue(refact.HasBeenInCanada); }
由於貨物可能在船隻之間移動並卸貨,因此貨物有責任知道它是否已暴露於這些北方的危險之中。幸運的是,只有在實際停靠港口時才會發生風險,只在水域中是安全的。因此,我們的抵達事件必須追蹤這一點。
類別 ArrivalEvent...
Port _port; Ship _ship; internal ArrivalEvent (DateTime occurred, Port port, Ship ship) : base (occurred) { this._port = port; this._ship = ship; } internal Port Port {get {return _port;}} internal Ship Ship {get{return _ship;}} internal override void Process() { Ship.HandleArrival(this); }
處理常式再次是 Ship 物件。
類別 Ship...
IList cargo; public void HandleArrival (ArrivalEvent ev) { Port = ev.Port; foreach (Cargo c in cargo) c.HandleArrival(ev); }
船隻不負責追蹤加拿大行程,因此它會將抵達通知傳遞到貨物。
類別 Cargo...
public bool HasBeenInCanada = false; public void HandleArrival(ArrivalEvent ev) { if (Country.CANADA == ev.Port.Country) HasBeenInCanada = true; }
考慮事件處理方法持有網域邏輯的替代方案 - 隨著網域邏輯變得更複雜,它需要對網域模型有大量的知識。在此方法中,網域物件會將事件傳遞到相關物件,以便它們可以處理它,以執行它們需要執行的回應。
範例:更新外部系統 (C#)
事件來源 的一大特點是您可以盡可能地重新處理事件。但是,如果處理事件會導致與外部系統互動,那麼這是一件壞事。最好的情況是他們會厭倦您所有的垃圾郵件事件。
協助處理此問題的簡單方法是確保您的系統透過閘道器呼叫外部系統,這些閘道器可以設定為確保在您「實際」處理事件之前不會發出任何訊息。
我將使用船隻和港口(這次沒有貨物)的簡單範例來說明這一點。讓我們假設每當船隻進入港口時,都必須通知當地海關當局。我們可以在事件處理網域邏輯中做到這一點。
class Port...
public void HandleArrival (ArrivalEvent ev) { ev.Ship.Port = this; Registry.CustomsNotificationGateway.Notify(ev.Occurred, ev.Ship, ev.Port); }
請注意,這段程式碼只會在閘道物件上呼叫通知,它不關心這是否為真正的處理或某種重播。此處的一般原則是,網域邏輯不應關心事件執行的背景。
閘道負責找出是否實際傳送訊息。由於這個案例相當簡單,它只需透過連結到事件處理器並檢查處理器是否處於活動狀態即可做到這一點。
class CustomsEventGateway...
EventProcessor processor; public void Notify (DateTime arrivalDate, Ship ship, Port port) { if (processor.isActive) SendToCustoms(BuildArrivalMessage(arrivalDate, ship, port)); }
事件處理器在執行常規處理時會讓自己保持活動狀態。
class EventProcessor...
public void Process(DomainEvent e) { isActive = true; e.Process(); isActive = false; log.Add(e); }
雖然這個案例非常簡單,但基本原則是一樣的。閘道決定是否傳送外部訊息,而不是網域邏輯。閘道根據他們收集的關於處理背景的資訊來決定這一點。在這個案例中,處理器的簡單布林狀態就足夠了。
範例:還原事件 (C#)
在這裡,我們將採用運輸範例,並了解如何還原事件。我們需要還原的關鍵是確保我們可以準確計算由於事件而變更狀態的任何物件的先前狀態。
儲存此先前資料的理想位置是在事件本身,這與範例中傳遞事件到網域物件的方法相當契合。由於網域物件可以處理事件,因此它們可以輕鬆地將資訊儲存在事件中。
載入事件是一個簡單的範例。事件載有下列來源資料。
class LoadEvent...
int _shipCode; string _cargoCode; internal LoadEvent(DateTime occurred, string cargo, int ship) : base(occurred){ this._shipCode = ship; this._cargoCode = cargo; } internal Ship Ship {get { return Ship.Find(_shipCode); }} internal Cargo Cargo {get { return Cargo.Find(_cargoCode); }}
處理會交給貨物物件,它需要儲存作為貨物先前位置的港口。
class LoadEvent...
internal override void Process() { Cargo.HandleLoad(this); }
internal Port priorPort;
類別 Cargo...
internal void HandleLoad(LoadEvent ev) { ev.priorPort = _port; _port = null; _ship = ev.Ship; _ship.HandleLoad(ev); }
若要反轉事件,我們會新增一個反轉方法,用來反映處理方法,在網域物件上呼叫反轉方法。
class LoadEvent...
internal override void Reverse() { Cargo.ReverseLoad(this); }
類別 Cargo...
public void ReverseLoad(LoadEvent ev) { _ship.ReverseLoad(ev); _ship = null; _port = ev.priorPort; }
在此情況下,事件會採用許多可變動的先前資料,這些資料是其處理資料的一部分。在類似情況下,簡單欄位就已足夠。其他情況可能需要更精密的資料結構。當貨物處理到達事件時,會追蹤它是否在加拿大。它可以使用簡單的布林值欄位來執行此動作。若要在事件上儲存先前值,需要的就不只是簡單欄位,因為許多貨物可能會受到到達事件影響。因此,在此情況下,我會使用由貨物索引的地圖。
類別 Cargo...
public void HandleArrival(ArrivalEvent ev) { ev.priorCargoInCanada[this] = _hasBeenInCanada; if ("CA" == ev.Port.Country) _hasBeenInCanada = true; } private bool _hasBeenInCanada = false; public bool HasBeenInCanada {get { return _hasBeenInCanada;}}
類別 ArrivalEvent...
internal Port priorPort; internal IDictionary priorCargoInCanada = new Hashtable();
接著反轉
類別 Cargo...
public void ReverseArrival(ArrivalEvent ev) { _hasBeenInCanada = (bool) ev.priorCargoInCanada[this]; }
這個範例清楚說明事件上的來源資料和錯誤處理如何影響我們執行反轉的方式。對於載入事件,我們需要儲存貨物載入時的港口。如果事件在其來源資料中包含此資訊,我們就不需要執行此動作。一些額外的來源資料可以消除新增先前資料的需要。這並非適用於所有情況:到達事件無法找出其貨物的先前加拿大狀態。
被視為正確處理的內容也可能造成差異。在此系統中,我們同時有船隻的到達事件和離港事件。假設所有運作都正確,我們應該始終交錯取得這些事件。因此,船隻可以透過將其港口欄位設定為 Port.OUT_TO_SEA
來反轉到達事件。如果我們連續收到兩個到達事件,會發生什麼事?若要反轉此動作,我們需要在船隻上儲存先前港口。我們的替代方案是將沒有離港的第二次到達宣告為錯誤,我們不需要執行此儲存動作。
範例:外部查詢(C#)
即使對於基本的 事件溯源,外部查詢也很尷尬,因為如果你想要重建應用程式狀態,你需要使用過去所做的外部查詢回應來執行此動作。
讓我們想像一個情況,其中一艘船隻必須在進入港口時決定其貨物的價值。此估值是由外部服務完成。如果一艘船隻在 11 月 3 日進入港口,而我立即處理事件,我將取得該貨物在 11 月 3 日的價值。如果我在 12 月 5 日重建我的應用程式狀態,我將需要貨物的相同價值,即使其價值在此期間已變更。
對於此範例,我將展示一種處理此問題的方法,方法是保留外部查詢的記錄,並在重新處理事件時使用這些記錄來提供值。在許多方面,這與你用於外部更新的方法類似,將互動轉換為系統邊界的事件,並使用事件記錄來記住發生了什麼事。
使用與我在這些範例中其他地方使用的事件處理類似樣式,我們將事件傳遞給網域物件來處理。在此情況下,貨物會發起對外部系統的呼叫並儲存值。(我們假設我們將對此值執行某些有用的操作,但這與此範例無關。)
類別 Cargo...
public void HandleArrival(ArrivalEvent ev) { declaredValue = Registry.PricingGateway.GetPrice(this); } private Money declaredValue;
依我的習慣,我會透過我控制的 閘道 封裝所有外部系統存取。基本閘道只會將呼叫轉換成外部互動所需的任何內容。為了支援重播事件,我只要用一個會記錄這些查詢的類別將其包覆起來即可。

圖 6:用記錄閘道包覆閘道,以支援從事件正確重建。
記錄閘道會檢查每個呼叫,以查看是否有與其相符的舊要求,如果沒有,它會提出新的要求。
class LoggedPricingGateway...
public Money GetPrice(Cargo cargo) { GetPriceRequest oldReq = oldRequest(cargo); if (null != oldReq) return (Money) oldReq.Result; else return newRequest(cargo); }
如果要求是新的,它會將要求轉換成事件物件,呼叫外部查詢,並將其儲存在記錄中。
class LoggedPricingGateway...
private Money newRequest(Cargo cargo) { GetPriceRequest request = new GetPriceRequest(cargo); request.Result = gateway.GetPrice(cargo); log.Store(request); return (Money) request.Result; }
private class GetPriceRequest : QueryEvent { private Cargo cargo; public GetPriceRequest(Cargo cargo) : base() { this.cargo = cargo; }
class QueryEvent...
DomainEvent _eventBeingProcessed; Object result; public QueryEvent() { _eventBeingProcessed = Registry.EventProcessor.CurrentEvent; } public object Result { get { return result; } set { result = value; } } public DomainEvent EventBeingProcessed { get { return _eventBeingProcessed; } } }
因此,若要尋找舊要求,它會搜尋其記錄。
class LoggedPricingGateway...
private GetPriceRequest oldRequest(Cargo cargo) { IList candidates = log.FindBy(EventProcessor.CurrentEvent, typeof (GetPriceRequest)); foreach (GetPriceRequest request in candidates) { if (request.Cargo.RegistrationCode == cargo.RegistrationCode) return request; } return null; }
查詢記錄是通用的,因此我們可以使用已處理的 網域事件 和要求類型,發出查詢以取得一些項目。這會提供給我們一個小型的集合,需要以閘道特定方式進一步檢查。
要求記錄需要以與 網域事件 記錄相同的方式持續存在,因為需要它來重建應用程式狀態。