1. 程式人生 > >CQRS之旅——旅程3(訂單和註冊限界上下文)

CQRS之旅——旅程3(訂單和註冊限界上下文)

旅程3:訂單和註冊限界上下文

CQRS之旅的第一站

“寓言家和鱷魚是一樣的,只是名字不同” --約翰·勞森

描述:

訂單和註冊上下文有一部分職責在會議預訂的過程中,在此上下文中,一個人(註冊者)可以購買特定會議的座位。還可以為已購買的座位分配與會者的名稱(這在第5章“準備釋出V1版本”中進行了描述)。

這是我們CQRS旅程的第一站,因此團隊決定實現一個核心的、但自包含的系統部分——訂單和註冊。對與會者來說,註冊過程必須儘可能地輕鬆。該流程必須確保業務客戶能夠預訂到儘可能多的座位,併為他們提供靈活的,在會議上為不同型別的座位設定價格的功能。

因為這是團隊處理的第一個限界上下文,所以我們還實現了系統的一些基礎設施來支援領域域的功能。包括命令和事件訊息匯流排以及聚合的持久化機制。

備註:本章描述的Contoso會議管理系統並不是該系統的最終版本。本此旅程描述的是一個過程,因此一些設計決策和實現細節在過程的後期會發生變化。這些變化將在後面的章節中描述。

在將來的某個旅程中,對這個限界上下文的改進計劃包括支援等待列表(如果沒有足夠的座位可用,對座位的請求將放在等待列表中),以及允許業務客戶為座位型別設定各種型別的折扣。

備註:在這個版本中沒有實現等待列表,但是社群成員正在開發這個特性和其他特性。任何帶外發布和更新都將在“CQRS之旅”網站上公佈。

本章的工作術語定義:

本章使用了一些術語,我們將在稍後定義它們。有關更多細節和可能的替代定義,請參閱參考指南中的“深入CQRS和ES”。

  • Command(命令):命令是要求系統執行更改系統狀態的操作。命令是必須服從(執行)的一種指令,例如:MakeSeatReservation。在這個限界上下文中,命令要麼來自使用者發起請求時的UI,要麼來自流程管理器(當流程管理器指示聚合執行某個操作時)。單個接收方處理一個命令。命令匯流排(command bus)傳輸命令,然後命令處理程式將這些命令傳送到聚合。傳送命令是一個沒有返回值的非同步操作。

    Gary(CQRS專家)發言:

    有一些討論是關於優化的可能性,這涉及到命令不同的定義,這些不同點是微小的。請參閱第6章“系統的版本控制”。

  • Event(事件):事件就是系統中發生的一些事情,通常是一個命令的結果。領域模型中的聚合會引發(raise)事件。多個事件訂閱者(subscribers)可以處理特定的事件。聚合將事件釋出到事件匯流排, 處理程式訂閱特定型別的事件,事件匯流排(event bus)將事件傳遞給訂閱者。在這個限界上下文中,唯一的訂閱者是流程管理器。

  • 流程管理器。在這個限界上下文中,流程管理器是一個協調領域域中聚合行為的類。流程管理器訂閱聚合引發的事件,然後遵循一組簡單的規則來確定傳送一個或一組命令。流程管理器不包含任何業務邏輯,它唯一的邏輯是確定下一個傳送的命令。流程管理器被實現為一個狀態機,因此當它響應一個事件時,除了傳送一個新命令外,還可以更改其內部狀態。

    Gregor Hohpe和Bobby Woolf合著的《Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions》(Addison-Wesley Professional, 2003)書中312頁講述了流程管理器實現模式。我們的流程管理器就是依照這個模式實現的。

    Markus(軟體開發人員)發言:

    對於剛接觸程式碼的人來說,跟蹤命令和事件在系統中的流動是很困難的。第4章“擴充套件和增強訂單和註冊有界上下文”中的“對測試的影響”一節,討論了怎樣可以幫助你搞清楚它們。

    在這個限界上下文中,流程管理器可以接收命令,也可以訂閱事件。

    Gary(CQRS專家)發言:

    在訂單和註冊限界上下文中,起初,提到流程管理器,團隊把它當作一個Saga(一種可用於處理事務的模式),要了解後來為什麼我們決定更改術語,請參閱本章後面的“模式和概念”一節。

    注:參考指南里包含了CQRS相關術語的附加定義和解釋。

領域定義(通用語言)

下面的列表定義了團隊在開發此訂單和註冊有界上下文時使用的關鍵領域相關術語。

  • 與會者:與會者是有權參加會議的人。與會者可以與系統互動,比如管理議程、列印徽標以及會後提供反饋。與會者也可以是不花錢參加會議的人,比如志願者、演講者或享受100%折扣的人。一位與會者可以有多種相關的與會者型別(如演講者、學生、志願者、track chair等等)。

  • 註冊者:註冊者是與系統互動下訂單併為這些訂單付款的人。註冊者還建立與訂單關聯的註冊。註冊者也可以是與會者。

  • 使用者:使用者是與會議相關的參會者、註冊者、演講者或志願者。每個使用者都有一個惟一的記錄定位器程式碼,使用者可以使用該程式碼訪問系統中特定於使用者的資訊。例如,註冊者可以使用記錄定位器程式碼訪問她的訂單,與會者可以使用記錄定位器程式碼訪問他的個性化會議議程。

    Carlos(領域專家)發言:

    我們刻意實現了一個記錄定位器機制,以通過該機制返回以前提交的訂單資料。這消除了使用者必須註冊,登入系統才能獲得訪問許可權的煩人要求。我們的客戶堅定要求這樣。

  • 座位分配:座位分配將與會者和按確定順序排列的座位相關聯。一個訂單可能有一個或多個與其相關的座位分配。

  • 訂單:當一位註冊者與系統互動時,系統建立一個訂單來管理預訂、付款和註冊。當註冊者已成功支付訂單項下的款項時,訂單將被確認。訂單包含一個或多個訂單項。

  • 訂單項:訂單項包含座位型別和數量,並與訂單關聯。訂單項有三種狀態:建立、保留或拒絕。訂單項最初處於建立狀態。如果系統保留了登記人要求的座位型別的座位數量,則訂單項處於保留狀態。如果系統無法保留登記人要求的座位型別的座位數量,則訂單項處於拒絕狀態。

  • 座位:一個座位代表著被允許參加一個會議或進入一個特定會議的權利,如雞尾酒會、教學或研討會。業務客戶可以更改每次會議的座位配額。業務客戶還可以更改每個小型會議的座位配額。

  • 預訂:預訂是一個或多個座位的臨時預訂。訂單程式將建立預訂。當註冊者開始訂購時,系統會根據註冊者要求的座位數量進行預訂。因此,其他註冊者無法預訂這些座位。預訂將保留n分鐘,在此期間,註冊者可以通過支付這些座位的費用來完成訂購過程。如果註冊者在n分鐘內沒有付款,系統將取消預訂,其他登記人可以預訂座位。

  • 座位可用性:每個會議都將追蹤每種型別座位的可用性。最初,所有的座位都可以預訂和購買。當一個座位被預訂時,該型別的可用座位數量將減少。如果系統取消預訂,則增加該型別的可用座位數量。業務客戶可以定義每種可用座位型別的初始數量,這是會議的一個特點。會議所有者可以根據不同的座位型別調整數量。

  • 會議網站:您可以使用唯一的URL訪問系統中定義的每個會議。註冊者可以從這個網站開始訂購過程。

這裡定義的每個術語都是通過開發團隊和領域專家之間的積極討論制定的。下面是開發人員和領域專家之間的一個示例對話,演示了團隊如何定義術語。

開發人員1:這裡是對與會者定義的初步嘗試。與會者是指花錢參加會議的人。與會者可以與系統互動,比如管理議程、列印徽標以及會後提供反饋。”

領域專家1:並不是所有與會者都會付費參加會議。例如,一些會議會有志願者,而且演講者通常不付錢。而且,在某些情況下,出席者可以獲得100%的折扣。

領域專家1:別忘了付費的不是與會者。這是由註冊者完成的。

開發者1:所以我們需要說與會者是被授權參加會議的人?

開發人員2:我們需要注意這裡的用詞。授權這個術語會讓一些人想到安全性、身份驗證和授權。

開發人員1:如何命名?

領域專家1:當系統執行諸如列印徽標之類的任務時,它需要知道徽標是用於哪種型別的與會者。例如,演講者、志願者、付費參與者等等。

開發人員1:現在我們有了這個定義,它捕獲了我們討論過的所有內容。與會者是有權參加會議的人。與會者可以與系統互動,比如管理議程、列印徽標以及會後提供反饋。與會者也可以是不花錢參加會議的人,比如志願者、演講者或享受100%折扣的人。與會者可以有多種相關的與會者型別(演講者、學生、志願者、track chair等等)。

建立訂單的需求

一位註冊者是指在會議上預訂座位並支付(訂座)費用的人。訂購過程分為兩個階段:首先,註冊者預訂一些座位,然後支付座位的費用來確認預訂。如果註冊者沒有完成付款,預定的座位將在一段固定時間後過期,系統將為其他註冊者預留座位。

下圖展示了了團隊用於探索座位預定的一些早期UI原型圖。

訂單功能的使用者介面原型圖

這些UI原型圖在幾個方面幫助了團隊,允許他們:

  • 將核心團隊對系統的願景傳達給第三方公司獨立團隊中的UI設計師。
  • 向開發人員傳達領域專家的知識。
  • 使用通用語言提煉,細化術語的定義。
  • 探索“如果發生XXX又怎樣XXX”的場景,研究替代方案。
  • 構建基礎的系統驗收測試套件。

架構

此應用程式設計為部署到Microsoft Azure。到旅程的這個階段,應用程式將包含一個ASP.NET MVC web應用程式和訊息處理程式以及領域模型物件。應用程式使用Azure SQL Database例項進行資料儲存,讀和寫兩者都包括。應用程式使用Azure Service Bus來進行訊息傳遞。

譯者注:鑑於Azure國內版瘸腿,國際版速度奇慢。而且都價格喜人。後續的實戰中,架構會根據當前的實際情況進行調整。主要是學習原文的思想。

在研究和測試解決方案時,可以在本地執行它,可以使用Azure compute emulator,也可以直接執行MVC web應用程式,並執行承載訊息處理程式和領域域物件的控制檯應用程式。在本地執行應用程式時,可以使用本地SQL Server Express資料庫,並使用一個在SQL Server Express資料庫實現的簡單的訊息傳遞基礎設施。

有關執行應用程式的選項的更多資訊,請參見附錄1“釋出說明”。

Gary(CQRS專家)發言:

CQRS模式的一個經常被援引的優勢是,它使您能夠獨立的伸縮應用程式的讀端和寫端,以支援不同的使用模式。然而,在這個限界上下文中,來自UI的讀操作的數量不太可能超過寫操作的數量:這個限界上下文中關注的是建立訂單的註冊者。因此,讀端和寫端將部署到同一個Azure工作者角色,而不是部署到兩個可以獨立伸縮的獨立工作者角色。

模式和概念

為了保持簡單,團隊決定在不使用事件源(Event Sourcing)的情況下先實現第一個限界上下文。當然,他們也確定,如果將來確定事件源能為這個限界上下文帶來特定的好處,那麼他們將重新考慮這個決定。

備註:有關Event Sourcing如何與CQRS模式關聯的描述,請參閱參考指南中的“介紹事件源”。

小組進行的一項重要討論是選擇它們將實現的聚合和實體。以下來自團隊白板的圖片說明了他們最初的一些想法,以及他們通過一個替代方法(一個真實的會議座位預定場景)來嘗試理解這裡有什麼優缺點。

“我認為開發人員需要收穫一個觀念,那就是把物件的屬性儲存在關係型資料庫中是不重要的。教會他們避免將領域模型作為關係儲存,我認為這樣將會更容易介紹和理解領域驅動設計(DDD)和CQRS” --Josh Elster, CQRS Advisors Mail List

Gary(CQRS專家)發言:

這些圖刻意排除了系統如何通過命令和事件處理程式處理命令和事件的細節。這些圖主要關注領域中的聚合之間的邏輯關係。

此場景考慮當註冊者試圖在會議上預訂多個座位時會發生什麼。系統必須:

  1. 檢查是否有足夠的座位。
  2. 記錄註冊詳情。
  3. 更新會議預訂的座位總數。
我們刻意保持場景簡單,以避免在團隊檢查其他方案時分心。這些示例沒有描述這個限界上下文的最終實現。

團隊考慮的第一種方法(如下圖所示)是使用兩個分開的聚合。

方法1:兩個分開的聚合

圖中的數字對應於以下步驟:

  1. 從UI傳送一個命令用來註冊參會者X和Y到157號會議,這個命令被路由到一個新的訂單(Order)聚合。
  2. 訂單聚合引發(Raise)一個事件,該事件報告已經建立了一個訂單。這個事件被路由到可用座位(SeatsAvailability)聚合。
  3. ID為157的可用座位(SeatsAvailability)聚合是從資料庫中取回還原(re-hydrated)的。
  4. 可用座位(SeatsAvailability)聚合更新它自己的預定座位總數。
  5. 更新後的可用座位(SeatsAvailability)聚合被持久化到資料庫中。
  6. ID為4239的新的訂單聚合被持久化到資料庫中。

    Markus(軟體開發人員)發言:

    術語re-hydrated是指從資料庫中反序列化聚合例項的過程。

    Jana(軟體架構師)發言:

    你可以考慮使用Memento模式來處理持久化和rehydration。



團隊考慮的第二種方法(如下圖所示)是使用單個聚合來代替兩個聚合。

方法2:單個聚合

圖中的數字對應於以下步驟:

  1. 從UI傳送一個命令用來註冊參會者X和Y到157號會議,這個命令被路由到會議(Conference)聚合,聚合ID為157。
  2. ID為157的會議(Conference)聚合從資料庫中取回還原(rehydrated)。
  3. 訂單(Order)實體將校驗本次預訂(它將查詢可用座位(SeatsAvailability)實體以檢視是否還有足夠的座位),然後呼叫方法更新在會議(Conference)實體上預訂的座位數量。
  4. 可用座位(SeatsAvailability)實體更新自己已預訂的座位總數。
  5. 更新後的會議(Conference)聚合的被持久化到資料庫中。



團隊考慮的第三種方法(如下圖所示)是使用流程管理器來協調兩個聚合之間的互動。

方法3:使用一個流程管理器

圖中的數字對應於以下步驟:

  1. 從UI傳送一個命令用來註冊參會者X和Y到157號會議,這個命令被路由到訂單(Order)聚合。
  2. 這個新的訂單(Order)聚合,ID為4239,被持久化到資料庫中
  3. 訂單(Order)聚合引發(Raise)一個事件,這個事件將被RegistrationProcessManager類處理
  4. RegistrationProcessManager類將傳送一個命令到ID為157的可用座位(SeatsAvailability)聚合
  5. 這個可用座位(SeatsAvailability)聚合從資料庫中取回還原(rehydrated)
  6. 可用座位(SeatsAvailability)聚合更新自己的預定座位總數,然後持久化回資料庫

    Gary(CQRS專家)發言:

    流程管理器或Saga,起初,團隊將RegistrationProcessManager類看做一個Saga,但是,當他們重新閱讀Hector Garcia-Molina和Kenneth Salem合著的《Saga》一文中對“Saga”的最初定義後,他們修改了自己的決定。主要原因是預定流程並不包含明確的補償步驟,所以並不需要一個長生命週期的事務。

有關流程管理器和Saga的更多資訊,請參見參考指南中的第6章“A Saga on Sagas”

團隊還明確了下列問題:

  • 在哪裡驗證是否有足夠的座位可供註冊?在訂單(Order)聚合裡還是可用座位(SeatsAvailability)聚合裡?
  • 事務邊界在哪裡?
  • 當多個註冊者試圖同時下訂單時,該模型如何處理併發問題?
  • 聚合根是什麼?

驗證

在登記人可以預訂座位之前,系統必須檢查是否有足夠的座位。雖然UI中的邏輯可以在傳送命令之前驗證是否有足夠的可用座位,但是領域中的業務邏輯也必須執行檢查。這是因為在UI執行驗證之後到系統將命令傳送到領域中的聚合時,狀態可能會發生變化。

Jana(軟體架構師)發言:

當我們在這裡談UI驗證時,我們指的是模型-檢視-控制器(MVC)執行的驗證,而不是瀏覽器前端。

在第一個模型中,驗證要麼在訂單(Order)聚合裡,要麼在可用座位(SeatsAvailability)聚合裡。如果是前者,則訂單(Order)聚合必須在預訂之前和引發事件之前從可用座位(SeatsAvailability)聚合中檢查當前的座位可用性。如果是後者,那麼可用座位(SeatsAvailability)聚合必須以某種方式通知訂單(Order)聚合它不能預訂座位,並且訂單(Order)聚合必須撤消(或彌補)它迄今為止完成的任何工作。

Beth(業務經理)發言:

撤銷只是現實生活中發生的許多彌補操作之一,彌補操作不僅僅侷限於系統內,甚至可以是系統外的人工操作,例如:一個Contoso的職員或客戶經理打電話給註冊者們,告訴他們系統發生了一個錯誤,請他們忽略Contoso發來的最終確認郵件。

第二個模型的行為類似,除了訂單(Order)聚合和可用座位(SeatsAvailability)聚合是在會議(Conference)聚合裡協作的。

在第三個模型中,使用了流程管理器,聚合通過流程管理器互相傳遞關於註冊者是否可以在當前時間進行預訂的訊息。

所有這三個模型都需要實體就驗證過程進行通訊,但是與流程管理器進行通訊的第三個模型看起來比其他兩個模型更復雜一些。

事務邊界

在DDD中,聚合表示一致性邊界。因此,具有兩個聚合的第一個模型,級別具有兩個聚合和一個流程管理器的第三個模型將涉及兩個事務:一個在系統持久化新的訂單(Order)聚合時,另一個在系統持久化更新的可用座位(SeatsAvailability)聚合時。

備註:術語“一致性邊界”指的是你可以假設所有元素始終保持一致的邊界。

為了確保註冊者建立訂單時系統的一致性,兩個事務都必須成功。為了保證這一點,我們必須採取步驟,通過確保基礎設施可靠地向聚合傳遞訊息,從而確保系統最終是一致的。

在第二個模型中,使用單一聚合,當註冊者下訂單時,我們只有一個事務。這似乎是三種模型裡最簡單的一種。

併發

註冊過程發生在多使用者環境中,許多註冊者可以嘗試同時購買座位。團隊決定使用預約模式來解決註冊過程中的併發問題。在這種情況下,這意味著為註冊者最初保留了座位(然後其他註冊者無法使用這些座位)。如註冊者在超時時間內完成付款,系統保留預訂,否則,系統將取消預訂。

此預訂系統引入了對附加訊息型別的需求,例如,報告註冊者已付款的事件,或報告超時發生的事件。

這個超時還要求系統在某個地方新增一個計時器來跟蹤預訂何時過期。

對這種使用訊息序列和需要計時器的複雜模型,最好的辦法就是使用流程管理器。

聚合和聚合根

在訂單(Order)聚合和可用座位(SeatsAvailability)聚合這種兩個聚合裡,團隊很容易識別出組成聚合的實體和聚合根。在單一聚合的模型中,選擇不是很明確:通過SeatsAvailability實體訪問Order,或者通過Order實體訪問SeatsAvailability,這似乎都不太自然。建立作為聚合根的新實體似乎沒有必要。

團隊決定採用包含流程管理器的模型,因為它提供了在這個限界上下文中處理併發需求的最佳方法。



實現細節

本節介紹訂單和註冊限界上下文中實現的一些重要特性。您或許需要獲取一份程式碼的拷貝,這樣就可以跟隨我們的腳步。您可以從Download center下載它,或者在github:mspnp/cqrs-journey-code上得到它

不要期望程式碼示例與參考實現中的程式碼完全匹配。本章描述了CQRS過程中的一個步驟,隨著我們瞭解更多並重構程式碼,實現可能會發生變化。

高層架構

正如我們在上一節中描述的,團隊最初決定使用CQRS模式在會議管理系統中實現預訂,但不使用事件源(Event Sourcing)。下圖顯示了實現的關鍵元素:MVC web應用程式、使用Azure SQL資料庫例項實現的資料儲存、讀寫模型和一些基礎設施元件。

備註:我們將在本節稍後的部分描述讀寫模型中發生的事情。

註冊限界上下文的高層架構

下面的部分與上圖中的數字相關,並提供了關於體系結構中各個元素的更多細節。

  1. 使用讀模型(Read Model)查詢資料

    ConferenceController類包含一個名為Display的action,該action建立一個包含特定會議資訊的檢視(View)。這個控制器類使用以下程式碼從讀模型裡查詢:
    ```C#
    public ActionResult Display(string conferenceCode)
    {
    var conference = this.GetConference(conferenceCode);

    return View(conference);
    }

private Conference.Web.Public.Models.Conference GetConference(string conferenceCode)
{
var repo = this.repositoryFactory();
using (repo as IDisposable)
{
var conference = repo.Query

    var conferenceModel =
        new Conference.Web.Public.Models.Conference { Code = conference.Code, Name = conference.Name, Description = conference.Description };

    return conferenceModel;
}

}
```
讀模型(Read Model)從資料儲存中檢索資訊,並使用資料傳輸物件(DTO)將資訊返回給控制器。

  1. 發出命令

    Web應用通過命令匯流排(Command Bus)向寫模型(Write Model)傳送命令。命令匯流排是系統中的可靠訊息傳遞基礎設施元件。在這個場景中,它非同步將命令傳送給接受者,並且只發送一次。

    RegistrationController類可以向寫模型(Write Model)傳送RegisterToConference命令,此命令傳送一個請求,請求在會議上註冊一個或多個席位,然後,RegistrationController類輪詢讀模型(Read Model),以發現註冊請求是否成功。參見第6節:“輪詢讀模型(Read Model)”以獲得更多細節。

    下面的程式碼示例展示了RegistrationController如何傳送RegisterToConference命令:

    var viewModel = this.UpdateViewModel(conferenceCode, contentModel);
    
    var command =
        new RegisterToConference
        {
            OrderId = viewModel.Id,
            ConferenceId = viewModel.ConferenceId,
            Seats = viewModel.Items.Select(x => new RegisterToConference.Seat { SeatTypeId = x.SeatTypeId, Quantity = x.Quantity }).ToList()
        };
    
    this.commandBus.Send(command);

    備註:所有的命令都是非同步傳送的,不需要等待返回。

  2. 處理命令

    命令處理程式在命令總線上註冊,然後,命令匯流排可以將命令轉發給正確的處理程式。

    OrderCommandHandler類處理從UI傳送的RegisterToConference命令。通常,處理程式負責呼叫領域裡的某些業務邏輯,並將某些狀態更新持久化到資料儲存中。

    下面的程式碼示例展示了OrderCommandHandler類如何處理RegisterToConference命令:
    ```c#
    public void Handle(RegisterToConference command)
    {
    var repository = this.repositoryFactory();

     using (repository as IDisposable)
     {
         var seats = command.Seats.Select(t => new OrderItem(t.SeatTypeId, t.Quantity)).ToList();
    
         var order = new Order(command.OrderId, Guid.NewGuid(), command.ConferenceId, seats);
    
         repository.Save(order);
     }

    }
    ```

  3. 在領域中初始化業務邏輯

    在前面的程式碼示例中,OrderCommandHandler類建立了一個新的Order例項。Order物件是一個聚合根,它的建構函式包含初始化領域邏輯的程式碼。有關此聚合根執行哪些操作的詳細資訊,請參閱下面的“在寫模型內部”一節。

  4. 把改動持久化

    在前面的程式碼示例中,命令處理程式通過呼叫repository類中的Save方法來持久化一個新的訂單(Order)聚合。這個Save方法還將在命令匯流排(Command Bus)上釋出訂單(Order)聚合引發的各種事件。

  5. 輪詢讀模型(Read Model)

    要向用戶提供反饋,UI端必須能夠檢查RegisterToConference命令是否成功。與系統中的所有命令一樣,此命令非同步執行,不返回結果。UI端通過輪詢讀模型(Read Model)來檢查命令是否成功。

    下面的程式碼示例展示了一個初始實現,其中RegistrationController類裡的WaitUntilUpdated方法輪詢讀模型,直到它發現訂單已經被持久化成功或超時。
    ```c#
    [HttpPost]
    public ActionResult StartRegistration(string conferenceCode, OrderViewModel contentModel)
    {
    ...

     this.commandBus.Send(command);
    
     var draftOrder = this.WaitUntilUpdated(viewModel.Id);
    
     if (draftOrder != null)
     {
         if (draftOrder.State == "Booked")
         {
             return RedirectToAction("SpecifyPaymentDetails", new { conferenceCode = conferenceCode, orderId = viewModel.Id });
         }
         else if (draftOrder.State == "Rejected")
         {
             return View("ReservationRejected", viewModel);
         }
     }
    
     return View("ReservationUnknown", viewModel);

    }
    後來,團隊用Post-Redirect-Get模式的實現替換了這種檢查系統是否儲存訂單的機制。下面的程式碼示例展示了StartRegistration方法的新版本。 > 備註:更多關於Post-Redirect-Get模式的資訊,請在Wikipedia檢視[Post/Redirect/Get]()c#
    [HttpPost]
    public ActionResult StartRegistration(string conferenceCode, OrderViewModel contentModel)
    {
    ...

     this.commandBus.Send(command);
    
     return RedirectToAction("SpecifyRegistrantDetails", new { conferenceCode = conferenceCode, orderId = command.Id });

    }
    新的StartRegistration action方法現在傳送命令後立即重定向到SpecifyRegistrantDetails action。下面的程式碼示例顯示了SpecifyRegistrantDetails action如何在返回檢視之前輪詢資料庫中的訂單。c#
    [HttpGet]
    public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId)
    {
    var draftOrder = this.WaitUntilUpdated(orderId);

     ...

    }
    ```
    新方法的優點:使用Post-Redirect-Get模式而不是StartRegistration post action,能讓瀏覽器的“前進”和“後退”導航按鈕工作的更好,並在控制器開始輪詢之前給命令處理程式更多時間來處理命令。

    譯者注:此文章編寫時間較早。文中的UI端指的是asp.net mvc web應用程式。現在流行的方式是前後端分離。後端入口服務一般是一個web api程式。而且基於輪詢的方案也不太理想。可以在web api端通過訊息匯流排訂閱資料持久化的各類事件,當在資料儲存層引發這些事件時。web api中的事件處理程式可以接收到檢視模型改變的資料。再將資料通過SignalR或web socket方式推送至前端。

在寫模型內部

聚合

下面是訂單(Order)聚合的程式碼示例:

public class Order : IAggregateRoot, IEventPublisher
{
    public static class States
    {
        public const int Created = 0;
        public const int Booked = 1;
        public const int Rejected = 2;
        public const int Confirmed = 3;
    }

    private List<IEvent> events = new List<IEvent>();

    ...

    public Guid Id { get; private set; }

    public Guid UserId { get; private set; }

    public Guid ConferenceId { get; private set; }

    public virtual ObservableCollection<TicketOrderLine> Lines { get; private set; }

    public int State { get; private set; }

    public IEnumerable<IEvent> Events
    {
        get { return this.events; }
    }

    public void MarkAsBooked()
    {
        if (this.State != States.Created)
            throw new InvalidOperationException();

        this.State = States.Booked;
    }

    public void Reject()
    {
        if (this.State != States.Created)
            throw new InvalidOperationException();

        this.State = States.Rejected;
    }
}

注意類的屬性沒有全被標記為virtual。在這個類的原始版本中,屬性Id、UserId、ConferenceId和State都被標記為virtual。下面是兩個開發人員之間的討論:

  • 開發人員1:我確信你不應該使屬性都成為虛擬的,除非物件關係對映(ORM)層需要。如果只是出於測試目的,實體和聚合根永遠不能用mock測試。如果你需要mock來測試實體和聚合根,那麼很明顯,設計中有問題。

  • 開發人員2:在預設情況下,我更喜歡開放和可擴充套件性。你永遠不知道將來會出現什麼需求,把屬性標記為virtual並不費什麼事。這當然是有爭議的,在.net中有點不標準。這樣吧,我們可能只需要給延遲載入的集合屬性標記為virtual。

  • 開發人員1:使用CQRS模式通常會使延遲載入的效果消失,所以你也不應該需要它。這樣會讓程式碼更簡單。

  • 開發人員2:CQRS並沒有說要使用事件源(Event Sourcing),但如果使用包含物件的聚合根,無論如何都需要它,對嗎?

  • 開發人員1:這不是關於Event Sourcing的,而是關於DDD的。當聚合邊界正確時,你就不需要延遲載入。

  • 開發人員2:需要明確的是,聚合邊界在這裡是為了將應該一起更改的內容分組,以保持一致性。延遲載入就意味著已經分組在一起的東西其實並不需要分組。

  • 開發人員1:我同意。我發現在命令端延遲載入意味著建模錯誤。如果我不需要命令端的值,那麼它就不應該在那裡。此外,我不喜歡virtual,除非它們有特定的用途(或者物件關係對映(ORM)工具的需求)。在我看來,這違反了開閉原則:你以各種可能有意也可能無意的方式敞開了自己接受修改的大門,而且即使發生了什麼影響,也可能無法立即發現。

    譯者注:ORM要求屬性必須為虛,Java裡著名的Hibernate就是這麼搞得,所以NHibernate也是這樣的。

  • 開發人員2:模型中的訂單聚合有一個訂單項列表。確定我們不需要載入就能把它標記為已訂好的嗎?我們建立的模型有問題嗎?

  • 開發人員1:OrderItems列表很長嗎?如果是,那麼建模可能是錯誤的,因為你並不一定需要那個級別的事務。通常,較晚的來回獲取和更新OrderItems的成本可能比預先載入它們要高,你應該評估列表的通常大小,並進行一些效能度量。首先讓它變得簡單,其次如果需要的話進行優化。

    -感謝Jeremie Chassaing和Craig Wilson

聚合和流程管理器

下圖展示了寫模型(Write Model)中存在的物件。有兩個聚合,Order和SeatsAvailability,每個都包含多個實體型別。此外,還有一個RegistrationProcessManager類來管理聚合之間的互動。

下圖中的表展示了流程管理器在給定當前狀態和特定型別訊息時的行為。

寫模型中的領域物件

註冊會議的過程從UI傳送RegisterToConference命令開始。基礎設施將此命令傳遞給訂單(Order)聚合。這個命令的結果是:系統建立了一個新的訂單(Order)聚合例項,並且這個新例項引發了一個OrderOrdered事件。訂單(Order)聚合類中的建構函式中的以下程式碼示例展示了這種情況。請注意系統如何使用Guid來標識不同的實體。

public Order(Guid id, Guid userId, Guid conferenceId, IEnumerable<OrderItem> lines)
{
    this.Id = id;
    this.UserId = userId;
    this.ConferenceId = conferenceId;
    this.Lines = new ObservableCollection<OrderItem>(items);

    this.events.Add(
        new OrderPlaced
        {
            OrderId = this.Id,
            ConferenceId = this.ConferenceId,
            UserId = this.UserId,
            Seats = this.Lines.Select(x => new OrderPlaced.Seat { SeatTypeId = x.SeatTypeId, Quantity = x.Quantity }).ToArray()
        });
}

備註:要檢視基礎設施元件如何傳遞命令和事件,在後面的圖裡有。

系統建立一個新的RegistrationProcessManager例項來管理新訂單。下面來自RegistrationProcessManager類的程式碼示例展示了流程管理器如何處理事件。

public void Handle(OrderPlaced message)
{
    if (this.State == ProcessState.NotStarted)
    {
        this.OrderId = message.OrderId;
        this.ReservationId = Guid.NewGuid();
        this.State = ProcessState.AwaitingReservationConfirmation;

        this.AddCommand(
            new MakeSeatReservation
            {
                ConferenceId = message.ConferenceId,
                ReservationId = this.ReservationId,
                NumberOfSeats = message.Items.Sum(x => x.Quantity)
            });
    }
    else
    {
        throw new InvalidOperationException();
    }
}

程式碼示例顯示流程管理器如何更改其狀態,併發送一個由SeatsAvailability聚合處理的新的MakeSeatReservation命令。程式碼示例還演示瞭如何將流程管理器實現為接收訊息、更改其狀態併發送新訊息的狀態機。

Markus(軟體開發人員)發言:

注意我們生成一個新的全域性惟一識別符號(GUID)來標識新的預訂。我們使用這些Guid將訊息關聯到正確的流程管理器例項和聚合例項。

當SeatsAvailability聚合接收到MakeReservation命令時,如果有足夠的可用座位,它將進行預訂。下面的程式碼示例顯示了SeatsAvailability類如何根據是否有足夠的座位引發不同的事件。

public void MakeReservation(Guid reservationId, int numberOfSeats)
{
    if (numberOfSeats > this.RemainingSeats)
    {
        this.events.Add(new ReservationRejected { ReservationId = reservationId, ConferenceId = this.Id });
    }
    else
    {
        this.PendingReservations.Add(new Reservation(reservationId, numberOfSeats));
        this.RemainingSeats -= numberOfSeats;
        this.events.Add(new ReservationAccepted { ReservationId = reservationId, ConferenceId = this.Id });
    }
}

流程管理器RegistrationProcessManager類處理預訂的接收和拒絕事件。這是一個臨時的座位預訂,讓使用者有機會進行支付。流程管理器在購買完成或預訂超時過期時釋放預訂。下面的程式碼示例顯示流程管理器如何處理這兩種事件。

public void Handle(ReservationAccepted message)
{
    if (this.State == ProcessState.AwaitingReservationConfirmation)
    {
        this.State = ProcessState.AwaitingPayment;

        this.AddCommand(new MarkOrderAsBooked { OrderId = this.OrderId });
        this.commands.Add(
            new Envelope<ICommand>(new ExpireOrder { OrderId = this.OrderId, ConferenceId = message.ConferenceId })
            {
                Delay = TimeSpan.FromMinutes(15),
            });
    }
    else
    {
        throw new InvalidOperationException();
    }
}

public void Handle(ReservationRejected message)
{
    if (this.State == ProcessState.AwaitingReservationConfirmation)
    {
        this.State = ProcessState.Completed;
        this.AddCommand(new RejectOrder { OrderId = this.OrderId });
    }
    else
    {
        throw new InvalidOperationException();
    }
}

如果預訂被接受,流程管理器將通過向自身傳送ExpireOrder命令啟動計時器,並向訂單(Order)聚合傳送MarkOrderAsBooked命令。否則,它將向訂單(Order)聚合傳送一條ReservationRejected訊息。

前面的程式碼示例顯示了流程管理器如何傳送ExpireOrder命令。基礎設施負責將訊息儲存在佇列中,等待15分鐘的延遲。

您可以借鑑SeatsAvailability和RegistrationProcessManager類裡的程式碼,以檢視其他訊息處理程式是如何實現的。它們都遵循相同的模式:接收訊息、執行一些邏輯併發送訊息。

Jana(軟體架構師)發言:

本章展示的程式碼示例都來自會議管理系統的早期版本。下一章將展示當團隊持續探索該領域以及學習了更多CQRS模式的知識之後,設計和實現是如何隨之發展的。

基礎設施

下面的序列圖展示了基礎設施元件如何與領域物件互動訊息的。

當UI中的MVC控制器使用命令匯流排傳送訊息時,典型的互動就開始了。訊息傳送方非同步呼叫命令總線上的Send方法。然後命令匯流排儲存訊息,直到訊息接收者收到訊息並將其轉發給適當的處理程式。系統包含許多命令處理程式,這些命令處理程式向命令匯流排註冊,以處理特定型別的命令。例如,OrderCommandHandler類為RegisterToConference、Markorderasbooking和RejectOrder命令定義了處理程式方法。下面的程式碼示例顯示了Markorderasbooking命令的處理程式方法。處理程式方法負責尋找正確的聚合例項,呼叫該例項上的方法,然後儲存該例項。

public void Handle(MarkOrderAsBooked command)
{
    var repository = this.repositoryFactory();

    using (repository as IDisposable)
    {
        var order = repository.Find<Order>(command.OrderId);

        if (order != null)
        {
            order.MarkAsBooked();
            repository.Save(order);
        }
    }
}

實現IRepository介面的類負責在事件總線上持久化聚合物件併發布聚合裡引發的任何事件,所有的這些都是事務的一部分。

Carlos(領域專家)發言:

稍後,當團隊試圖使用Azure服務匯流排作為訊息傳遞基礎設施時,發現了一個問題。Azure服務匯流排不支援帶有資料庫的分散式事務。有關這個問題的討論,請參閱第5章“準備發行V1版本”。

在註冊限界上下文中,惟一的事件訂閱者是RegistrationProcessManager類。它的Router訂閱者從訂閱事件匯流排訂閱,來處理特定的事件,下面的程式碼示例展示了RegistrationProcessManager類。

我們使用了術語Handler來指代處理命令和事件並將它們轉發給聚合例項的類,使用術語Router來指代處理事件和命令並將它們轉發給流程管理器例項的類。
public void Handle(ReservationAccepted @event)
{
    var repo = this.repositoryFactory.Invoke();
    using (repo as IDisposable)
    {
        lock (lockObject)
        {
            var process = repo.Find<RegistrationProcessManager>(@event.ReservationId);
            process.Handle(@event);

            repo.Save(process);
        }
    }
}

通常,事件處理程式方法獲取流程管理器例項,將事件傳遞給流程管理器,然後儲存流程管理器例項。在本例中,IRepository例項負責持久化流程管理器例項,並負責將任何命令從流程管理器例項傳送到命令匯流排。

使用Azure服務匯流排(Service Bus)

為了傳輸命令和事件,團隊決定使用Azure服務匯流排來提供底層訊息傳遞基礎設施。本節描述了系統如何使用Azure服務匯流排,以及團隊在設計階段考慮的一些替代方案和權衡。

Jana(軟體架構師)發言:

Contoso的開發團隊決定使用Azure服務匯流排,因為它為會議管理系統中的訊息傳遞場景提供了開箱即用的支援。這將最小化團隊需要編寫的程式碼量,並提供健壯的、可伸縮的訊息傳遞基礎設施。該團隊計劃使用重複訊息檢測和保證訊息排序等功能。要了解Azure服務匯流排和Azure佇列之間的區別,請參閱MSDN上的“Microsoft Azure Queues and Microsoft Azure Service Bus Queues - Compared and Contrasted”。

下圖顯示了命令和事件訊息如何在系統中流動。MVC控制器和領域物件使用CommandBus和EventBus例項將BrokeredMessage訊息傳送給Azure服務匯流排中的兩個Topic之一。接收訊息時,訊息處理類是CommandProcessor和EventProcessor例項,CommandProcessor類確定哪個處理程式應該接收命令訊息,EventProcessor類確定哪些處理程式應該接收事件訊息。後者使用SubscriptionReceiver類從Topic獲取事件。處理程式例項負責呼叫領域物件上的方法。

Azure服務匯流排的Topic可以有多個訂閱者。Azure服務匯流排將傳送到Topic的訊息傳遞給它的所有訂閱者。因此,一條訊息可以有多個接收者。

在最初的實現中,CommandBus和EventBus類非常相似。Send方法和Publish方法之間的惟一區別是,Send方法期望訊息被包裝在Envelope類中。Envelope類允許傳送方指定訊息傳遞的時間延遲。

事件可以有多個接收者。在上圖的示例中,ReservationRejected事件被髮送到RegistrationProcessManager、WaitListProcessManager和另一個目的地。EventProcessor類通過檢查已註冊的處理程式列表來標識收到事件的處理程式列表。

命令只有一個接收者。在上圖中,MakeSeatReservation被髮送到可用座位(SeatsAvailability)聚合。只有一個為該命令註冊的處理程式。CommandProcessor類通過檢查已註冊的處理程式列表來標識收到命令的處理程式。

這一實現帶來了一些問題:

  • 如何將命令的傳遞限制為單個接收?
  • 如果CommandBus和EventBus類如此相似,為什麼要分別使用它們呢?
  • 這種實現的可伸縮性如何?
  • 這種實現的健壯性如何?
  • 怎麼劃分Topic和訂閱的粒度?
  • 命令和事件如何序列化?

下面幾節將討論這些問題。

將命令傳遞給單個接收者

本討論假設您已經基本瞭解了Azure服務匯流排佇列和Topic之間的區別。有關Azure服務匯流排的介紹,請參閱參考指南中的“參考實現中使用的技術”。

使用上圖所示的實現,有兩件事是必要的,以確保命令只有單個處理程式。首先,Azure服務匯流排中應該保證只有一個會議/命令Topic的訂閱。請記住,Azure服務匯流排主題是可以有多個訂閱者的。其次,CommandProcessor應該為它接收到的每個命令呼叫一個處理程式。Azure服務匯流排中沒有辦法將主題限制為單個訂閱。因此,開發人員必須自己小心的為命令的Topic建立單個訂閱。

Gary(CQRS專家)發言:

另一個問題是確保處理程式從Topic獲取命令後只處理一次。您必須確保命令是冪等的,或者系統保證只處理命令一次。該團隊將在旅程的後期處理這個問題。有關更多資訊,請參見旅程第7章“增添適應能力和優化效能”。

備註:可能會執行多個SubscriptionReceiver例項,因為可以同時部署執行多個工作服務。如果多個SubscriptionReceiver例項可以接收來自同一主題訂閱的訊息,那麼第一個呼叫SubscriptionClient物件上的Receive方法的例項將獲取並處理該命令。

另一種方法是使用Azure服務匯流排佇列代替Topic來傳遞命令。Azure服務匯流排佇列與Topic的不同之處在於,它們的設計目的是將訊息傳遞給單個接收者,而不是通過多個訂閱傳遞給多個接收者。開發人員計劃更詳細的評估這個方案,以便在專案的稍後部分用此方案來實現。

下面來自SubscriptionReceiver類的程式碼示例顯示了它如何接收來自Topic訂閱的訊息。

private SubscriptionClient client;

...

private void ReceiveMessages(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        BrokeredMessage message = null;

        try
        {
            message = this.receiveRetryPolicy.ExecuteAction<BrokeredMessage>(this.DoReceiveMessage);
        }
        catch (Exception e)
        {
            Trace.TraceError("An unrecoverable error occurred while trying to receive a new message:\r\n{0}", e);

            throw;
        }

        try
        {
            if (message == null)
            {
                Thread.Sleep(100);
                continue;
            }

            this.MessageReceived(this, new BrokeredMessageEventArgs(message));
        }
        finally
        {
            if (message != null)
            {
                message.Dispose();
            }
        }
    }
}

protected virtual BrokeredMessage DoReceiveMessage()
{
    return this.client.Receive(TimeSpan.FromSeconds(10));
}

Jana(軟體架構師)發言:

此程式碼示例展示了系統如何使用Transient Fault Handling Application Block可靠地從Topic獲取訊息。

Azure服務匯流排SubscriptionClient類使用peek/lock技術從訂閱中獲取訊息。在程式碼示例中,Receive方法在訂閱時鎖定訊息。當訊息被鎖定時,其他客戶端無法看到它。然後Receive方法嘗試處理訊息。如果客戶端成功處理訊息,則呼叫Complete方法:這將從訂閱中刪除訊息。否則,如果客戶端未能成功處理該訊息,則呼叫Abandon方法:這將釋放訊息上的鎖,然後相同的客戶端或不同的客戶端就可以繼續接收它。如果客戶端在固定的時間內沒有呼叫Complete方法或Abandon方法,則也會釋放訊息上的鎖。

MessageReceived事件將一個引用傳遞給SubscriptionReceiver例項,以便處理程式在處理訊息時可以呼叫Complete方法或Abandon方法。

下面來自MessageProcessor類的程式碼示例展示瞭如何使用BrokeredMessage例項作為MessageReceived事件的引數以及如何使用它呼叫Complete和Abandon方法。

private void OnMessageReceived(object sender, BrokeredMessageEventArgs args)
{
    var message = args.Message;

    object payload;
    using (var stream = message.GetBody<Stream>())
    using (var reader = new StreamReader(stream))
    {
        payload = this.serializer.Deserialize(reader);
    }

    try
    {
        ...

        ProcessMessage(payload);

        ...
    }
    catch (Exception e)
    {
        if (args.Message.DeliveryCount > MaxProcessingRetries)
        {
            Trace.TraceWarning("An error occurred while processing a new message and will be dead-lettered:\r\n{0}", e);
            message.SafeDeadLetter(e.Message, e.ToString());
        }
        else
        {
            Trace.TraceWarning("An error occurred while processing a new message and will be abandoned:\r\n{0}", e);
            message.SafeAbandon();
        }

        return;
    }

    Trace.TraceInformation("The message has been processed and will be completed.");
    message.SafeComplete();
}

備註:本示例使用可靠的Transient Fault Handling Application Block,並使用擴充套件方法呼叫BrokeredMessage的Complete方法和Abandon方法。

為什麼分為CommandBus和EventBus?

儘管在會議管理系統開發的早期階段,CommandBus和EventBus類的實現非常相似,您可能想知道為什麼我們同時擁有這兩個分開的類,因為團隊預計它們在未來會出現區別。

Markus(軟體開發人員)發言:

在呼叫處理程式的方式和為它們捕獲什麼樣的上下文方面可能存在差異:命令可能希望捕獲額外的執行時狀態,而事件通常不需要這樣做。由於這些潛在的未來差異,我不想統一實現。我以前也遇到過這種情況,一旦有進一步的要求時,我就把它們分開。

這個方案的可擴充套件性如何?

使用這種方案,您可以在不同的Azure工作角色例項中執行SubscriptionReceiver類的多個例項和各種處理程式,這使您能夠擴充套件您的解決方案。您還可以在不同的Azure工作角色例項中擁有CommandBus、EventBus和TopicSender類的多個例項。

有關擴充套件Azure服務匯流排基礎設施的資訊,請參閱MSDN上的Best Practices for Performance Improvements Using Service Bus Brokered Messaging

這個方案的健壯性如何?

方案使用Azure服務匯流排的代理訊息傳遞選項來提供非同步訊息傳遞。服務匯流排總是可靠地儲存訊息,直到使用者連線並獲取這些訊息。

另外,從佇列或Topic訂閱獲取訊息的peek/lock方法為訊息消費者在處理訊息失敗的場景中增加了可靠性。如果消費者在呼叫Complete方法之前失敗,則當消費者重新啟動時,任然可以處理該訊息。

怎麼劃分Topic和訂閱的粒度?

當前的實現是系統中的所有命令都使用一個Topic(會議/命令),為系統中的所有事件也使用一個Topic(會議/事件)。每個Topic都有一個訂閱,每個訂閱接收發送到該主題的所有訊息。CommandProcessor和EventProcessor類負責將訊息傳遞給正確的處理程式。

將來,團隊會研究使用多個Topic,例如,為每個限界上下文使用單獨的命令Topic和多個訂閱(一個事件型別一個訂閱)。這些替代方案可以簡化程式碼,並促進擴充套件應用程式跨多個Azure工作角色,來工作。

Jana(軟體架構師)發言:

使用多個Topic、訂閱或佇列沒有額外的成本。Azure服務匯流排是根據傳送的訊息數量和從Azure子區域傳輸的資料量來進行計費的。

命令和事件如何序列化?

Contoso會議管理系統使用Json.NET來序列化和反序列化。有關應用程式如何使用序列化工具的詳細資訊,請參閱參考指南中的“參考實現中使用的技術”

您應該考慮是否需要為命令使用Azure服務匯流排。命令通常使用在有邊界的上下文中,您可能不需要跨程序邊界傳送它們(在寫入端,您可能不需要額外的層),在這種情況下,您可以使用記憶體佇列來傳遞命令。” -- Greg Young,與模式與實踐團隊的對話

對測試的影響

因為這是團隊處理的第一個限界上下文,所以關鍵一點是,如果團隊希望採用測試驅動開發(TDD),那麼如何進行測試。下面是兩名開發人員之間的對話,他們討論了在沒有事件源(ES)的情況下實現CQRS模式時如何進行TDD,對話總結了他們的想法:

  • 開發人員1:如果我們使用事件源(ES),那麼在建立領域物件時使用TDD方法將會很容易。測試的輸入將是一個命令(可能起源於UI),然後我們可以測試領域物件是否觸發了預期的事件。然而,如果我們不使用事件源,我們就沒有任何事件,領域物件的行為是通過ORM層將其更改持久化到資料儲存中的。

  • 開發人員2:那麼我們為什麼不發起事件呢?我們沒有使用事件源(ES)並不意味著我們的領域物件不能引發事件。讓領域物件引發事件,然後我們可以按照通常的方法設計測試,以檢查響應命令時觸發的正確事件。

  • 開發人員1:這難道不是讓事情變得比需要的更復雜了嗎?使用CQRS的動機之一就是簡化事情!現在我們有了領域物件,它們需要使用ORM層來持久化它們的狀態。然後我們又要引發事件來報告它們所持久化的內容,因為這樣我們就可以執行單元測試了。

  • 開發人員2:我明白你的意思。

  • 開發人員1:我們可能在如何進行測試上遇到了瓶頸。也許我們不應該基於領域物件的預期行為來設計測試,而是應該考慮在領域物件處理命令之後測試它們的狀態。

  • 開發人員2:這應該很容易做到,畢竟,領域物件把我們想要檢查的所有資料都儲存在屬性中,以便ORM可以將正確的資訊持久化到儲存中。

  • 開發人員1:所以我們只需要考慮在這個場景中使用另一種不同的風格進行測試。

  • 開發人員2:我們還需要考慮這個問題的另一個方面:我們可能有一組測試來測試領域物件,並且所有這些測試都可能通過。我們還可能有一組測試來驗證ORM層是否能夠成功地儲存和獲取物件。但是,我們還必須測試領域物件在ORM層上執行時是否正確。領域物件有可能執行正確的業務邏輯,但無法正確的持久化其狀態,這可能是因為ORM處理特定資料型別的方式存在問題。

有關這裡討論的兩種測試方法的更多資訊,請參閱Martin Fowler的文章“Mocks Aren't Stubs”和Steve Freeman、Nat Pryce和Joshua Kerievsky編寫的“Point/Counterpoint”。

備註:解決方案中包含的測試是使用xUnit.net編寫的。

下面的程式碼示例展示了使用上面討論的行為方法編寫的兩個測試示例。

Markus(軟體開發人員)發言:

這些是我們剛開始時使用的測試,但是我們隨後用基於狀態的測試替換了它們。

public SeatsAvailability given_available_seats()
{
    var sut = new SeatsAvailability(SeatTypeId);
    sut.AddSeats(10);
    return sut;
}

[TestMethod]
public void when_reserving_less_seats_than_total_then_succeeds()
{
    var sut = this.given_available_seats();
    sut.MakeReservation(Guid.NewGuid(), 4);
}

[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void when_reserving_more_seats_than_total_then_fails()
{
    var sut = this.given_available_seats();
    sut.MakeReservation(Guid.NewGuid(), 11);
}

這兩個測試共同驗證了可用座位(SeatsAvailability)聚合的行為。在第一個測試中,預期的行為是MakeReservation方法成功,並且不會丟擲異常。在第二個測試中,MakeReservation方法的預期行為是丟擲異常,因為沒有足夠的空閒座位來完成預訂。

如果沒有聚合引發事件,則很難以任何其他方式測試行為。例如,檢查是否進行了正確的呼叫以將聚合持久化到資料儲存裡,如果您試圖用這個來測試行為,那麼測試就會和資料儲存實現耦合(這是一種壞氣味):如果希望更改資料儲存的實現,那麼就需要更改領域模型中對聚合的測試。

下面的程式碼示例展示了使用被測試物件的狀態編寫的測試示例。這是在專案中使用的一種測試風格。

public class given_available_seats
{
    private static readonly Guid SeatTypeId = Guid.NewGuid();

    private SeatsAvailability sut;
    private IPersistenceProvider sutProvider;

    protected given_available_seats(IPersistenceProvider sutProvider)
    {
        this.sutProvider = sutProvider;
        this.sut = new SeatsAvailability(SeatTypeId);
        this.sut.AddSeats(10);

        this.sut = this.sutProvider.PersistReload(this.sut);
    }

    public given_available_seats()
        : this(new NoPersistenceProvider())
    {
    }

    [Fact]
    public void when_reserving_less_seats_than_total_then_seats_become_unavailable()
    {
        this.sut.MakeReservation(Guid.NewGuid(), 4);
        this.sut = this.sutProvider.PersistReload(this.sut);

        Assert.Equal(6, this.sut.RemainingSeats);
    }

    [Fact]
    public void when_reserving_more_seats_than_total_then_rejects()
    {
        var id = Guid.NewGuid();
        sut.MakeReservation(id, 11);

        Assert.Equal(1, sut.Events.Count());
        Assert.Equal(id, ((ReservationRejected)sut.Events.Single()).ReservationId);
    }
}

這裡展示的兩個測試在呼叫MakeReservation方法後測試可用座位(SeatsAvailability)聚合的狀態。第一個用來測試有足夠座位可用的場景。第二個用來測試沒有足夠的座位可用的場景。第二個測試可以利用可用座位(SeatsAvailability)聚合的行為,因為如果該聚合拒絕預訂,它確實會引發一個事件。

彙總

在旅程的第一階段,我們探索了實現CQRS模式的一些基礎知識,併為下一階段做了一些準備。

下一章將描述我們如何擴充套件和增強已經完成的工作,為訂單和註冊限界上下文新增更多的特性和功能。我們還將研究一些額外的測試技術,以瞭解它們可能如何幫助我們實現這一目標