CQRS之旅——旅程5(準備釋出V1版本)
旅程5:準備釋出V1版本
新增功能和重構,為V1版本釋出做準備。
“大多數人在完成一件事之後,就像留聲機的唱片一樣,一遍又一遍地使用它,直到它破碎,忘記了過去是用來創造更多未來的東西。” -- 弗雷婭.斯塔克
釋出Contoso會議管理系統V1版本:
本章描述了團隊為準備Contoso會議管理系統的第一個產品版本所做的更改。這項工作包括對前兩章介紹的訂單(Order)和註冊(Registrations)限界上下文的一些重構和功能新增,以及一個新的會議管理(Conference Management)限界上下文和一個新的支付(Payment)限界上下文。
團隊在此過程中進行的一個關鍵重構是將事件源(ES)引入訂單(Order)和註冊(Registrations)限界上下文中。
實現CQRS模式的一個預期好處是,它將幫助我們在複雜系統中管理變化。在CQRS旅程中釋出一個V1版本將幫助團隊評估當我們從V1版本遷移到系統的下一個產品版本時使用CQRS和ES的好處。剩下的章節將描述V1版本釋出後的情況。
本章描述了團隊在此階段新增到公共網站的使用者介面(UI),幷包括了對基於任務的UI的討論。
本章的工作術語定義:
本章使用了一些術語,我們將在下面進行描述。有關更多細節和可能的替代定義,請參閱參考指南中的“深入CQRS和ES”。
Access code(訪問程式碼):當業務客戶建立一個新的會議時,系統生成一個5個字元的訪問程式碼並通過電子郵件傳送給業務客戶。業務客戶可以使用其電子郵件地址和會議管理網站上的訪問程式碼在稍後的日子從系統中檢索會議詳細資訊。該系統使用訪問碼而不是密碼,因此業務客戶不需要僅為了建立一個支付而註冊賬戶。
Event sourcing(事件源):事件源是在系統中持久化和重新載入聚合狀態的一種方法。每當聚合的狀態發生更改時,聚合將引發詳細說明狀態更改的事件。然後,系統將此事件儲存到事件儲存中。系統可以通過重播與聚合例項關聯的所有先前儲存的事件來重新建立聚合的狀態。事件儲存成為系統儲存資料的記錄簿。此外,您還可以使用事件源作為審計資料的來源,作為查詢歷史狀態、從過去的資料獲得新的業務見解以及重播事件以進行除錯和問題分析的方法。
Eventual consistency(最終一致性):最終一致性是一個一致性模型,它不能保證立即訪問更新的值。對資料物件進行更新後,儲存系統不保證對該物件的後續訪問將返回更新後的值。然而,儲存系統確實保證,如果在足夠長的時間內沒有對物件進行新的更新,那麼最終所有訪問都可以返回最後更新的值。
使用者故事(User stories)
在這個過程的這個階段,團隊實現了下面描述的使用者故事。
定義通用語言
業務客戶:業務客戶代表使用會議管理系統執行其會議的組織。
座位:座位代表會議上的一個空間或進入會議上特定會議如歡迎招待會、教程或研討會的通道。
註冊者:註冊者是與系統互動下訂單併為這些訂單付款的人。註冊者還建立與訂單關聯的註冊。
會議管理限界界的上下文的使用者故事
業務客戶可以建立新的會議並管理它們。在業務客戶建立新會議之後,他可以使用電子郵件地址和會議定位器訪問程式碼訪問會議的詳細資訊。當業務客戶建立會議時,系統生成訪問程式碼。
業務客戶可以指定以下關於會議的資訊:
- 名稱、描述和Slug(用於訪問會議的URL的一部分)。
- 會議的開始和結束日期。
- 會議提供的不同座位型別和配額。
此外,業務客戶可以通過釋出或取消釋出會議來控制會議在公共網站上的可見性。
業務客戶可以使用會議管理網站檢視訂單和與會者列表。
訂單和註冊限界的上下文的使用者故事
當註冊者建立一個訂單時,可能無法完全完成該訂單。例如,註冊者申請5個座位參加整個會議,5個座位參加歡迎招待會,3個座位參加會前講習班。整個會議可能只有3個座位,歡迎招待會只有1個座位,但會前講習班有3個以上的座位。系統會將此資訊顯示給註冊者,並讓她有機會在繼續付款過程之前按順序調整每種座位的數量。
當註冊者選擇了每種座位型別的數量後,系統會計算訂單的總價,然後註冊者可以使用線上支付服務支付這些座位。Contoso不代表客戶處理付款。每個業務客戶必須有一個通過線上支付服務接受支付的機制。在專案的後期,Contoso將新增對業務客戶的支援,以將他們的發票系統與會議管理系統整合在一起。在將來的某個時候,Contoso可能會提供一項代表客戶收款的服務。
備註:在系統的這個版本中,實際上支付系統是模擬的。
註冊者在會議上購買了座位後,可以為參會者分配這些座位。系統儲存每個參會者的姓名和聯絡方式。
架構
下圖說明了在V1版本中Contoso會議管理系統的關鍵體系架構。該應用程式由兩個網站和三個限界上下文組成。基礎設施包括Microsoft Azure SQL資料庫(SQL Database)例項、事件儲存和訊息傳遞基礎設施。
圖後面的表列出了圖中顯示的構件(聚合、MVC控制器、讀取模型生成器和資料訪問物件)相互交換的所有訊息。
備註:為了清晰,圖中沒有展示Handlers(把訊息傳送給領域物件的類,例如:OrderCommandHandler)。
元素 | 型別 | 傳送 | 接收 |
---|---|---|---|
ConferenceController | MVC Controller | N/A | ConferenceDetails |
OrderController | MVC Controller | AssignSeat UnassignSeat |
DraftOrder OrderSeats PricedOrder |
RegistrationController | MVC Controller | RegisterToConference AssignRegistrantDetails InitiateThirdPartyProcessorPayment |
DraftOrder PricedOrder SeatType |
PaymentController | MVC Controller | CompleteThirdPartyProcessorPayment CancelThirdPartyProcessorPayment |
ThirdPartyProcessorPaymentDetails |
Conference Management | CRUD Bounded Context | ConferenceCreated ConferenceUpdated ConferencePublished ConferenceUnpublished SeatCreated SeatUpdated |
OrderPlaced OrderRegistrantAssigned OrderTotalsCalculated OrderPaymentConfirmed SeatAssigned SeatAssignmentUpdated SeatUnassigned |
Order | Aggregate | OrderPlaced OrderExpired OrderUpdated OrderPartiallyReserved OrderReservationCompleted OrderPaymentConfirmed OrderRegistrantAssigned |
RegisterToConference MarkSeatsAsReserved RejectOrder AssignRegistrantDetails ConfirmOrderPayment |
SeatsAvailability | Aggregate | SeatsReserved AvailableSeatsChanged SeatsReservationCommitted *SeatsReservationCancelled |
MakeSeatReservation CancelSeatReservation CommitSeatReservation AddSeats RemoveSeats |
SeatAssignments | Aggregate | SeatAssignmentsCreated SeatAssigned SeatUnassigned SeatAssignmentUpdated |
AssignSeat UnassignSeat |
RegistrationProcessManager | Process manager | MakeSeatReservation ExpireRegistrationProcess MarkSeatsAsReserved CancelSeatReservation RejectOrder CommitSeatReservation ConfirmOrderPayment |
OrderPlaced PaymentCompleted SeatsReserved ExpireRegistrationProcess |
OrderViewModelGenerator | Handler | DraftOrder | OrderPlaced OrderUpdated OrderPartiallyReserved OrderReservationCompleted OrderRegistrantAssigned |
PricedOrderViewModelGenerator | Handler | N/A | SeatTypeName |
ConferenceViewModelGenerator | Handler | Conference AddSeats RemoveSeats |
ConferenceCreated ConferenceUpdated ConferencePublished ConferenceUnpublished SeatCreated SeatUpdated |
ThirdPartyProcessorPayment | Aggregate | PaymentCompleted PaymentRejected PaymentInitiated |
InitiateThirdPartyProcessorPayment CompleteThirdPartyProcessorPayment CancelThirdPartyProcessorPayment |
標記*的這些事件僅用於使用事件源持久化聚合狀態。
標記**的是ConferenceViewModelGenerator從SeatCreated和SeatUpdated事件建立的這些命令,這些事件在會議管理限界上下文中處理。
下面的列表概述了Contoso會議管理系統中的訊息命名約定
- 所有事件在命名約定中都使用過去時。
- 所有命令都使用命令式命名約定。
- 所有的DTO都是名詞。
該應用程式旨在部署到Microsoft Azure。在旅程的那個階段,應用程式由兩個角色組成,一個包含ASP.Net MVC Web應用程式的web角色和一個包含訊息處理程式和領域物件的工作角色。應用程式在寫端和讀端都使用Azure SQL DataBase例項進行資料儲存。訂單(Order)和註冊(Registrations)限界上下文現在使用事件儲存在寫端持久化狀態。此事件儲存是使用Azure table storage來實現的。應用程式使用Azure服務匯流排來提供其訊息傳遞基礎設施。
在研究和測試解決方案時,可以在本地執行它,可以使用Azure compute emulator,也可以直接執行MVC web應用程式,並執行承載訊息處理程式和領域域物件的控制檯應用程式。在本地執行應用程式時,可以使用本地SQL Server Express資料庫,使用在SQL Server Express資料庫實現的簡單的訊息傳遞基礎設施和簡單事件儲存。
備註:事件儲存和訊息傳遞基礎設施的基於sql的實現只是為了幫助您在本地執行應用程式以進行探索和測試。它們並不是想要說明一種用於實際產品的方法。
有關執行應用程式的選項的更多資訊,請參見附錄1“釋出說明”。
會議管理有界上下文
會議管理限界上下文是一個簡單的兩層,建立/讀取/更新(CRUD)風格的web應用程式。它使用ASP.NET MVC和Entity Framework。
這個限界上下文必須與實現CQRS模式的其他限界上下文整合。
模式和概念
本節介紹了在團隊旅程的當前階段,應用程式的一些關鍵地方,並介紹了團隊在處理這些地方時遇到的一些挑戰。
事件源
Contoso的團隊最初在沒有使用事件源的情況下實現了訂單和註冊的限界上下文。然而,在實現過程中,很明顯,使用事件源將有助於簡化這個限界上下文。
在第4章“擴充套件和增強訂單和註冊邊界上下文”中,團隊發現我們需要使用事件將更改從寫端推到讀端。在讀端,OrderViewModelGenerator類訂閱Order聚合釋出的事件,並使用這些事件更新由讀取模型查詢的資料庫中的檢視。
這已經是事件源實現的一半了,因此在整個限界上下文中使用基於事件的單一永續性機制是有意義的。
事件源基礎設施可在其他限界上下文中重用,訂單和註冊的實現也變得更加簡單。
Poe(IT運維人員)發言:
作為一個實際的問題,在V1釋出之前,團隊只有有限的時間來實現一個產品級別的事件儲存。他們基於Azure表建立了一個簡單的基本事件儲存作為臨時解決方案。但是,在將來從一個事件儲存遷移到另一個事件儲存時,他們可能會面臨問題。
關鍵是演進:例如,可以展示如何實現事件源使您擺脫那些冗長的資料遷移,甚至允許您從過去構建報告。
- tom Janssens - CQRS Advisors郵件列表
團隊使用Azure表儲存實現了基本的事件儲存。如果您將應用程式託管在Azure中,還可以考慮使用Azure blobs或SQL資料庫來儲存事件。
在為事件儲存選擇基礎技術時,應該確保您的選擇能夠提供應用程式所需的可用性、一致性、可靠性、可伸縮性和效能需要。
Jana(軟體架構師)發言:
在選擇Azure中的儲存機制時要考慮的問題之一是成本。如果使用SQL資料庫,則根據資料庫的大小進行計費。如果使用Azure table或blob儲存,則根據使用的儲存量和儲存事務的數量進行計費。您需要仔細評估系統中不同聚合上的使用模式,以確定哪種儲存機制的成本效率最高。可能會發現,不同的儲存機制對於不同的聚合型別是有意義的。您可以引入一些優化來降低成本,例如使用快取來減少儲存事務的數量。
根據我的經驗,如果您正在進行新手開發,那麼您需要非常好的辯論來選擇一種SQL資料庫。Azure儲存服務應該是預設的選擇。但是,如果您已經有一個想要遷移到雲中的SQL Server資料庫,那麼情況就不同了。
- mark Seemann - CQRS Advisors郵件列表
確定聚合
在團隊為V1版本建立的基於Azure表儲存的事件儲存實現中,我們使用聚合ID作為分割槽鍵。這使得定位包含任何特定聚合事件的分割槽非常有效。
在某些情況下,系統必須定位相關的聚合。例如,訂單聚合可能具有相關的註冊聚合,其中包含分配到特定座位的參會者的詳細資訊。在這個場景中,團隊決定為相關的聚合對(訂單和註冊聚合)重用相同的聚合ID,以便於查詢。
Gary(CQRS專家)發言:
在這種情況下,您需要考慮是否應該有兩個聚合。您可以將註冊建模為訂單聚合內的實體。
更常見的場景是聚合之間存在一對多的關係,而不是一對一的關係。在這種情況下,不可能共享聚合ID,相反,“一”的聚合可以儲存“多”聚合的ID列表,而“多”的每個聚合可以儲存“一”聚合的ID。
當聚合存在於不同的限界上下文中時,共享聚合ID是很常見的。如果您在不同的限界上下文中使用聚合對同一個現實實體的不同方面建模,那麼它們共享相同的ID是有意義的。
Greg Young --與模式和實踐團隊的對話
基於任務的使用者介面
UI的設計在過去的十年中有了很大的改進。應用程式比以前更容易使用,更直觀,導航也更簡單。一些UI設計指南的例子可以幫助您建立這樣的現代的、使用者友好的應用程式,如Microsoft Inductive User Interface Guidelines和Index of UX guidelines。
影響UI設計和可用性的一個重要因素是UI如何與應用程式的其他部分通訊。如果應用程式基於CRUD風格的體系結構,這可能會洩漏到UI。如果開發人員專注於CRUD風格的操作,這可能會導致出現類似下圖(左邊)中第一個螢幕設計所示的UI。
在第一個螢幕上,按鈕上的文字反映了當使用者單擊Submit按鈕時系統將執行的底層CRUD操作,而不是顯示使用者更關心的操作的文字。不幸的是,第一個螢幕還要求使用者推理一些關於螢幕和應用程式功能的知識。例如,Add按鈕的功能並不是立即可見的。
第一個螢幕背後的典型實現將使用資料傳輸物件(DTO)在後端和UI之間交換資料。UI從後端請求資料,這些資料封裝在DTO中,UI將修改資料,然後將DTO發回到後端。後端將使用DTO來確定它必須對底層資料儲存執行哪些CRUD操作。
第二個螢幕更明確地顯示了業務流程方面正在發生的事情:使用者正在選擇座位型別的數量作為會議註冊任務的一部分。根據使用者正在執行的任務來考慮UI,可以更容易地將UI與CQRS模式實現中的寫模型關聯起來。UI可以向寫端傳送命令,這些命令是寫端領域模型的一部分。在實現CQRS模式的限界上下文中,UI通常查詢讀端並接收DTO,然後向寫端傳送命令。
上圖顯示了一系列頁面,這些頁面使註冊者能夠完成“在會議上購買座位”的任務。在第一頁,註冊者選擇座位的型別和數量。在第二頁,註冊者可以檢視她所預訂的座位,輸入她的聯絡方式,並完成必要的付款資訊。然後系統將註冊者重定向到支付提供者,如果支付成功完成,系統將顯示第三個頁面。第三個頁面顯示了訂單的摘要,並提供了到註冊者可以啟動其他任務的頁面的連結。
為了突出顯示基於任務的UI中命令和查詢的角色,故意簡化了上圖中所示的序列。例如,實際流程包括系統根據註冊者選擇的支付型別顯示的頁面,以及如果支付失敗系統顯示的錯誤頁面。
Gary(CQRS專家)發言:
您並不總是需要使用基於任務的UI。在某些場景中,簡單的CRUD風格的UI工作得很好。您必須評估基於任務的UI的好處是否大於所需的額外實現工作。通常,選擇實現CQRS模式的限界上下文也是受益於基於任務的UI的限界上下文,因為它們具有更復雜的業務邏輯和更復雜的使用者互動。
我想一勞永逸地宣告,CQRS不需要基於任務的UI。我們可以將CQRS應用於基於CRUD的介面(儘管建立分離的資料模型之類的事情要困難得多)。
然而,有一件事確實需要基於任務的UI。這就是領域驅動設計。
-Greg Young, CQRS, Task Based UIs, Event Sourcing agh!
更多資訊,請參見參考指南中的第4章“深入CQRS和ES”。
CRUD
您不應該將CQRS模式用作頂層體系結構的一部分。您應該只在模式帶來明顯好處的限界上下文中實現模式。在Contoso會議管理系統中,會議管理限界上下文是整個系統中相對簡單、穩定和低容量的一部分。因此,團隊決定使用傳統的兩層CRUD風格的體系結構來實現這個限界上下文。
有關CRUD風格的體系結構何時適合(或不適合)的討論,請參閱部落格文章:Why CRUD might be what they want, but may not be what they need
限界上下文之間的整合
會議管理限界上下文需要與訂單和註冊限界上下文整合。例如,如果業務客戶更改會議管理限界上下文中座位型別的配額,則必須將此更改傳播到訂單和註冊限界上下文中。此外,如果註冊者向會議添加了一個新的參會者,業務客戶必須能夠在會議管理網站的列表中檢視到參會者的詳細資訊。
從會議管理限界上下文中推送更新
下面是幾位開發人員和領域專家之間的對話,這些對話強調了團隊在計劃如何實現此整合時需要解決的一些關鍵問題。
- 開發人員1:我想談談我們應該如何實現與我們CRUD風格的會議管理限界上下文相關聯的整合任務的兩部分。首先,當業務客戶在此限界上下文中為現有會議建立新會議或定義新座位型別時,其他限界上下文中(如訂單和註冊限界上下文中)需要知道更改。其次,當業務客戶更改座位型別的配額時,其他限界上下文也需要知道這種更改。
- 開發人員2:所以在這兩種情況下,您都會從會議管理有限上下文中推送更改。這是一個方法。
- 開發人員1:是的。
- 開發人員2:您所說的場景之間有什麼顯著的區別嗎?
- 開發人員1:在第一個場景中,這些更改相對較少,通常在業務客戶建立會議時發生。此外,這些都僅是追加的更改。我們不允許業務客戶在會議首次釋出後刪除會議或座位型別。在第二種場景中,更改可能更頻繁,業務客戶可能會增加或減少座位配額。
- 開發人員2:對於這些整合場景,您考慮哪些實現方法?
- 開發人員1:因為我們有一個兩層的CRUD樣式的限界上下文,對於第一個場景,我計劃將會議和座位型別的資訊直接從資料庫中公開成一個簡單的只讀服務。對於第二個場景,我計劃在業務客戶更新座位配額時釋出事件。
- 開發人員2:為什麼這裡使用兩種不同的方法?使用單一的方法會更簡單。從長遠來看,使用事件更加靈活。如果其他限界上下文需要此資訊,則可以輕鬆訂閱事件。使用事件可以減少限界上下文之間的耦合。
- 開發人員1:我可以看到,如果我們使用事件,將更容易適應未來不斷變化的需求。例如,如果一個新的限界上下文需要知道關於誰更改了配額的資訊,我們可以將此資訊新增到事件中。對於現有的限界上下文,我們可以新增一個介面卡,將新的事件格式轉換為舊的。
- 開發人員2:您的意思是,通知訂閱者配額更改的事件傳送的是配額的更改。例如,假設業務客戶將座位配額增加了50個。這樣訂閱者如果一開始沒有訂閱,就不能收到完整的更新歷史記錄,會發生什麼?
- 開發人員1:我們可以包含一些使用當前狀態快照的同步機制。不管怎樣,在這種情況下,事件都可以簡單的報告配額的新值。如果有必要,事件可以報告座位配額的變化和當前值。
- 開發人員2:如何確保一致性?您需要確保限界上下文將其資料持久儲存並在訊息佇列上釋出事件。
- 開發人員1:我們可以將資料庫寫操作和add-to-queue操作封裝在一個事務中。
- 開發人員2:當網路資料的大小增加、響應時間變長和失敗的概率增加時,有兩個原因會導致以後出現問題。首先,我們的基礎設施使用Azure服務匯流排來處理訊息。不能使用單個事務將服務總線上的訊息傳送和對資料庫的寫入結合起來。其次,我們試圖避免兩階段提交,因為從長遠來看,它們總是會導致問題。
- 領域專家:我們有一個與另一個限界上下文類似的場景,我們將在稍後檢視。在這種情況下,我們不能對限界上下文做任何更改,我們不再擁有原始碼的最新副本。
- 開發人員1:我們可以做些什麼來避免使用兩階段提交?如果我們不能訪問原始碼,因此不能做任何更改,我們可以做什麼呢?
- 開發人員2:在這兩種情況下,我們使用相同的技術來解決問題。我們可以使用另一個程序來監視資料庫,並在它檢測到資料庫中的更改時傳送事件,而不是從應用程式程式碼中釋出事件。這個解決方案可能會引入少量延遲,但是它確實避免了兩階段提交的需要,並且您可以在不更改應用程式程式碼的情況下實現它。
另一個問題涉及何時何地持久化整合事件。在上面討論的示例中,會議管理限界上下文釋出事件,訂單和註冊限界上下文處理這些事件並使用它們填充其讀模型。如果發生了導致系統丟失讀模型資料的故障,那麼不儲存事件就無法重新建立讀模型。
是否需要持久化這些整合事件將取決於應用程式的特定需求和實現。例如:
- 寫端可以處理整合來替代在讀端處理,例如:事件將導致寫入端發生更改,這些更改將作為其他事件儲存。
- 整合事件可以當做臨時資料不做持久化。
- 來自CRUD風格的限界上下文的整合事件可能包含狀態資料,因此只需要最後一個事件。例如,如果來自會議管理限界上下文的事件包含當前座位配額,您可能對以前的值不感興趣。
另一種要考慮的方法是使用多個限界上下文共享的事件儲存。這樣,原始的限界上下文(例如CRUD風格的會議管理限界上下文)可以負責持久化整合事件。
- greg Young -與模式和實踐團隊的對話。
關於Azure服務匯流排的一些說明
前面的討論提出了一種在會議管理限界上下文中避免使用分散式兩段提交的方法。然而,也有其他的方法。
雖然Azure服務匯流排不支援分散式事務(把總線上的一個操作和資料庫上的一個操作合併),但您可以在傳送訊息時使用RequiresDuplicateDetection屬性,和在收到訊息使用PeekLock模式。這樣可以創建出所需級別的健壯性而不使用分散式事務。
作為替代方案,您可以使用分散式事務來更新資料庫,並使用本地Microsoft訊息佇列(MSMQ)傳送訊息。然後可以使用橋接器將MSMQ佇列連線到Azure服務匯流排佇列。
有關實現從MSMQ到Azure服務匯流排的橋接的示例,請參閱Microsoft Azure AppFabric SDK中的示例。
有關Azure服務匯流排的更多資訊,請參見參考指南中的第7章“在參考實現中使用的技術”。
推送更改到會議管理限界上下文
將關於已完成訂單和註冊的資訊從訂單和註冊限界上下文中推送到會議管理限界上下文中引發了一系列不同的問題。
訂單和註冊限界上下文通常在建立訂單時引發以下許多事件:OrderPlaced,OrderRegistrantAssigned,OrderTotalsCalculated,OrderPaymentConfirmed,SeatAssignmentsCreated,SeatAssignmentUpdated,SeatAssigned和 SeatUnassigned。限界上下文使用這些事件在聚合和事件源之間進行通訊。
對於會議管理限界上下文來說,要捕獲顯示註冊和參會者詳細資訊所需的資訊,它必須處理所有這些事件。它可以使用這些事件包含的資訊來建立資料的非規範化SQL表,然後業務客戶可以在UI中檢視這些資料。
這種方法的問題是會議管理限界上下文需要從另一個限界上下文理解一組複雜的事件。這是一個脆弱的解決方案,因為訂單和註冊限界上下文的更改可能會破壞會議管理限界上下文中的這一特性。
Contoso計劃為系統的V1版本保留這個解決方案,但是將在旅程的下一階段評估其他方案。這些替代方案將包括:
- 修改訂單和註冊限界上下文,以生成為整合而顯式設計的更有用的事件。
- 在訂單和註冊限界上下文中生成非規範化資料,並在資料準備好時通知會議管理限界上下文。然後,會議管理有界上下文可以通過服務呼叫請求資訊。
備註:要檢視當前方法如何工作,請檢視原始碼中Conference專案裡的OrderEventHandler類。
選擇何時更新讀端資料
在會議管理有界上下文中,業務客戶可以更改座位型別的描述。這將引發一個SeatUpdated事件,由ConferenceViewModelGenerator類在訂單和註冊限界上下文中處理。該類更新讀模型資料,以反映有關座椅型別的新資訊。當註冊者下訂單時,UI顯示新的座位描述。
然而,如果註冊者檢視先前建立的訂單(例如為參會者分配座位),註冊者將看到原始的座位描述。
Carlos(領域專家)發言:
這是一個要反覆思考的商業決策。我們不想讓註冊者因為在建立訂單後更改座位描述而混淆。
Gary(CQRS專家)發言:
如果我們想要更新現有訂單上的座位描述,我們需要修改PricedOrderViewModelGenerator類來處理SeatUpdated事件並調整它的檢視模型。
分散式事務和事件源
上一節討論了會議管理限界上下文的整合選項,提出了使用分散式兩段提交事務的問題,以確保儲存會議管理資料的資料庫和向其他限界上下文釋出更改的訊息傳遞基礎設施之間的一致性。
實現事件源時也會出現同樣的問題:必須確保儲存所有事件的限界上下文中的事件儲存與將這些事件釋出到其他限界上下文中的訊息傳遞基礎設施之間的一致性。
事件儲存實現的一個關鍵特性應該是,它提供了一種方法來確保其儲存的事件與限界上下文釋出到其他限界上下文的事件之間的一致性。
Carlos(領域專家)發言:
如果您決定自己實現一個事件儲存,這是您應該解決的一個關鍵挑戰。如果您正在設計一個可伸縮的事件儲存,並計劃將其部署到分散式環境(如Azure)中,那麼您必須非常小心,以確保滿足這一需求。
自治和授權
訂單和註冊限界上下文負責代表註冊者建立和管理訂單。支付限界上下文負責管理與外部支付系統的互動,以便註冊者可以為他們訂購的座位付費。
當團隊檢查這兩個限界上下文的領域模型時,發現兩個上下文都不知道定價。訂單和註冊上下文建立了一個訂單,其中列出了註冊者請求的不同座位型別的數量。支付繫結上下文只是將總數傳遞給外部支付系統。在某個時候,系統需要在呼叫支付流程之前計算訂單的總數。
團隊考慮了兩種不同的方法來解決這個問題:支援自治和支援權威。
支援自治
自治方法將計算訂單總數的任務分配給訂單和註冊限界上下文。它在需要執行計算時不依賴於另一個限界上下文,因為它已經擁有了必要的資料。在過去的某個時候,它將從其他限界上下文(例如會議管理限界上下文)收集所需的定價資訊並快取它。
這種方法的優點是訂單和註冊限界上下文是自治的。它不依賴於另一個限界上下文或服務的可用性。
缺點是價格資訊可能已經過時。業務客戶可能在會議管理限界上下文中更改了定價資訊,但該更改可能尚未到達訂單和註冊有界上下文中。
支援授權
在這種方法中,計算訂單總數的系統部分在執行計算時從限界上下文中(例如會議管理限界上下文中)獲取定價資訊。訂單和註冊限界上下文仍然可以執行計算,或者可以將計算委託給系統中的另一個限界上下文或服務。
這種方法的優點是,每當計算訂單總數時,系統總是使用最新的定價資訊。
缺點是,當需要確定訂單總數時,訂單和註冊限界上下文依賴於另一個限界上下文。它要麼需要查詢會議管理限界上下文以獲得最新的定價資訊,要麼呼叫另一個執行計算的服務。
**在自治和授權之間選擇
這兩種之間的選擇是一個業務決策。場景的特定業務需求決定採用哪種方法。自治通常是大型線上系統的首選。
Jana(軟體架構師)發言:
這個選擇可能會根據系統的狀態而改變。考慮一個超額預訂的場景。當大量會議席位仍然可用時,自治策略可能會在正常情況下進行優化,但是隨著特定會議的滿員,系統可能需要變得更加保守,並使用關於座位可用性的最新資訊來支援授權。
會議管理系統計算訂單總數的方法是選擇自治而不是授權的一個例子。
Carlos(領域專家)發言:
對於Contoso來說,自治是明確的選擇。註冊者因為其他一些限界上下文掛了而不能購買座位是一個嚴重的問題。無論怎樣,我們並不真正關心業務客戶修改的定價資訊和用於計算訂單總數的新定價資訊之間是否存在短暫的延遲。
下面的計算彙總部分描述了系統如何執行此計算。
讀端的實現方法
在前幾章對讀端進行的討論中,您看到了團隊如何使用基於sql的儲存來從寫端對資料進行非規範化的對映。
您可以為讀取模型資料使用其他儲存機制。例如,您可以使用檔案系統或Azure table或blob來儲存。在訂單和註冊限界上下文中,系統使用Azure blob儲存關於座位分配的資訊。
Gary(CQRS專家)發言:
當您為讀端選擇底層儲存機制時,除了要求讀端上的查詢方便且高效外,還應該考慮與儲存相關的成本(尤其是在雲中)。
備註:請參閱SeatAssignmentsViewModelGenerator類,以瞭解如何將資料持久化到blob儲存,以及SeatAssignmentsDao類,以瞭解UI如何檢索資料以供顯示。
最終一致性
在測試期間,團隊發現了一個場景,在這個場景中,註冊者可能會看到操作中最終一致性的證明。如果註冊者將參會者分配到訂單上購買的座位,然後快速導航到檢視分配,那麼有時該檢視只顯示部分更新。然而,重新整理頁面會顯示正確的資訊。這是因為記錄座位分配的事件傳播到讀模型需要時間,有時測試人員會過早地檢視從讀模型查詢的資訊。
儘管生產系統更新讀取模型的速度可能比本地執行的應用程式的除錯版本要快,但是團隊決定在檢視頁面中新增一個註釋,警告使用者這種可能性。
Carlos(領域專家)發言:
只要註冊者知道更改已經被持久化,並且UI顯示的內容可能過期了幾秒鐘,他們就不會擔心。
實現細節
本節描述訂單和註冊限界上下文的實現的一些重要功能。您可能會發現擁有一份程式碼拷貝很有用,這樣您就可以繼續學習了。您可以從Download center下載一個副本,或者在GitHub上檢視儲存庫:https://github.com/mspnp/cqrs-journey-code。您可以從GitHub上的Tags頁面下載V1發行版的程式碼。
備註:不要期望程式碼示例與參考實現中的程式碼完全匹配。本章描述了CQRS過程中的一個步驟,隨著我們瞭解更多並重構程式碼,實現可能會發生變化。
會議管理限界上下文
會議管理限界上下文允許業務客戶定義和管理會議,它是一個簡單的兩層、CRUD風格的應用程式,使用ASP.MVC。
在Visual Studio解決方案中,Conference專案包含模型程式碼和Conference.Web專案。Conference.Web專案包含MVC View和Controller。
與訂單和註冊限界上下文進行整合
會議管理限界上下文通過釋出以下事件將更改通知推送到會議。
- ConferenceCreated。在業務客戶建立新會議時釋出。
- ConferenceUpdated。在業務客戶更新現有會議時釋出。
- ConferencePublished。每當業務客戶釋出會議時釋出。
- ConferenceUnpublished。每當業務客戶取消釋出新會議時釋出。
- SeatCreated。每當業務客戶定義新座位型別時釋出。
- SeatsAdded。每當業務客戶增加座位型別的配額時釋出。
Conference專案中的ConferenceService類將這些事件釋出到事件匯流排。
Markus(軟體開發人員)發言:
目前,還沒有分散式事務來把資料庫更新和訊息釋出包裝到一起。
支付限界上下文
支付限界上下文負責和支付的外部系統互動,進行支付的處理和驗證。在V1版本中,支付可以通過模擬的外部第三方支付處理器(模仿PayPal等系統的行為)或發票系統進行處理。外部系統可以報告付款成功或失敗。
下圖中的序列圖演示了支付過程中涉及的關鍵元素如何相互互動。該圖顯示了一個簡化的檢視,忽略了處理程式類以更好地描述流程。
上圖顯示了訂單和註冊限界上下文、支付限界上下文和外部支付服務如何相互互動。在未來,註冊使用者也可以通過發票支付來替代第三方支付服務。
註冊者將支付作為UI中整個流程的一部分,如上圖所示。PaymentController控制器類先不顯示檢視,它必須等待系統建立第三方ThirdPartyProcessorPayment聚合例項。它的作用是將從註冊者收集的支付資訊轉發給第三方支付處理程式。
通常,當您實現CQRS模式時,您使用事件作為限界上下文之間通訊的機制。然而,在本例中,RegistrationController和PaymentController控制器類向支付限界上下文傳送命令。支付限界上下文使用事件與訂單和註冊限界上下文中的RegistrationProcessManager例項通訊。
支付限界上下文的實現使用了CQRS模式,但沒有事件源。
寫端模型包含一個名為ThirdPartyProcessorPayment的聚合,它由兩個類組成:ThirdPartyProcessorPayment和ThirdPartyProcessorPaymentItem。通過使用Entity Framework將這些類的例項持久化到SQL資料庫例項中。PaymentsDbContext類實現了一個Entity Framework dbcontext。
ThirdPartyProcessorPaymentCommandHandler是一個在寫端實現的命令處理程式。
讀取端模型也使用Entity Framework實現。PaymentDao類在讀端匯出支付資料。請參見GetThirdPartyProcessorPaymentDetails方法。
下圖說明了組成支付限界上下文的讀端和寫端的不同部分。
與線上支付服務的整合、最終的一致性和命令驗證
通常,線上支付服務提供兩種級別的整合方式:
- 簡單的方法是通過一種簡單的重定向機制來工作,您不需要與支付提供者建立一個商家帳戶。您將客戶重定向到支付服務。支付服務接受支付,然後將客戶重定向回網站上的一個頁面,並附帶一個確認碼。
- 複雜的方法(您確實需要一個商家帳戶)是基於API的。它通常分兩步執行。首先,支付服務驗證您的客戶是否可以支付所需的金額,並向您傳送一個令牌。其次,您可以在固定的時間內使用令牌,通過將令牌傳送回支付服務來完成支付。
Contoso假定其業務客戶沒有商戶帳戶,必須使用簡單的方法。這樣做的一個後果是,在客戶完成付款時,座位預訂可能會過期。如果發生這種情況,系統會嘗試在客戶付款後重新獲得座位。如果無法重新獲得座位,系統會將此問題通知業務客戶,業務客戶必須手動解決此情況。
備註:該系統允許一點額外的時間,顯示在倒計時時鐘上,來完成支付過程。
在這個特定的場景中,如果沒有使用者(在本例中是業務所有者,他必須發起退款或覆蓋座位配額)的手動干預,系統無法使自己完全一致,這說明了與最終一致性和命令驗證相關的以下更普遍的觀點。
接受最終一致性的一個關鍵好處是消除了使用分散式事務的需求,由於大型系統中必須持有的鎖的數量和持續時間,分散式事務對可伸縮性和效能有顯著的負面影響。在這個特定的場景中,您可以採取以下兩種方式來避免在沒有座位的情況下接受付款的潛在問題:
- 把系統更改成在付款前重新檢查座位是否有空位。但這是不現實的,因為與支付系統的整合是在沒有商戶帳戶的情況下工作的。
- 保留座位直到付款完畢。這也很困難,因為你不知道付款過程需要多長時間。您必須預留(鎖定)座位一段不確定的時間,等待註冊人完成付款。
團隊選擇允許這樣一種可能性,即註冊者可以付費購買座位,卻發現座位已不再可用。在實際中不太可能發生超時,除非註冊者要付費的座位很多。這種方法對系統的影響最小,因為它不需要對任何座位進行長期預訂(鎖定)。
Markus(軟體開發人員)發言:
為了進一步減少發生這種情況的機會,團隊決定將釋放預留座位的緩衝時間從5分鐘增加到14分鐘。選擇5分鐘的原始值是為了考慮伺服器之間任何可能的時鐘傾斜使得在UI中的15分鐘倒計時器過期之前不會釋放預訂。
在更通常的情況下,你可以重申上述兩個選項:
- 在執行命令之前驗證命令,以確保命令成功。
- 鎖定所有資源,直到命令完成。
如果命令隻影響單個聚合,並且不需要引用聚合定義的一致性邊界之外的任何內容,那麼就沒有問題,因為驗證命令所需的所有資訊都在聚合中。目前的情況並非如此。如果您能在付款之前驗證座位是否仍然可用,那麼這個資訊將需要檢查當前彙總之外的資訊。
如果選擇驗證命令,您需要檢視聚合之外的資料,例如,通過查詢讀模型或檢視快取,系統的可伸縮性將受到負面影響。另外,如果您正在查詢一個讀模型,請記住讀模型最終是一致的。在當前場景中,您需要查詢最終一致的讀模型來檢查座位的可用性。
如果您決定在命令完成之前鎖定所有相關資源,請注意這將對系統的可伸縮性造成的影響。
從業務角度處理這樣的問題要比在系統上設定大型架構約束好得多。
-- Greg Young
有關這個問題的詳細討論,請參閱Q/A Greg Young's Blog。
事件源
事件源基礎設施的初始實現是非常基本的:團隊打算在不久的將來用產品質量的事件儲存來替換它。本節描述了初始的、基本的實現,並列出了改進它的各種方法。
這個基本事件源解決方案的核心要素是:
- 每當聚合例項的狀態發生更改時,例項將引發一個事件,該事件將完整地描述狀態更改。
- 系統將這些事件儲存在事件儲存中。
- 聚合可以通過重播其過去的事件流來重建狀態。
- 其他聚合和流程管理器(可能在不同的限界上下文中)可以訂閱這些事件。
當聚合狀態發生更改時引發事件
訂單(Order)聚合中的以下兩個方法是OrderCommandHandler類在接收訂單命令時呼叫的方法的示例。這兩種方法都不會更新訂單(Order)聚合的狀態。相反,它們引發一個事件,該事件將由訂單(Order)聚合處理。在MarkAsReserved方法中,有一些最小的邏輯來確定要引發哪兩個事件。
public void MarkAsReserved(DateTime expirationDate, IEnumerable<SeatQuantity> reservedSeats)
{
if (this.isConfirmed)
throw new InvalidOperationException("Cannot modify a confirmed order.");
var reserved = reservedSeats.ToList();
// Is there an order item which didn't get an exact reservation?
if (this.seats.Any(item => !reserved.Any(seat => seat.SeatType == item.SeatType && seat.Quantity == item.Quantity)))
{
this.Update(new OrderPartiallyReserved { ReservationExpiration = expirationDate, Seats = reserved.ToArray() });
}
else
{
this.Update(new OrderReservationCompleted { ReservationExpiration = expirationDate, Seats = reserved.ToArray() });
}
}
public void ConfirmPayment()
{
this.Update(new OrderPaymentConfirmed());
}
Order類的抽象基類定義了Update方法。下面的程式碼示例顯示了這個方法以及EventSourced類中的Id和Version屬性。
private readonly Guid id;
private int version = -1;
protected EventSourced(Guid id)
{
this.id = id;
}
public int Version { get { return this.version; } }
protected void Update(VersionedEvent e)
{
e.SourceId = this.Id;
e.Version = this.version + 1;
this.handlers[e.GetType()].Invoke(e);
this.version = e.Version;
this.pendingEvents.Add(e);
}
Update方法設定Id並遞增聚合的版本。它還確定應該呼叫聚合中的哪個事件處理程式來處理事件型別。
Markus(軟體開發人員)發言:
每次系統更新聚合的狀態時,都會增加聚合的版本號。
下面的程式碼示例顯示Order類中的事件處理程式方法,這些方法是在呼叫上面顯示的命令方法時呼叫的。
private void OnOrderPartiallyReserved(OrderPartiallyReserved e)
{
this.seats = e.Seats.ToList();
}
private void OnOrderReservationCompleted(OrderReservationCompleted e)
{
this.seats = e.Seats.ToList();
}
private void OnOrderExpired(OrderExpired e)
{
}
private void OnOrderPaymentConfirmed(OrderPaymentConfirmed e)
{
this.isConfirmed = true;
}
這些方法更新聚合的狀態。
聚合必須能夠處理來自其他聚合的事件和它自己引發的事件。Order類中的受保護建構函式列出Order聚合可以處理的所有事件。
protected Order()
{
base.Handles<OrderPlaced>(this.OnOrderPlaced);
base.Handles<OrderUpdated>(this.OnOrderUpdated);
base.Handles<OrderPartiallyReserved>(this.OnOrderPartiallyReserved);
base.Handles<OrderReservationCompleted>(this.OnOrderReservationCompleted);
base.Handles<OrderExpired>(this.OnOrderExpired);
base.Handles<OrderPaymentConfirmed>(this.OnOrderPaymentConfirmed);
base.Handles<OrderRegistrantAssigned>(this.OnOrderRegistrantAssigned);
}
事件持久化
當聚合在EventSourcedAggregateRoot類的Update方法中處理事件時,它將該事件新增到掛起事件的私有列表中。此列表將在名為Events的類(是EventSourced抽象類的實現類)中暴露成IEnumerable型別的公開屬性。
來自OrderCommandHandler類的以下程式碼示例展示了處理程式如何呼叫Order類中的方法來處理命令,然後使用儲存庫將所有掛起事件附加到儲存中,從而持久儲存Order聚合的當前狀態。
public void Handle(MarkSeatsAsReserved command)
{
var order = repository.Find(command.OrderId);
if (order != null)
{
order.MarkAsReserved(command.Expiration, command.Seats);
repository.Save(order);
}
}
下面的程式碼示例顯示了SqlEventSourcedRepository類中Save方法的初始簡單實現。
備註:這些示例引用的是一個基於SQL Server實現的事件儲存。這是最初的方法,後來被基於Azure表儲存的實現所取代。基於SQL server實現的事件儲存仍然保留在解決方案中,這是為了方便您可以在本地執行應用程式,並使用這個實現來避免對Azure的任何依賴。
public void Save(T eventSourced)
{
// TODO: guarantee that only incremental versions of the event are stored
var events = eventSourced.Events.ToArray();
using (var context = this.contextFactory.Invoke())
{
foreach (var e in events)
{
using (var stream = new MemoryStream())
{
this.serializer.Serialize(stream, e);
var serialized = new Event { AggregateId = e.SourceId, Version = e.Version, Payload = stream.ToArray() };
context.Set<Event>().Add(serialized);
}
}
context.SaveChanges();
}
// TODO: guarantee delivery or roll back, or have a way to resume after a system crash
this.eventBus.Publish(events);
}
通過重播事件來重建狀態
當處理程式類從儲存中載入聚合例項時,它通過重播儲存的事件流來載入例項的狀態。
Poe(IT運維人員)發言:
我們後來發現,使用事件源並能夠重播事件對於分析執行在雲中的生產系統中的bug是非常寶貴的技術。我們可以建立事件儲存的本地副本,然後在本地重播事件流,並在Visual Studio中除錯應用程式,以準確理解生產系統中發生了什麼。
下面來自OrderCommandHandler類的程式碼示例顯示瞭如何呼叫儲存庫中的Find方法來啟動此過程。
public void Handle(MarkSeatsAsReserved command)
{
var order = repository.Find(command.OrderId);
...
}
下面的程式碼示例顯示了SqlEventSourcedRepository類如何載入與聚合關聯的事件流。
Jana(軟體架構師)發言:
該團隊後來使用Azure表而不是SqlEventSourcedRepository開發了一個簡單的事件儲存。下一節將描述這種基於Azure表儲存的實現。
public T Find(Guid id)
{
using (var context = this.contextFactory.Invoke())
{
var deserialized = context.Set<Event>()
.Where(x => x.AggregateId == id)
.OrderBy(x => x.Version)
.AsEnumerable()
.Select(x => this.serializer.Deserialize(new MemoryStream(x.Payload)))
.Cast<IVersionedEvent>()
.AsCachedAnyEnumerable();
if (deserialized.Any())
{
return entityFactory.Invoke(id, deserialized);
}
return null;
}
}
下面的程式碼示例顯示了當前面的程式碼呼叫Invoke方法時候,Order類中的建構函式是怎樣從自己的事件流裡重建狀態的。
public Order(Guid id, IEnumerable<IVersionedEvent> history) : this(id)
{
this.LoadFrom(history);
}
LoadFrom方法在EventSourced類中定義,如下面的程式碼示例所示。對於歷史中儲存的每個事件,它確定要在Order類中呼叫的適當處理程式方法,並更新聚合例項的版本號。
protected void LoadFrom(IEnumerable<IVersionedEvent> pastEvents)
{
foreach (var e in pastEvents)
{
this.handlers[e.GetType()].Invoke(e);
this.version = e.Version;
}
}
簡單事件儲存實現的一些問題
前面幾節中概述的事件源和事件儲存的簡單實現有許多缺點。下面的列表列出了在生產質量的實現中應該克服的一些缺點。
- SqlEventRepository類中的Save方法不能保證將事件持久儲存併發布到訊息傳遞基礎設施。失敗可能導致事件被儲存到儲存區,但不會發布。
- 沒有檢查當系統持久儲存一個事件時,它是否是比前一個事件晚一些的事件。事件需要被按順序儲存。
- 對於事件流中有大量事件的聚合例項,沒有適當的優化。這可能會在重播事件時導致效能問題。
基於Azure表的事件儲存
基於Azure表實現的事件儲存解決了簡單的基於SQL server實現的事件儲存的一些缺點。然而,在這一點上,它仍然不是一個生產質量的實現。
團隊設計此實現是為了確保事件既被持久化到儲存中,又被髮布在訊息總線上。為了實現這一點,它使用了Azure表的事務功能。
Markus(軟體開發人員)發言:
Azure表儲存支援跨共享相同分割槽鍵的記錄的事務。
EventStore類最初儲存要持久化的每個事件的兩個副本。一個副本是該事件的永久記錄,另一個副本成為必須在Azure服務總線上釋出的事件虛擬佇列的一部分。下面的程式碼示例顯示了EventStore類中的Save方法。字首“Unpublished”標識事件的副本,該副本是未釋出事件的虛擬佇列的一部分。
public void Save(string partitionKey, IEnumerable<EventData> events)
{
var context = this.tableClient.GetDataServiceContext();
foreach (var eventData in events)
{
var formattedVersion = eventData.Version.ToString("D10");
context.AddObject(
this.tableName,
new EventTableServiceEntity
{
PartitionKey = partitionKey,
RowKey = formattedVersion,
SourceId = eventData.SourceId,
SourceType = eventData.SourceType,
EventType = eventData.EventType,
Payload = eventData.Payload
});
// Add a duplicate of this event to the Unpublished "queue"
context.AddObject(
this.tableName,
new EventTableServiceEntity
{
PartitionKey = partitionKey,
RowKey = UnpublishedRowKeyPrefix + formattedVersion,
SourceId = eventData.SourceId,
SourceType = eventData.SourceType,
EventType = eventData.EventType,
Payload = eventData.Payload
});
}
try
{
this.eventStoreRetryPolicy.ExecuteAction(() => context.SaveChanges(SaveChangesOptions.Batch));
}
catch (DataServiceRequestException ex)
{
var inner = ex.InnerException as DataServiceClientException;
if (inner != null && inner.StatusCode == (int)HttpStatusCode.Conflict)
{
throw new ConcurrencyException();
}
throw;
}
}
備註:此程式碼示例還說明了如何使用重複鍵錯誤來標識併發錯誤。
repository類中的Save方法如下所示。此方法由事件處理程式類呼叫,它呼叫前面程式碼示例中所示的Save方法,並呼叫EventStoreBusPublisher類的SendAsync方法。
public void Save(T eventSourced)
{
var events = eventSourced.Events.ToArray();
var serialized = events.Select(this.Serialize);
var partitionKey = this.GetPartitionKey(eventSourced.Id);
this.eventStore.Save(partitionKey, serialized);
this.publisher.SendAsync(partitionKey);
}
EventStoreBusPublisher類負責從Azure表儲存中的虛擬佇列中讀取聚合的未釋出事件,將事件釋出到Azure服務總線上,然後從虛擬佇列中刪除未釋出的事件。
如果系統在將事件釋出到Azure服務匯流排和從虛擬佇列中刪除事件之間失敗,那麼當應用程式重新啟動時,將第二次釋出事件。為了避免重複釋出事件引起的問題,Azure服務匯流排被配置為檢測重複訊息並忽略它們。
Markus(軟體開發人員)發言:
在出現故障的情況下,系統必須包含一種機制,用於掃描表儲存中的所有分割槽,尋找包含未釋出事件的聚合,然後釋出這些事件。這個過程需要一些時間來執行,但是隻需要在應用程式重新啟動時執行。
計算總數
為了保證其自主性,訂單和註冊限界上下文在不訪問會議管理限界上下文的情況下計算訂單總數。會議管理限界上下文負責維護會議座位的價格。
每當業務客戶新增新的座位型別或更改座位的價格時,會議管理限界上下文就會引發一個事件。訂單和註冊限界上下文將處理這些事件,並將資訊作為其讀模型的一部分儲存(詳細資訊,請參考解決方案中的ConferenceViewModelGenerator類)。
當訂單聚合計算訂單總數時,它使用讀模型提供的資料。詳細資訊請參考訂單聚合和PricingService類中的MarkAsReserved方法。
Jana(軟體架構師)發言:
當註冊者向訂單新增座位時,UI還動態顯示計算的總數。應用程式使用JavaScript計算這個值。當註冊者付款時,系統使用訂單總數計算的總數。
對測試的影響
Markus(軟體開發人員)發言:
不要讓通過的單元測試使您產生錯誤的安全感。當您實現CQRS模式時,有很多靈活的部分。您需要測試它們是否都能正確地協同工作。
Markus(軟體開發人員)發言:
不要忘記為讀模型建立單元測試。讀模型生成器上的單元測試在V1版本釋出之前就發現過一個bug,系統在更新訂單時刪除了訂單項。
時間問題
當業務客戶建立新的座位型別時,其中有一個驗收測試來驗證系統的行為。測試中的關鍵步驟是建立一個會議,為會議建立一個新的座位型別,然後釋出會議。這將引發相應的事件序列:ConferenceCreated,SeatCreated和ConferencePublished。
訂單和註冊限界上下文處理這些整合事件。測試確定訂單和註冊限界上下文接收這些事件的順序與會議管理限界上下文傳送這些事件的順序不同。
Azure服務匯流排只提供先入先出(FIFO),因此,它可能不會按照事件傳送的順序交付事件。在這個場景中,也有可能出現問題,因為在測試中建立訊息並將其交付給Azure服務匯流排的步驟所花費的時間不同。在測試步驟之間引入人為的延遲為這個問題提供了一個臨時的解決方案。
在V2版本中,團隊計劃解決訊息排序的一般問題,或者修改基礎設施以確保正確的排序,或者在訊息確實出現順序錯誤時使系統更加健壯。
關於領域專家
在第4章“擴充套件和增強訂單和註冊限界上下文”中,您看到了領域專家如何參與設計驗收測試,以及他的參與如何幫助澄清領域知識。
您還應該確保領域專家參加錯誤分類會議。他或她可以幫助闡明系統的預期行為,並且在討論期間可以發現新的使用者場景。例如,對與在會議管理限界上下文中取消釋出會議相關的bug進行分類時,領域專家確定了一個需求,以允許業務客戶將未釋出會議的重定向連結新增到新的會議或備用頁面。
總結
在我們旅程的這個階段,我們完成了Contoso會議管理系統的第一個偽生產版本。它現在包含了幾個整合的限界上下文、一個更加完善的UI,並在訂單和註冊限界上下文中使用事件源。
我們還有更多的工作要做,下一章將描述CQRS之旅的下一個階段,我們將走向V2發行版並解決與系統版本控制相關的問題