添加功能和重構,爲V1版本發佈作準備。
「大多數人在完成一件事以後,就像留聲機的唱片同樣,一遍又一遍地使用它,直到它破碎,忘記了過去是用來創造更多將來的東西。」 -- 弗雷婭.斯塔克
複製代碼
本章描述了團隊爲準備Contoso會議管理系統的第一個產品版本所作的更改。這項工做包括對前兩章介紹的訂單(Order)和註冊(Registrations)限界上下文的一些重構和功能添加,以及一個新的會議管理(Conference Management)限界上下文和一個新的支付(Payment)限界上下文。git
團隊在此過程當中進行的一個關鍵重構是將事件源(ES)引入訂單(Order)和註冊(Registrations)限界上下文中。github
實現CQRS模式的一個預期好處是,它將幫助咱們在複雜系統中管理變化。在CQRS旅程中發佈一個V1版本將幫助團隊評估當咱們從V1版本遷移到系統的下一個產品版本時使用CQRS和ES的好處。剩下的章節將描述V1版本發佈後的狀況。web
本章描述了團隊在此階段添加到公共網站的用戶界面(UI),幷包括了對基於任務的UI的討論。sql
本章使用了一些術語,咱們將在下面進行描述。有關更多細節和可能的替代定義,請參閱參考指南中的「深刻CQRS和ES」。數據庫
訪問代碼(Access code):當業務客戶建立一個新的會議時,系統生成一個5個字符的訪問代碼並經過電子郵件發送給業務客戶。業務客戶可使用其電子郵件地址和會議管理網站上的訪問代碼在稍後的日子從系統中檢索會議詳細信息。該系統使用訪問碼而不是密碼,所以業務客戶不須要僅爲了建立一個支付而註冊帳戶。c#
事件源(Event sourcing):事件源是在系統中持久化和從新加載聚合狀態的一種方法。每當聚合的狀態發生更改時,聚合將引起詳細說明狀態更改的事件。而後,系統將此事件保存到事件存儲中。系統能夠經過重播與聚合實例關聯的全部先前保存的事件來從新建立聚合的狀態。事件存儲成爲系統存儲數據的記錄簿。此外,您還可使用事件源做爲審計數據的來源,做爲查詢歷史狀態、從過去的數據得到新的業務看法以及重播事件以進行調試和問題分析的方法。windows
最終一致性(Eventual consistency):最終一致性是一個一致性模型,它不能保證當即訪問更新的值。對數據對象進行更新後,存儲系統不保證對該對象的後續訪問將返回更新後的值。然而,存儲系統確實保證,若是在足夠長的時間內沒有對對象進行新的更新,那麼最終全部訪問均可以返回最後更新的值。後端
在這個過程的這個階段,團隊實現了下面描述的用戶故事。緩存
業務客戶:業務客戶表明使用會議管理系統運行其會議的組織。安全
座位:座位表明會議上的一個空間或進入會議上特定會議如歡迎招待會、教程或研討會的通道。
註冊者:註冊者是與系統交互下訂單併爲這些訂單付款的人。註冊者還建立與訂單關聯的註冊。
業務客戶能夠建立新的會議並管理它們。在業務客戶建立新會議以後,他可使用電子郵件地址和會議定位器訪問代碼訪問會議的詳細信息。當業務客戶建立會議時,系統生成訪問代碼。
業務客戶能夠指定如下關於會議的信息:
此外,業務客戶能夠經過發佈或取消發佈會議來控制會議在公共網站上的可見性。
業務客戶可使用會議管理網站查看訂單和與會者列表。
當註冊者建立一個訂單時,可能沒法徹底完成該訂單。例如,註冊者申請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會議管理系統中的消息命名約定
該應用程序旨在部署到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表建立了一個簡單的基本事件存儲做爲臨時解決方案。可是,在未來從一個事件存儲遷移到另外一個事件存儲時,他們可能會面臨問題。
關鍵是演進:例如,能夠展現如何實現事件源使您擺脫那些冗長的數據遷移,甚至容許您從過去構建報告。
團隊使用Azure表存儲實現了基本的事件存儲。若是您將應用程序託管在Azure中,還能夠考慮使用Azure blobs或SQL數據庫來存儲事件。
在爲事件存儲選擇基礎技術時,應該確保您的選擇可以提供應用程序所需的可用性、一致性、可靠性、可伸縮性和性能須要。
Jana(軟件架構師)發言:
在選擇Azure中的存儲機制時要考慮的問題之一是成本。若是使用SQL數據庫,則根據數據庫的大小進行計費。若是使用Azure table或blob存儲,則根據使用的存儲量和存儲事務的數量進行計費。您須要仔細評估系統中不一樣聚合上的使用模式,以肯定哪一種存儲機制的成本效率最高。可能會發現,不一樣的存儲機制對於不一樣的聚合類型是有意義的。您能夠引入一些優化來下降成本,例如使用緩存來減小存儲事務的數量。
根據個人經驗,若是您正在進行新手開發,那麼您須要很是好的辯論來選擇一種SQL數據庫。Azure存儲服務應該是默認的選擇。可是,若是您已經有一個想要遷移到雲中的SQL Server數據庫,那麼狀況就不一樣了。
在團隊爲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」。
您不該該將CQRS模式用做頂層體系結構的一部分。您應該只在模式帶來明顯好處的限界上下文中實現模式。在Contoso會議管理系統中,會議管理限界上下文是整個系統中相對簡單、穩定和低容量的一部分。所以,團隊決定使用傳統的兩層CRUD風格的體系結構來實現這個限界上下文。
有關CRUD風格的體系結構什麼時候適合(或不適合)的討論,請參閱博客文章:Why CRUD might be what they want, but may not be what they need
會議管理限界上下文須要與訂單和註冊限界上下文集成。例如,若是業務客戶更改會議管理限界上下文中座位類型的配額,則必須將此更改傳播到訂單和註冊限界上下文中。此外,若是註冊者向會議添加了一個新的參會者,業務客戶必須可以在會議管理網站的列表中查看到參會者的詳細信息。
下面是幾位開發人員和領域專家之間的對話,這些對話強調了團隊在計劃如何實現此集成時須要解決的一些關鍵問題。
另外一個問題涉及什麼時候何地持久化集成事件。在上面討論的示例中,會議管理限界上下文發佈事件,訂單和註冊限界上下文處理這些事件並使用它們填充其讀模型。若是發生了致使系統丟失讀模型數據的故障,那麼不保存事件就沒法從新建立讀模型。
是否須要持久化這些集成事件將取決於應用程序的特定需求和實現。例如:
另外一種要考慮的方法是使用多個限界上下文共享的事件存儲。這樣,原始的限界上下文(例如CRUD風格的會議管理限界上下文)能夠負責持久化集成事件。
前面的討論提出了一種在會議管理限界上下文中避免使用分佈式兩段提交的方法。然而,也有其餘的方法。
雖然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上查看存儲庫:github.com/mspnp/cqrs-…。您能夠從GitHub上的Tags頁面下載V1發行版的代碼。
備註:不要指望代碼示例與參考實現中的代碼徹底匹配。本章描述了CQRS過程當中的一個步驟,隨着咱們瞭解更多並重構代碼,實現可能會發生變化。
複製代碼
會議管理限界上下文容許業務客戶定義和管理會議,它是一個簡單的兩層、CRUD風格的應用程序,使用ASP.MVC。
在Visual Studio解決方案中,Conference項目包含模型代碼和Conference.Web項目。Conference.Web項目包含MVC View和Controller。
會議管理限界上下文經過發佈如下事件將更改通知推送到會議。
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方法。
下圖說明了組成支付限界上下文的讀端和寫端的不一樣部分。
一般,在線支付服務提供兩種級別的集成方式:
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;
}
}
複製代碼
前面幾節中概述的事件源和事件存儲的簡單實現有許多缺點。下面的列表列出了在生產質量的實現中應該克服的一些缺點。
基於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發行版並解決與系統版本控制相關的問題。