事件溯源和有狀態系統的結合應用

本文最初發佈於stitcher博客,經原作者授權由InfoQ中文站編譯並分享。

首先來看場景概況。這是我們曾經做過的一個比較大的項目。一旦項目完成,它將爲數以十萬計的用戶提供服務,處理大量財務交易,並且需要即時創建獨立的租戶專屬安裝。該項目的一個關鍵需求是需要輕鬆地報告和追蹤產品訂購流程的歷史記錄,這一流程也是業務的核心。

除了這個面向前端的客戶流程外,還會有一個複雜的管理面板來管理產品。這裏幾乎完全不需要報告或追蹤管理活動的歷史記錄。主要目標是提供一個易於使用的產品管理系統。

希望你能理解我故意使用模糊術語的做法,因爲這顯然不是一個開源項目。不過,我認爲“產品管理”和“訂單”的概念足以讓你瞭解我們所做的是怎樣的設計決策了。

我們首先來討論這個系統的一種設計方法,這種方法出自我之前寫過的《超越CRUD的Laravel》文章系列。

在這樣的系統中可能會有兩個域組:Product和Order,以及兩個同時使用這些域的應用程序:AdminApplicationCustomerApplication

簡化版本如下所示:

在之前的項目中,我們成功應用了這一架構,所以這次也可以直接使用它了事。但它也有一些缺點,特別是對於這個新項目而言,缺陷很突出:我們必須牢記,報告和歷史記錄跟蹤是訂購流程的關鍵之處。我們希望能在代碼中體現這一點,而不是簡單地加個副效果就行。

例如:我們可以使用活動日誌包來跟蹤訂單過程中出現的“歷史消息”。我們還能在訂單和歷史記錄表上編寫自定義查詢以生成報告。

但是,這些解決方案只能成爲核心業務的簡單的副效果,否則就沒法正常工作。可是我們的情況下並沒有這個條件。因此,Freek和我的任務就是爲這個項目設計一個方案,讓報告和歷史記錄跟蹤成爲應用程序中易於維護和易於使用的核心部分。

很自然地,我們將目光投向了事件溯源,這是一種可以滿足上述要求的優秀而靈活的解決方案。但沒有什麼是免費的午餐:事件溯源需要編寫很多額外代碼才能完成其他方法下很簡單的事情。在有些地方,你過去只需要簡單的CRUD操作處理數據庫中的數據即可,可現在你不得不操心事件調度,還要用projector和reactor處理事件,同時還要一直關注版本控制。

很明顯,事件溯源系統能解決許多問題;但即使在一些沒有任何收益的地方,它也會帶來很多開銷。

這就是我的意思:如果我們決定事件溯源Orders模塊(它依賴於Products模塊中的數據),那麼我們還需要事件溯源後者,否則的話我們可能會撞上無效狀態。如果Products沒有事件溯源,並且已被刪除,則我們將無法重建Orders狀態,因爲它缺少信息。

因此,要麼我們事件溯源一切內容,要麼就設法解決這個矛盾。

需要事件溯源一切內容嗎?

過去,在一些業餘項目中使用事件溯源的經歷讓我們痛苦地意識到,我們不應該低估它所增加的複雜性。此外,Greg Young表示事件溯源整個系統往往不是一個好主意——他對人們關於事件溯源的誤解有完整的論述,值得一看!

很顯然,我們不想事件溯源整個應用程序。這樣做根本沒有任何意義。唯一的選擇是找到一種將有狀態系統與事件溯源系統結合在一起的方法,但可惜在這個主題上我們發現沒什麼資源可用。

不管怎樣,我們還是進行了一些費工費力的研究,並設法找到了問題的答案。但這個答案不是來自事件溯源社區,而是來自一種完善的DDD實踐:限界上下文。

如果我們希望Products模塊成爲一個獨立的,有狀態的系統,則必須清晰地尊重Products和Orders之間的界限。我們不能把這兩個模塊視爲一個單體應用程序,而必須將它們視爲兩個單獨的上下文——也就是單獨的服務,兩者之間通信時必須確保Order上下文永遠不會進入無效狀態。

如果構建的Order上下文不直接依賴於Product上下文,那麼這個Product上下文是怎麼構建的就無關緊要了。

在與Freek討論時,我提到:將Products視爲可通過一個REST API訪問的獨立服務。當API下線或改動了自己的數據結構時,我們如何保證事件溯源應用程序仍然可以正常工作呢。

顯然,我們實際上並不會構建在服務之間通信的API,因爲它們將位於同一服務器上的同一代碼庫中。但在設計系統時有這樣的思考還是很好的。

邊界是下面這個樣子,其中每個服務都有自己的內部設計。

如果你讀過了我的《超越CRUD的Laravel》系列文章,那麼肯定已經熟悉了Product上下文的工作機制。那邊就沒什麼新東西要講了。不過Order上下文可以多講一些背景信息。

事件溯源一部分內容

下面我們看一下事件溯源的部分內容。我假設你之所以會閱讀這篇文章,至少是因爲你對事件溯源感興趣,所以我不會詳細解釋所有內容。

OrderAggregateRoot將跟蹤在這一上下文中發生的所有事件,並將成爲與應用程序對話的入口點。它還將調度事件,事件被存儲並傳播到所有reactor和projector。

Reactor將處理副效果,而這些副效果將永遠不會重放,並且projector將進行投影。在我們的項目中,這些都是簡單的Laravel模型。儘管只能從projector內部寫入這些模型,但可以從其他任何上下文中讀取它們。

我們在這裏做出的一個設計決策是不拆分讀寫模型,因爲現在我們使用了口頭和書面約定,要求這些模型只能通過它們的projector寫入。這種投影模型的一個例子就是一個Order。

要記住的一條最重要規則是,只能從Order存儲的事件中重建Order上下文的整個狀態。

那麼我們如何從其他上下文中提取數據呢?當與Product相關的上下文中發生某些事情時,如何通知Order上下文?可以肯定的是:與Products有關的所有信息將需要作爲事件存儲在Order上下文中;因爲在這一上下文中,事件是唯一的真實來源。

爲了做到這一點,我們引入第三種事件監聽器。我們已經有了projectors和reactors;現在我們添加訂閱者(subscribers)的概念。允許這些訂閱者偵聽來自其他上下文的事件,並在其當前上下文中進行相應的處理。看起來,它們幾乎總是將外部事件轉換爲內部存儲事件。

從事件存儲在Order上下文中的那一刻起,我們就可以放心地忘記對Product上下文的任何依賴。

有些讀者可能認爲我們會通過在這兩個上下文之間複製事件來複制數據。當然,我們將基於Product的created時間存儲特定於Orders的事件,因此的確會複製一些數據。但是,這樣做帶來的好處比你想象的更多。

首先:Product上下文不需要知道其他哪些上下文將使用它的數據。它不必考慮事件版本控制,因爲它的事件永遠都不會被存儲。這樣我們就可以在處理Product上下文時將它視爲正常的有狀態應用程序,無需加入事件溯源,也就避免其複雜性。

第二:會被事件溯源的不只是Order上下文,而且所有這些上下文都能單獨偵聽Product上下文中觸發的相關事件。

第三:我們不必存儲原始Product事件的完整副本,因爲每個上下文都可以挑選和存儲與自己用例相關的數據。

數據遷移問題呢?

一個新問題出現了。

假設這套系統已經投入生產一年之久,我們決定添加一個新的事件溯源上下文;它還需要有關Product上下文的信息。由於上面列出的原因,原始的Product事件沒有被存儲下來——那麼我們如何爲新的上下文建立初始狀態?

答案是這樣的:在部署時,我們必須讀取所有產品數據,並根據現有產品將相關事件發送到新添加的上下文中。這種一次性遷移的麻煩是額外的成本,但它讓我們可以自由地處理Product上下文,而不必擔心外部環境。對於這個項目,這是值得付出的代價。

最終整合

最後,通過使用只讀模型,我們就能使用從所有上下文收集的應用程序數據。到目前爲止,我們的約定依舊要求這些模型是隻讀的;而將來可能會改變這個約定。

從應用程序到Product上下文,就像普通的有狀態應用程序一樣通信即可。應用程序和事件溯源的上下文(例如Orders)之間是通過其聚合根通信的。

下面是最終成品的概述。這張圖中還缺少一些箭頭,但是上下文和應用程序之間與它們內部的相關流程畫的應該足夠清楚了。

解決我們問題的關鍵來自DDD的限界上下文思想。它們描述了我們代碼庫中的嚴格界限,我們不能隨意跨越這些界限。當然這增加了一層複雜性,但它還讓我們能夠自由地以期望的方式構建每個上下文,而不必操心支持其他上下文的問題。

最後一個難題是僅依靠事件作爲上下文之間的交流手段。它又增加了一層複雜性,但同時也是一種解耦和增加靈活性的方式。

第二部分則是講述我們如何在一個Laravel項目中編寫具體的代碼。

英文原文:

Combining event sourcing and stateful systems

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章