快速輕巧的CQRS和事件源解決方案

目錄

介紹

何苦呢?

優先級

1.可讀性

2.性能

3.可調試性

4.最小的依賴

5. 單獨的命令和事件

6.多租戶

7. Sagas/流程經理

8. 調度

9. 聚合到期

10. Async/Await是邪惡的

整潔架構

Timeline項目

樣例項目

總覽

入門

用法

方案A:如何創建和更新聯繫人

數據流

方案B:如何製作聚合快照

方案C:如何使聚合脫機

方案D:如何創建具有唯一登錄名的新用戶

方案E:如何調度命令

方案F:如何使用一個命令更新多個聚合

方案G:如何實現自定義事件處理程序

方案H:如何使用自定義處理程序覆蓋命令

展示

應用程序

寫端(Write Side)

讀端(Read Side)

領域

持久性

CQRS + ES主幹

命令

事件

聚合

快照

指標


本文的目的是提供使用C#編程語言和.NET FrameworkCQRS + ES模式的快速、輕量級實現。此實現功能比較全面,包括對命令和事件的SQL Server序列化、調度的命令、快照、sagas(即流程管理器)以及用於多租戶自定義的即插即用替代的支持。我將描述代碼的結構,並說明如何與示例應用程序一起工作。

介紹

如果您正在閱讀本文,那麼您可能已經對命令查詢責任隔離(CQRS)和事件源(ES)有所瞭解,所以我不會解釋它是什麼,爲什麼要使用它,或者爲什麼你想要避免它。有很多相關的文章可用參考,如下:

本文的目的是提供使用C#編程語言和.NET Framework的快速,輕量級的實現。

此實現功能比較全面,包括支持SQL Server命令和事件的持久性、調度的命令、快照、sagas(即流程管理器)以及用於多租戶自定義的即插即用替代。

我將描述代碼的結構,並說明如何使用遵循整潔架構(Clean Architectur)模式的示例應用程序。

何苦呢?

幾乎可以肯定這是您的第一個問題。

已經有很多CQRS + ES框架和解決方案,這些框架和解決方案已經得到充分開發和充分驗證。如果您正在研究選擇方案並評估構建vs購買決策,那麼您可以選擇出色的商業產品和開源替代品。

例如:

爲什麼要實現另一種解決方案?爲什麼我要從頭開始並發展自己的?

我研究和使用CQRSES模式已經有幾年了。我既使用了商業解決方案,也使用了開源解決方案,並使用它們來構建和/或改進實際的生產系統。如果您的背景和經歷與我的相似,那麼您可能已經知道該問題的答案,但是您可能不想相信這是真的(因爲我很長時間沒有這樣做):

如果您認真考慮採用CQRS + ES架構,那麼除了自行構建之外,別無選擇。

就像克里斯·基爾(Chris Kiehl)在他的文章中說的那樣:事件很難 ”

...您可能會從頭開始構建核心組件。就技術堆棧而言,該領域的框架往往是重量級,規範性強,缺乏靈活性的框架。如果您想啓動並運行某事,則應該自己動手做(這是建議的方法)。

如果是這樣,那麼對我來說寫這篇文章有什麼幫助?

簡單:這是另一個帶有源代碼的示例,因此您可以看到我如何解決CQRS + ES解決方案中出現的一些問題。如果您是從CQRS + ES項目開始的,那麼您應該研究發現的所有示例。

我不希望(或建議)採用此源代碼並將其合併到您開發的任何應用程序中。

相反,我的意圖僅是提供另一個示例,您可以從中得出一些想法——也許(如果我做得不錯的話)爲您自己的項目提供一些小啓發。

優先級

從驅動實現的優先級列表開始是很重要的,因爲要做出的許多設計決策都需要進行重大的權衡。

CQRS + ES的純粹主義者會反對我的某些決定,並堅決譴責其他決定。我可以忍受這一點。

我已經設計和開發軟件很長時間了(比我準備在這裏承認的時間還長)。我流血、流汗、流淚和染白頭髮不止一點——所以我敏銳地意識到,面對取捨,選擇不當所帶來的成本。

以下優先級有助於告知和指導這些決定。它們以重要性的高低順序列出,但是所有都是必需的,以,給自己倒杯酒,安頓下來,因爲這裏的序言將是很長的...

1.可讀性

該代碼必須可讀

代碼越可讀,它就越有用、可維護和可擴展。

在我使用的某些實現中(以及我自己開發的某些實現中),除了原始作者之外,幾乎所有其他人都無法理解底層CQRS + ES主幹的代碼。我們不能在這裏允許。一小組開發人員必須能夠並且相對容易地共享並使用代碼,並且充分了解其工作方式以及爲什麼以這種方式編寫代碼。

尤其重要的是,用於註冊命令處理程序和事件處理程序的代碼必須簡單明瞭。

許多CQRS + ES框架使用反射和依賴注入的組合來自動註冊用戶,以處理命令和事件。儘管這通常非常聰明——並通常會減少項目中的代碼行總數——但它隱藏了命令(或事件)與其訂閱者之間的關係,從而將這些關係變成了不透明的,神奇的黑框。許多控制反轉(IoC)容器使此操作變得容易,因此可以理解,但我相信這是一個錯誤。

需要明確的是:在項目中使用IoC容器不是錯誤。依賴注入是一種出色的最佳實踐,也是一種完善的重要技術。但是,依賴項注入模式本身並不是發佈-訂閱模式,並且將兩者混爲一談會導致很多痛苦和災難。在IoC容器庫中使用高度專業化的功能來自動化該庫的預期用途之外的功能,然後將軟件體系結構中最關鍵的組件緊密結合在一起是一個錯誤(我自己做過)。當您的應用程序中的某些行爲異常時,這將使故障排除和調試異常困難且耗時。

因此,作爲此可讀性目標的一部分,必須在代碼中顯式定義命令處理程序和事件處理程序的註冊,而不是通過約定或自動化來隱式定義。

2.性能

代碼必須是快速的

處理命令和事件是在CQRS + ES架構上開發的任何系統的核心,因此吞吐量優化是關鍵的性能指標。

就併發用戶和系統發出命令並觀察已發佈事件的影響而言,實現必須處理最大可能的數量。

在我以前的一些實現中,很多痛苦和苦難是由於將命令發送到大型聚合(例如,具有大量事件流的長期聚合)時發生的併發衝突而引起的。根本原因通常是性能不佳的代碼。因此,算法優化至關重要。

快照是滿足此要求所不可或缺的,因此必須是解決方案所不可或缺的。該實現必須具有對每個聚合根上的自動快照的內置支持。

內存中緩存是運行時優化的另一個重要部分,因此,它也必須是解決方案不可或缺的一部分。

3.可調試性

使用標準調試器(如Visual Studio IDE調試器)來跟蹤代碼並跟蹤其執行必須是很容易的。

我已經看到許多CQRS + ES實現依賴於複雜的算法來動態註冊、查找和調用用於處理命令和事件的方法。

同樣,這些算法中的許多算法都非常聰明:它們具有強大的功能和靈活性,並且可以顯着減少解決方案中的代碼行數。

例如,我在過去的一些項目中使用過DynamicInvoker類。這是一段巧妙的代碼——少於150行——而且效果很好。(我沒有寫它,所以當我這麼說的時候我並不自誇。)但是,如果代碼中有些雜亂無章的東西,您已經編寫了調用此類的方法的代碼,並且如果需要使用調試器,然後你需要特別熟練地進行思維體操,以瞭解所發生的事情。我不是,所以如果使用任何動態調用,那麼在使用調試器時,理解代碼和跟蹤其執行的線程必須非常容易。

4.最小的依賴

外部依賴性必須保持在絕對的最低限度。

過多的依賴性導致代碼比您在系統的任何關鍵組件中可能需要的速度更慢,更重且更脆弱。最小化依賴關係有助於確保您的代碼更快、更輕巧、更健壯。

最重要的是,最小化依賴性有助於確保解決方案不會與任何外部程序集、服務或組件緊密耦合,除非該依賴性至關重要。

如果軟件的基本體系結構依賴於某些外部第三方組件,則必須做好準備,有可能在某天對其進行更改可能會影響您的項目。有時這是可以接受的風險,而其他時候則不是。

在該特定實現方式中,對該風險的容忍度非常低。

因此,您會注意到核心的Timeline程序集(實現CQRS + ES主幹)僅具有一個外部依賴項:即System.NET Framework中的名稱空間。

旁白一下,因爲這是一篇有趣的文章,說明了我的觀點:在撰寫本文時,2018年,NPM JavaScript軟件包單數在一週內有280萬以上的安裝。所有這些開發人員都沒有編寫基本的代碼來讓函數在數字爲奇數時返回true的情況,而是選擇將is-odd程序包與他們的300多個依賴項鍊合併到他們的解決方案中!

5. 單獨的命令和事件

許多CQRS + ES框架都以共同的基類派生的方式實現一個Command類和一個Event類。

這樣做的理由很明顯:將命令和事件都視爲通用消息的子類型是很自然的。兩者都是使用某種形式的服務總線”“發送的,那麼爲什麼不在共享基類中實現通用功能,而編寫一個雙重用途的類來路由消息——而不是編寫大量重複代碼呢?

這是我過去採用的方法,對此有很好的論據。

但是,我現在認爲這可能是一個錯誤。引用羅伯特·馬丁(Robert C. Martin)的話

軟件開發人員經常陷入陷阱——陷阱取決於他們對重複的恐懼。在軟件中,複製通常是一件壞事。但是有不同種類的重複。確實存在重複,其中對一個實例的每次更改都必須對該實例的每個副本進行相同的更改。然後有虛假或偶然的重複。如果兩個明顯重複的代碼部分沿着不同的路徑發展——如果它們以不同的速率變化並且由於不同的原因——那麼它們就不是真正的重複...當您將用例彼此垂直分離時,就會遇到這個問題,您的誘惑是將用例耦合在一起,因爲它們具有相似的用戶界面,相似的算法或相似的數據庫模式。小心。抵制誘惑,不要犯條件反射式消除重複的罪。確保重複是真實的。

命令和事件彼此之間有足夠的不同,以保證它們可以沿着不同的路徑發展並適應系統需求。

我還沒有經歷過通過消除A)發送/處理命令和B)發佈/處理事件的重複代碼來提高代碼質量、性能或可讀性的任何情況。

因此,命令和事件不得具有任何共享基類,並且用於發送/發佈命令/事件的機制一定不能是共享隊列。

6.多租戶

多租戶必須是解決方案不可或缺的組成部分,而不是事後必須附加的功能或設施。

這些天來,我專門構建和維護企業多租戶系統。這意味着我只有一個應用程序的單個實例,該實例爲具有多個併發用戶的多個併發租戶提供服務。

在此實現中將多租戶作爲優先級有幾個原因:

  • 每個集合必須分配給一個租戶。這使得數據的所有權清晰且定義明確。
  • 當需要擴大規模時,分片必須易於實現。分片是將聚合分佈到多個寫側節點,而租戶是劃分聚合的最自然邊界。
  • 特定於租戶的自定義必須易於實現。每個應用程序對於每個命令和每個事件都有核心的默認行爲,但是在爲許多不同的組織和/或利益相關者服務的大型複雜應用程序中,不同的租戶肯定具有各種特定的需求。有時差異很小。有時它們很重要。此處的解決方案必須允許開發人員使用針對特定租戶定製的功能來覆蓋命令和/或事件的默認處理。覆蓋必須是明確的,因此易於識別、啓用或禁用。

7. Sagas/流程經理

實現流程管理器所需的步驟數量必須相對較少,並且流程管理器的代碼必須相對易於編寫。

流程管理器(有時稱爲saga)是一個獨立的組件,它以交叉聚合、最終一致的方式對域事件做出反應。流程管理器有時純粹是反應性的,有時代表工作流。

從技術角度來看,流程管理器是一種狀態機,受傳入事件的驅動,這些事件可能是從多個聚合發佈的。每個狀態都可能有副作用(例如,發送命令,與外部Web服務通信,發送電子郵件)。

我曾使用過一些CQRS + ES框架,這些框架根本不支持流程管理器,而其他框架則支持該概念,但不以易於理解或配置的方式提供支持。

例如,在我自己過去的一種實現中,事件被附加到數據庫日誌之後,事件存儲將立即發佈事件。它不是由聚集或命令處理程序發佈的。這甚至使實現最基本的工作流程也變得異常困難:我無法從事件處理程序中向聚合發送同步命令,因爲事件存儲的Save方法在同步鎖(以維護線程安全)內執行,並且新事件不創建死鎖就無法發佈。

無論工作流程的狀態機多麼簡單或複雜,要協調該流程中的事件,都需要具有副作用的代碼,例如向其他聚合發送命令,向外部Web服務發送請求或發送電子郵件。因此,此處的解決方案必須具有本地的內置支持才能實現此目的。

8. 調度

命令調度必須是解決方案不可或缺的一部分。

使用計時器發送命令必須很容易,因此該命令會在計時器經過後執行。這使開發人員可以指示執行任何命令的特定日期和時間。

這對於必須依賴時間觸發的命令很有用。

對於在正常執行流程之外的後臺進程中必須脫機執行的命令,它也很有用。這種類型的完全異步操作非常適合需要較長時間才能完成的命令。

例如,假設您有一條命令要求在某些外部第三方Web服務上調用方法,並且該服務通常需要超過80萬毫秒才能響應。必須安排此類命令在非高峯時間執行和/或在執行的主線程之外執行。

9. 聚合到期

該解決方案必須具有本機內置的聚合到期和清除支持。

我需要一個CQRS + ES解決方案,該解決方案可以輕鬆地將聚合事件流從聯機結構化日誌複製到脫機存儲,並從事件存儲中清除它。

事件源極簡主義者將立即對此進行紅色標記,並說絕不可更改或刪除聚合事件流。他們會說事件(因此是聚集)從定義上是不可變的。

但是,在某些情況下,這是不可協商的業務需求。

  • 第一:當客戶不續訂對多租戶應用程序的訂閱時,託管該應用程序的服務提供商通常負有合同義務,要求從其系統中刪除該客戶的數據。
  • 第二:當項目團隊進行頻繁的集成測試以確認系統功能正常運行時,從定義上看,輸入和輸出這些測試的數據是臨時的。用於測試聚合的事件流的永久存儲是浪費磁盤空間,沒有當前或將來的業務價值;我們需要一種刪除它的機制。

因此,可以說,這裏的解決方案必須提供一種簡便的方法來將聚合移出操作系統並移入冷存儲

10. Async/Await是邪惡的

我當然在開玩笑。

但事實並非如此。

C#中的async/await模式產生非常高性能的代碼。毫無疑問。在某些情況下,我已經看到它將性能提高了一個數量級或更多。

async/await模式可以在此解決方案的將來迭代中應用,但是——儘管此列表中具有第二優先級——在此解決方案中它是不允許的,因爲它會導致破壞第一優先級。

在將async/await引入方法後,您將被迫轉換其調用方,以便它們使用asyncawait(或被迫開始將乾淨的代碼包裝在髒線程塊中),然後被迫轉換這些調用方的調用方,因此他們在整個代碼庫中使用async/await,依此類推。該async/await關鍵字蔓延像傳染性殭屍病毒。由此產生的異步代碼混亂幾乎可以肯定會更快,但同時更難閱讀,甚至更難調試。

可讀性是這裏的重中之重,因此,我一直避免async/await直到它是提高性能的唯一剩餘選擇(而且提高性能是不可商議的業務要求)。

整潔架構

馬修· 倫茲(Matthew Renze在整潔架構主題方面開設了出色的Pluralsight。該解決方案的源代碼包含五個程序集,並且遵循他所倡導的簡潔架構模式。

Timeline項目

Timeline程序集實現了CQRS + ES主幹。該程序集沒有上游依賴性,因此它並不特定於任何應用程序。它可以從示例應用程序中斷開,並集成到一個新的解決方案中,以開發完全不同的應用程序。

其他四個程序集(Sample*)使用Timeline程序集在控制檯應用程序中實現這些層,以演示我在CQRS + ES軟件系統中執行常見任務的方法。

項目依賴關係圖如下所示:

樣例項目

注意,Timeline程序集沒有引用任何Sample程序集。

還要注意以領域爲中心的方法:領域層不依賴於PresentationApplicationPersistence層。

示例領域的實體關係圖如圖2所示:

在此基本數據模型中:

  • 一個Person0..N個銀行Account
  • 一個Transfer從一個賬戶取錢,然後存入另一個賬戶;
  • 一個User可能是沒有個人數據的管理員,或者是擁有多個租戶擁有的個人數據的某人

請記住:每個PersonAccountTransfer都是聚合根,因此這些實體中的每個都有一個Tenant屬性。

總覽

3展示了此解決方案中CQRS + ES的總體方法:

請注意,Write Side(命​​令)和Read Side(查詢)已被很好地描述。

您還可以看到,事件源非常類似於Write Side的插件。儘管此解決方案中未進行演示,但您可以看到不帶事件源的CQRS解決方案的外觀,有時(取決於CQRS)是更好的模式,具體取決於項目的要求。

以下是該體系結構的關鍵特徵:

  • 命令隊列將命令(調度所需)保存在結構化日誌中。
  • 命令訂閱者在命令隊列上偵聽命令。
  • 命令訂戶負責創建聚合並在執行命令時在聚合上調用方法。
  • 命令訂戶將聚合(作爲事件流)保存在結構化日誌中。
  • 命令訂戶在事件隊列上發佈事件。
  • 已發佈的事件由事件訂閱者和流程管理器處理。
  • 流程管理器可以響應事件在命令隊列上發送命令。
  • 事件訂閱者在查詢存儲中創建和更新投影。
  • 查詢搜索是用於讀取投影的輕量級數據訪問層。

入門

在編譯和執行源代碼之前:

  1. 執行腳本Create Database.sql以創建本地SQL Server數據庫。
  2. 更新Web.config的連接字符串。
  3. OfflineStoragePath更新Web.configappSetting值。

用法

我將從頂部開始並演示如何使用它,然後從應用程序堆棧一直向下瀏覽到CQRS + ES主幹的基本細節,而不是從底部開始描述Timeline程序集的工作方式。

如果我吸引了你這麼長時間,那麼我應該感謝你陪我到現在……

方案A:如何創建和更新聯繫人

這是最簡單的用法。

在這裏,我們創建一個新的聯繫人,然後進行名稱更改,模擬Alice結婚的用例:

public static void Run(ICommandQueue commander)
{
    var alice = Guid.NewGuid();
    commander.Send(new RegisterPerson(alice, "Alice", "O'Wonderland"));
    commander.Send(new RenamePerson(alice, "Alice", "Cooper"));
}

這樣運行後,讀端投影看起來很好,正如預期的那樣:

數據流

下圖說明了系統在這種情況下執行的步驟:

方案B:如何製作聚合快照

快照由“Timeline”程序集自動執行。默認情況下,每個聚合都啓用了它們,因此您無需執行任何操作即可運行此功能。

在下一個測試運行中,Timeline程序集被配置爲每10個事件後拍攝一次快照。我們註冊一個新的聯繫人,然後將其重命名20次。這將在事件編號20上生成快照,這是倒數第二個重命名操作。

public static void Run(ICommandQueue commander)
{
    var henry = Guid.NewGuid();
    commander.Send(new RegisterPerson(henry, "King", "Henry I"));
    for (int i = 1; i <= 20; i++)
        commander.Send(new RenamePerson(henry, "King", "Henry " + (i+1).ToRoman()));
}

不出所料,我們在版本20上有一個快照,並在事件編號後顯示了當前狀態21

方案C:如何使聚合脫機

術語裝箱取消裝箱用於使聚合脫機並使其重新聯機。

當您發送命令將彙總框裝箱時Timeline程序集:

  1. 創建快照;
  2. 將快照和整個聚合事件流複製到存儲在文件系統目錄中的JSON文件中;然後
  3. SQL Server結構化日誌表中刪除快照和聚合。

當然,這使它成爲極具破壞性的操作,除非是強制性的業務/法律要求,否則永遠不要使用它。

在下一個測試運行中,我們註冊一個新的聯繫人,將其重命名7次,然後將聚合框起來。

public static void Run(ICommandQueue commander)
{
    var hatter = Guid.NewGuid();
    commander.Send(new RegisterPerson(hatter, "Mad", "Hatter One"));
    for (int i = 2; i <= 8; i++)
        commander.Send(new RenamePerson(hatter, "Mad", "Hatter " + i.ToWords().Titleize()));
    commander.Send(new BoxPerson(hatter));
}

如您所見,聚合不再存在於事件存儲中,並且最終快照的脫機副本(以及整個事件流)已在文件系統上進行了製作。

方案D:如何創建具有唯一登錄名的新用戶

在開發人員中,我最經常在線上嘗試理解CQRS + ES的問題是:

如何強制執行參照完整性以確保新用戶具有唯一的登錄名?

在我對CQRS + ES模式進行研究的初期,我自己(不止一次)問過同樣的問題。

來自經驗豐富的從業人員的許多答案看起來像這樣:

您的問題表明您不瞭解CQRS + ES

這是真的(我現在意識到),但是完全沒有幫助——尤其是對於那些努力學習的人。

一些答案稍好一些,以摘要形式提供了高級建議,但使用了CQRS + ES術語,但這也不總是很有幫助。我最喜歡的建議之一是(來自Edument的好夥伴):

創建一個反應式的saga,以標記和停用仍然使用重複的用戶名創建的帳戶,無論是由於極端巧合還是惡意或由於客戶端故障引起的。

我第一次讀到我對它的含義只有一個模糊的認識,根本不知道如何開始執行這樣的建議。

下一個測試運行以真實的工作代碼爲例,展示了一種使用唯一名稱創建新用戶的方法(但不是唯一方法)。

在這種情況下,訣竅是要意識到您實際上確實需要一個saga(或我更喜歡稱呼它流程管理器)。創建新的用戶帳戶不是一步一步的操作。這是一個過程,因此需要協調。流程圖(或狀態機,如果您願意的話)在您的應用程序中可能非常複雜,但是即使在所有可能的情況中最簡單的情況下,它也會看起來像這樣:

下圖顯示了依賴於流程管理器來實現此功能的代碼:

public void Run()
{
    var login = "[email protected]";
    var password = "Let_Me_In!";

    if (RegisterUser(Guid.NewGuid(), login, password)) // succeeds.
        System.Console.WriteLine($"User registration for {login} succeeded");
    
    if (!RegisterUser(Guid.NewGuid(), login, password)) // fails; duplicate login.
        System.Console.WriteLine($"User registration for {login} failed");
}

private bool RegisterUser(Guid id, string login, string password)
{
    bool isComplete(Guid user) { return _querySearch.IsUserRegistrationCompleted(user); }
    const int waitTime = 200; // ms
    const int maximumRetries = 15; // 15 retries (~3 seconds)

    _commander.Send(new StartUserRegistration(id, login, password));

    for (var retry = 0; retry < maximumRetries && !isComplete(id); retry++)
        Thread.Sleep(waitTime);

    if (isComplete(id))
    {
        var summary = _querySearch.SelectUserSummary(id);
        return summary?.UserRegistrationStatus == "Succeeded";
    }
    else
    {
        var error = $"Registration for {login} has not completed after 
                    {waitTime * maximumRetries} ms";
        throw new IncompleteUserRegistrationException(error);
    }
}

請注意,上面示例中的調用方未假定命令StartUserRegistration的同步處理。而是輪詢註冊的狀態,等待註冊完成。

知道Timeline程序集中的代碼是同步的,我們可以重構方法RegisterUser,使其更加簡單:

private bool RegisterUserNoWait(Guid id, string login, string password)
{
    bool isComplete(Guid user) { return _querySearch.IsUserRegistrationCompleted(user); }

    _commander.Send(new StartUserRegistration(id, login, password));

    Debug.Assert(isComplete(id));

    return _querySearch.SelectUserSummary(id).UserRegistrationStatus == "Succeeded";
}

流程管理器本身的代碼比您可能想象的要簡單:

public class UserRegistrationProcessManager
{
    private readonly ICommandQueue _commander;
    private readonly IQuerySearch _querySearch;

    public UserRegistrationProcessManager
       (ICommandQueue commander, IEventQueue publisher, IQuerySearch querySearch)
    {
        _commander = commander;
        _querySearch = querySearch;

        publisher.Subscribe<UserRegistrationStarted>(Handle);
        publisher.Subscribe<UserRegistrationSucceeded>(Handle);
        publisher.Subscribe<UserRegistrationFailed>(Handle);
    }

    public void Handle(UserRegistrationStarted e)
    {
        // Registration succeeds only if no other user has the same login name.
        var status = _querySearch
            .UserExists(u => u.LoginName == e.Name 
			    && u.UserIdentifier != e.AggregateIdentifier)
            ? "Failed" : "Succeeded";

        _commander.Send(new CompleteUserRegistration(e.AggregateIdentifier, status));
    }

    public void Handle(UserRegistrationSucceeded e) { }

    public void Handle(UserRegistrationFailed e) { }
}

那裏有一個基本的反應式saga,它標記了使用重複用戶名創建的不活動帳戶。有很多的欣喜。

如預期的那樣,第一次註冊成功,而第二次失敗:

方案E:如何調度命令

調度命令在將來的日期/時間運行很容易:

public static void Run(ICommandQueue commander)
{
    var alice = Guid.NewGuid();
    var tomorrow = DateTimeOffset.UtcNow.AddDays(1);
    commander.Schedule(new RegisterPerson(alice, "Alice", "O'Wonderland"), tomorrow);

    // After the above timer elapses, any call to Ping() executes the scheduled command.
    // commander.Ping();
}

注意,這不會在事件日誌中創建任何聚合,並且命令日誌現在包含計劃的條目:

方案F:如何使用一個命令更新多個聚合

這是試圖瞭解如何實現CQRS + ES模式的開發人員提出的另一個常見問題。當我自己學習時,這是我(很多次)問過的另一個問題。

從業者經常回答:

你不能。

這不是很有啓發性的。

有些人會提供更多指導,內容如下:

聚合和命令處理程序的分解將使這種想法無法在代碼中表達。

最初幾次閱讀該語句似乎很神祕,最後發現它對於驗證實現很有幫助,但是從一開始它並不是超級有用。

最有用的是一個帶有實際工作代碼的示例,該示例首先實現了引發問題的功能類型:

  • 假設我有兩個銀行帳戶,每個銀行帳戶都是一個總根,我想將資金從一個帳戶轉移到另一個帳戶。如何使用CQRS + ES做到這一點?

下一次測試運行顯示了可以完成此操作的一種方法(而非唯一方法)。

在這種情況下,訣竅是要意識到您需要另一個聚合根——即匯款本身不是一個帳戶——並且需要一個流程管理器來協調工作流。

下圖說明了最簡單的流程圖。(會計系統顯然需要比這更復雜的東西。)

一旦完成所有步驟,依靠流程管理器來實現上述工作流程的代碼就很容易了:

public void Run()
{
    // Start one account with $100.
    var bill = Guid.NewGuid();
    CreatePerson(bill, "Bill", "Esquire");
    var blue = Guid.NewGuid();
    StartAccount(bill, blue, "Bill's Blue Account", 100);

    // Start another account with $100.
    var ted = Guid.NewGuid();
    CreatePerson(ted, "Ted", "Logan");
    var red = Guid.NewGuid();
    StartAccount(ted, red, "Ted's Red Account", 100);

    // Create a money transfer for Bill giving money to Ted.
    var tx = Guid.NewGuid();
    _commander.Send(new StartTransfer(tx, blue, red, 69));
}

private void StartAccount(Guid person, Guid account, string code, decimal deposit)
{
    _commander.Send(new OpenAccount(account, person, code));
    _commander.Send(new DepositMoney(account, deposit));
}

private void CreatePerson(Guid person, string first, string last)
{
    _commander.Send(new RegisterPerson(person, first, last));
}

執行該測試後,Bill的藍色帳戶餘額爲31美元,Ted的紅色帳戶餘額爲169美元,這與預期的一樣:

匯款流程管理器的代碼也不太困難:

public class TransferProcessManager
{
    private readonly ICommandQueue _commander;
    private readonly IEventRepository _repository;

    public TransferProcessManager
    (ICommandQueue commander, IEventQueue publisher, IEventRepository repository)
    {
        _commander = commander;
        _repository = repository;

        publisher.Subscribe<TransferStarted>(Handle);
        publisher.Subscribe<MoneyDeposited>(Handle);
        publisher.Subscribe<MoneyWithdrawn>(Handle);
    }

    public void Handle(TransferStarted e)
    {
        var withdrawal = new WithdrawMoney(e.FromAccount, e.Amount, e.AggregateIdentifier);
        _commander.Send(withdrawal);
    }

    public void Handle(MoneyWithdrawn e)
    {
        if (e.Transaction == Guid.Empty)
            return;

        var status = new UpdateTransfer(e.Transaction, "Debit Succeeded");
        _commander.Send(status);

        var transfer = (Transfer) _repository.Get<TransferAggregate>(e.Transaction).State;

        var deposit = new DepositMoney(transfer.ToAccount, e.Amount, e.Transaction);
        _commander.Send(deposit);
    }

    public void Handle(MoneyDeposited e)
    {
        if (e.Transaction == Guid.Empty)
            return;

        var status = new UpdateTransfer(e.Transaction, "Credit Succeeded");
        _commander.Send(status);

        var complete = new CompleteTransfer(e.Transaction);
        _commander.Send(complete);
    }
}

方案G:如何實現自定義事件處理程序

在下一個示例中,我將演示如何定義一個自定義事件處理程序,該事件處理程序旨在供多租戶系統中的一個租戶和僅一個租戶使用。

在這種情況下,Umbrella Corporation是我們的租戶之一,並且該組織希望我們系統中所有現有的核心功能。但是,該公司還需要其他自定義功能:

  • 當從任何一個Umbrella帳戶開始進行資金轉賬或向其進行資金轉賬時,如果美元金額超過10,000美元,則必須將電子郵件通知直接發送給公司所有者。

爲了滿足此要求,我們爲租戶實現了流程管理器。依賴於此流程管理器的調用代碼與之前的場景沒有什麼不同。

public void Run()
{
    // Start one account with $50,000.
    var ada = Guid.NewGuid();
    CreatePerson(ada, "Ada", "Wong");
    var a = Guid.NewGuid();
    StartAccount(ada, a, "Ada's Account", 50000);

    // Start another account with $25,000.
    var albert = Guid.NewGuid();
    CreatePerson(albert, "Albert", "Wesker");
    var b = Guid.NewGuid();
    StartAccount(albert, b, "Albert's Account", 100);

    // Create a money transfer for Ada giving money to Albert.
    var tx = Guid.NewGuid();
    _commander.Send(new StartTransfer(tx, a, b, 18000));
}

private void StartAccount(Guid person, Guid account, string code, decimal deposit)
{
    _commander.Send(new OpenAccount(account, person, code));
    _commander.Send(new DepositMoney(account, deposit));
}

private void CreatePerson(Guid person, string first, string last)
{
    _commander.Send(new RegisterPerson(person, first, last));
}

這是Visual Studio調試器的快照,查看流程管理器的代碼,在發送電子郵件通知的行上有一個斷點。請注意,彈出窗口中的消息正文是我們期望的:

方案H:如何使用自定義處理程序覆蓋命令

最後一個示例是前一個示例的變體。Umbrella Corporation希望完全禁用核心應用程序功能,並將其替換爲完全自定義的行爲。新的業務需求如下所示:

  • 不允許在我們的系統中更改聯繫人的姓名。曾經

爲了滿足此要求,我們對流程管理器進行了一些簡單的更改。我們向構造函數添加一行代碼,指定覆蓋,然後添加替換函數:

public class UmbrellaProcessManager
{
    private IQuerySearch _querySearch;

    public UmbrellaProcessManager
      (ICommandQueue commander, IEventQueue publisher, IQuerySearch querySearch)
    {
        _querySearch = querySearch;

        publisher.Subscribe<TransferStarted>(Handle);
        commander.Override<RenamePerson>(Handle, Tenants.Umbrella.Identifier);
    }

    public void Handle(TransferStarted e) { }

    public void Handle(RenamePerson c)
    {
        // Do nothing. Umbrella does not permit renaming people.
        
        // Throw an exception to make the consequences even more severe 
		// for any attempt to rename a person...
        // throw new DisallowRenamePersonException();
    }
}

這是一個基本的測試運行,以證明此功能可以按預期進行:

public static class Test08
{
    public static void Run(ICommandQueue commander)
    {
        ProgramSettings.CurrentTenant = Tenants.Umbrella;

        var alice = Guid.NewGuid();
        commander.Send(new RegisterPerson(alice, "Alice", "Abernathy"));
        commander.Send(new RenamePerson(alice, "Alice", "Parks"));
    }
}

請注意,日誌中只有一個事件,並且此人的姓名沒有變化:

展示

示例應用程序中的表示層是一個控制檯應用程序,僅用於編寫和運行測試用例場景。

這裏沒有什麼值得特別注意的。您會注意到,我沒有使用第三方組件進行依賴注入。相反,我寫了一個非常基本的內存服務定位器。

這樣做僅是爲了使示例應用程序儘可能小且儘可能集中。在您自己的表示層中,將使用您喜歡的任何IoC容器,以最適合您的方式實吸納依賴項注入。

應用程序

應用程序層分爲兩個不同的部分:用於命令的寫端和用於查詢的讀端。這種劃分有助於確保我們不會意外地混合使用寫端和讀端功能。

請注意,這裏沒有引用外部第三方程序集:

寫端(Write Side)

命令是普通的舊C#對象(PO​​CO)類,因此它們可以輕鬆地用作數據傳輸對象(DTO)以便於序列化:

public class RenamePerson : Command
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public RenamePerson(Guid id, string firstName, string lastName)
    {
        AggregateIdentifier = id;
        FirstName = firstName;
        LastName = lastName;
    }
}

注意:與數據傳輸對象相比,我更喜歡數據包一詞,並且我知道許多讀者會反對,因此請選擇適合您和您的團隊的術語。

命令處理程序方法的註冊在命令訂戶類的構造函數中是顯式的,並且事件在保存到事件存儲後才發佈:

public class PersonCommandSubscriber
{
    private readonly IEventRepository _repository;
    private readonly IEventQueue _publisher;

    public PersonCommandSubscriber
      (ICommandQueue commander, IEventQueue publisher, IEventRepository repository)
    {
        _repository = repository;
        _publisher = publisher;

        commander.Subscribe<RegisterPerson>(Handle);
        commander.Subscribe<RenamePerson>(Handle);
    }

    private void Commit(PersonAggregate aggregate)
    {
        var changes = _repository.Save(aggregate);
        foreach (var change in changes)
            _publisher.Publish(change);
    }

    public void Handle(RegisterPerson c)
    {
        var aggregate = new PersonAggregate { AggregateIdentifier = c.AggregateIdentifier };
        aggregate.RegisterPerson(c.FirstName, c.LastName, DateTimeOffset.UtcNow);
        Commit(aggregate);
    }

    public void Handle(RenamePerson c)
    {
        var aggregate = _repository.Get<PersonAggregate>(c.AggregateIdentifier);
        aggregate.RenamePerson(c.FirstName, c.LastName);
        Commit(aggregate);
    }
}

讀端(Read Side)

查詢也是POCO類,使其輕量且易於序列化。

public class PersonSummary
{
    public Guid TenantIdentifier { get; set; }

    public Guid PersonIdentifier { get; set; }
    public string PersonName { get; set; }
    public DateTimeOffset PersonRegistered { get; set; }

    public int OpenAccountCount { get; set; }
    public decimal TotalAccountBalance { get; set; }
}

事件處理程序方法的註冊在事件訂閱者類的構造函數中也很明顯

public class PersonEventSubscriber
{
    private readonly IQueryStore _store;

    public PersonEventSubscriber(IEventQueue queue, IQueryStore store)
    {
        _store = store;

        queue.Subscribe<PersonRegistered>(Handle);
        queue.Subscribe<PersonRenamed>(Handle);
    }

    public void Handle(PersonRegistered c)
    {
        _store.InsertPerson(c.IdentityTenant, c.AggregateIdentifier, 
                            c.FirstName + " " + c.LastName, c.Registered);
    }

    public void Handle(PersonRenamed c)
    {
        _store.UpdatePersonName(c.AggregateIdentifier, c.FirstName + " " + c.LastName);
    }
}

領域

領域僅包含聚合和事件。再次,您將看到這裏的參考列表儘可能裸機:

每個聚合根類都包含一個函數,用於接受其更改狀態請求:

public class PersonAggregate : AggregateRoot
{
    public override AggregateState CreateState() => new Person();

    public void RegisterPerson(string firstName, string lastName, DateTimeOffset registered)
    {
        // 1. Validate command
        // Omitted for the sake of brevity.

        // 2. Validate domain.
        // Omitted for the sake of brevity.

        // 3. Apply change to aggregate state.
        var e = new PersonRegistered(firstName, lastName, registered);
        Apply(e);
    }

    public void RenamePerson(string firstName, string lastName)
    {
        var e = new PersonRenamed(firstName, lastName);
        Apply(e);
    }
}

注意,聚合狀態是在與聚合根分開的類中實現的。

這使得序列化和快照更易於管理,並有助於整體可讀性,因爲它在命令相關功能和事件相關功能之間進行了更強的劃分:

public class Person : AggregateState
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTimeOffset Registered { get; set; }

    public void When(PersonRegistered @event)
    {
        FirstName = @event.FirstName;
        LastName = @event.LastName;
        Registered = @event.Registered;
    }

    public void When(PersonRenamed @event)
    {
        FirstName = @event.FirstName;
        LastName = @event.LastName;
    }
}

事件(如命令和查詢)是輕量級的POCO類:

public class PersonRenamed : Event
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public PersonRenamed(string first, string last) { FirstName = first; LastName = last; }
}

持久性

在持久層,我們開始看到對外部第三方組件的更多依賴關係。例如,在這裏我們依靠:

該項目中的源代碼實現了標準的常規數據訪問層,並且在這一層中應該沒有什麼是新的,特別是創新的,或任何有經驗的開發人員都感到驚訝的,因此不需要進行特殊討論。

CQRS + ES主幹

女士們,先生們,終於(漫長)終於來到了您一直在等待的夜晚:Timeline程序集實際上實現了CQRS + ES模式,這使得上述所有事情成爲可能。

有趣的是...現在我們已經到了基本要點,剩下的謎團應該很少了。

您會注意到的第一件事是Timeline程序集依賴於外部第三方組件(顯然,.NET Framework本身除外)。

命令

這裏只有幾件事要注意。

如您所料,Command基類包含用於聚合標識符和版本號的屬性。它還包含用於租戶和發送命令的用戶身份的屬性。

/// <summary>
/// Defines the base class for all commands.
/// </summary>
/// <remarks>
/// A command is a request to change the domain. It is always are named with a verb in 
/// the imperative mood, such as Confirm Order. Unlike an event, a command is not a 
/// statement of fact; it is only a request, and thus may be refused. Commands are
/// immutable because their expected usage is to be sent directly to the domain model for 
/// processing. They do not need to change during their projected lifetime.
/// </remarks>
public class Command : ICommand
{
    public Guid AggregateIdentifier { get; set; }
    public int? ExpectedVersion { get; set; }

    public Guid IdentityTenant { get; set; }
    public Guid IdentityUser { get; set; }

    public Guid CommandIdentifier { get; set; }
    public Command() { CommandIdentifier = Guid.NewGuid(); }
}

CommandQueue實現了ICommandQueue接口,該接口定義了一組用於註冊訂閱者和覆蓋以及發送和調度命令的方法。您可以將其視爲命令的服務總線

事件

Event基類包含用於集合標識符和版本號的屬性,以及用於爲其發起/發佈事件的租戶和用戶的標識的屬性。這樣可以確保每個事件日誌條目都與特定的租戶和用戶相關聯。

您可以將其EventQueue視爲事件的服務總線。

聚合

AggregateState類只有一點點黑魔法。Apply方法使用反射來確定將事件應用於聚合狀態時要調用的方法。我不是特別喜歡這種方法,但是我找不到任何避免方法。幸運的是,這些代碼易於閱讀和理解:

/// <summary>
/// Represents the state (data) of an aggregate. A derived class should be a POCO
/// (DTO/Packet) that includes a When method for each event type that changes its
/// property values. Ideally, the property values for an instance of  this class 
/// should be modified only through its When methods.
/// </summary>
public abstract class AggregateState
{
    public void Apply(IEvent @event)
    {
        var when = GetType().GetMethod("When", new[] { @event.GetType() });

        if (when == null)
            throw new MethodNotFoundException(GetType(), "When", @event.GetType());

        when.Invoke(this, new object[] { @event });
    }
}

快照

實現快照的源代碼比我最初啓動該項目時想象的更加整潔和簡單。邏輯有些複雜,但是Snapshots命名空間中只有240行代碼,因此在此不再贅述。

指標

我將以一些基本指標結束本文。(稍後再介紹。)

這是NDepend根據Timeline程序集生成的分析報告:

如您所見,源代碼並不完美,但確實獲得了“A”級評級,技術債務估計僅爲1.3%。在我撰寫本文時,該項目也非常緊湊,只有439行代碼。

注意NDepend 從程序集.pdb符號文件中每個方法的序列點數中計算代碼行(LOCVisual StudioLOC的計數不同;在Timeline項目上,它報告了1,916行源代碼,以及277行可執行代碼。

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