旅程4:擴展和增強訂單和注冊限界上下文
進一步探索訂單和注冊的有界上下文。
“我明白,如果一個人想看些新鮮的東西,旅行並不是沒有意義的。”儒勒·凡爾納,環游世界80天
對限界上下文的更改:
前一章詳細描述了訂單和注冊限界上下文。本章描述了在CQRS之旅的第二階段,團隊在這個限界上下文中所做的一些更改。
本章的主題包括:
- 改進RegistrationProcessManager類中消息相關的工作方式。這說明了限界上下文中的聚合實例如何以復雜的方式進行交互。
- 實現一個記錄定位器,使注冊者能夠檢索她在前一個會話中保存的訂單。這說明了如何向寫端(Write Side)添加一些額外的邏輯,使您能夠在不知道聚合實例惟一ID的情況下定位它。
- 在UI中添加一個倒計時器,使注冊者能夠跟蹤他們需要在多長時間內完成訂單。這說明了對寫端(Write Side)進行的增強,以支持在UI中顯示豐富的信息。
- 同時支持多種座位類型的預定。例如,注冊者為會前的活動申請5個座位,為會議申請8個座位。這需要在寫端(Write Side)使用更復雜的業務邏輯。
- CQRS命令驗證。這說明了如何在將CQRS命令發送到領域之前使用MVC中的模型驗證特性來驗證它們。
本章描述的Contoso會議管理系統並不是該系統的最終版本。本旅程描述的是一個過程,因此一些設計決策和實現細節將在過程的后續步驟中更改。這些變化將在后面的章節中描述。
本章的工作術語定義:
本章使用了一些術語,我們將在下面進行描述。有關更多細節和可能的替代定義,請參閱參考指南中的“深入CQRS和ES”。
-
命令(Command):命令是要求系統執行更改系統狀態的操作。命令是必須服從(執行)的一種指令,例如:MakeSeatReservation。在這個限界上下文中,命令要么來自用戶發起請求時的UI,要么來自流程管理器(當流程管理器指示聚合執行某個操作時)。單個接收方處理一個命令。命令總線(command bus)傳輸命令,然后命令處理程序將這些命令發送到聚合。發送命令是一個沒有返回值的異步操作。
-
事件(Event):事件就是系統中發生的一些事情,通常是一個命令的結果。領域模型中的聚合會引發(raise)事件。多個事件訂閱者(subscribers)可以處理特定的事件。聚合將事件發布到事件總線, 處理程序訂閱特定類型的事件,事件總線(event bus)將事件傳遞給訂閱者。在這個限界上下文中,唯一的訂閱者是流程管理器。
-
流程管理器。在這個限界上下文中,流程管理器是一個協調領域域中聚合行為的類。流程管理器訂閱聚合引發的事件,然后遵循一組簡單的規則來確定發送一個或一組命令。流程管理器不包含任何業務邏輯,它唯一的邏輯是確定下一個發送的命令。流程管理器被實現為一個狀態機,因此當它響應一個事件時,除了發送一個新命令外,還可以更改其內部狀態。
Gregor Hohpe和Bobby Woolf合著的《Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions》(Addison-Wesley Professional, 2003)書中312頁講述了流程管理器實現模式。我們的流程管理器就是依照這個模式實現的。
用戶故事(User stories)
除了描述訂單和注冊限界上下文的一些更改和增強之外,本章還討論了兩個用戶故事的實現。
使用記錄定位器作為登錄
當注冊者創建會議座位的訂單時,系統生成一個5個字符的訂單訪問代碼,並通過電子郵件發送給注冊者。登記人可以使用她的電子郵件地址和會議系統網站上的訂單訪問代碼作為記錄定位器,以便稍后從系統中檢索訂單。注冊者可能希望檢索訂單以查看它,或者通過分配與會者到座位來完成注冊過程。
Carlos(領域專家)發言:
從商業的角度來看,對我們來說,盡可能地做到用戶友好是很重要的。我們不想阻止或不必要地增加任何試圖注冊會議的人的負擔。因此,我們不要求用戶在注冊之前在系統中創建帳戶,特別是要求用戶無論如何都必須在標准的結帳過程中輸入大部分信息。
告訴會議注冊者還剩余多少時間來完成訂單
當注冊者創建一個訂單時,系統將保留注冊者請求的座位,直到完成訂單或預訂過期。要完成訂單,注冊者必須提交她的詳細信息,如姓名和電子郵件地址,並成功付款。
為了幫助注冊者,系統會顯示一個倒計時計時器,告訴她還有多少時間可以在預定到期前完成訂單。
使注冊者能夠創建包含多個座位類型的訂單
當注冊者創建一個訂單,她可以申請不同數量的座位,並且這些座位類型可以不相同。例如,登記人可要求五個會議座位和三個會前講習班座位。
架構
該應用程序旨在部署到Microsoft Azure。在旅程的這個階段,應用程序由兩個角色組成,一個包含ASP.Net MVC Web應用程序的web角色和一個包含消息處理程序和領域對象的工作角色。應用程序在寫端和讀端都使用Azure SQL DataBase實例進行數據存儲。應用程序使用Azure服務總線來提供其消息傳遞基礎設施。下圖展示了這個高級體系結構。
在研究和測試解決方案時,可以在本地運行它,可以使用Azure compute emulator,也可以直接運行MVC web應用程序,並運行承載消息處理程序和領域域對象的控制台應用程序。在本地運行應用程序時,可以使用本地SQL Server Express數據庫,並使用一個在SQL Server Express數據庫實現的簡單的消息傳遞基礎設施。
有關運行應用程序的選項的更多信息,請參見附錄1“發布說明”。
模式和概念
本節介紹了在團隊旅程的當前階段,應用程序的一些關鍵地方,並介紹了團隊在處理這些地方時遇到的一些挑戰。
記錄定位器
該系統使用訪問碼而不是密碼,這樣注冊者就不會被迫在該系統中設置帳戶。許多注冊者可能只使用系統一次,因此不需要創建一個帶有用戶ID和密碼的永久帳戶。
系統需要能夠根據注冊者的電子郵件地址和訪問代碼快速檢索訂單信息。為了提供最低程度的安全性,系統生成的訪問代碼不應該是可預測的,注冊者可以檢索的訂單信息不應該包含任何敏感信息。
在讀端查詢數據
前一章重點介紹了寫端模型及其實現,在本章中,我們將更詳細地探討讀端的實現。特別地,我們將解釋如何從MVC控制器實現讀取模型和查詢機制。
在對CQRS模式的初步研究中,團隊決定使用數據庫中的SQL視圖作為讀取端MVC控制器查詢數據的基礎數據源。為了最小化讀端查詢必須執行的工作,這些SQL視圖提供了數據的反規范化(denormalised)版本。這些視圖目前與寫模型使用的規范化(normalized)表存在同一個數據庫中。
Jana(軟件架構師)發言:
該團隊將把數據庫分為兩個部分,並在旅程的后期將探索其他的選擇來從規范化的寫端推送數據到反規范化的讀端。有關使用Azure blob存儲而不是SQL表存儲讀取端數據的示例,請參見SeatAssignmentsViewModelGenerator類。
在數據庫存儲反規范化的視圖
存儲讀端數據的一個常見選項是使用一組關系數據庫表來保存。您應該優化讀取端以實現快速讀取,因此存儲規范化數據通常沒有任何好處,因為這將需要復雜的查詢來為客戶端構造數據。這意味着讀取端的目標應該是使查詢盡可能簡單,並以能夠快速有效地讀取的方式在數據庫中構建表。
Gary(CQRS專家)發言:
當人們選擇使用CQRS模式時,可伸縮的應用程序和響應式UI通常是明確的目標。優化讀端以提供對查詢的快速響應,同時保持資源利用率較低,這將幫助您實現這些目標。
Jana(軟件架構師)發言:
由於表連接操作過多,規范化數據庫模式可能無法提供足夠快的響應時間。盡管關系數據庫技術有所進步,但是與單表讀取相比,JOIN操作仍然非常昂貴。
譯者注:讀取端/查詢端通常就是所說的前端UI,如果使用關系型數據庫的關系表來存儲UI層要展現的頁面數據。每次讀取都需要做連接查詢或多次查詢。所以把讀取端需要的數據保存為反規范的數據可以實現快速讀取。這個反規范化(denormalised)可以簡單理解為,拋棄關系型數據庫的關系,存儲非關系型的數據。
一個需要重要考慮的地方就是讀取端用來查詢數據的接口。讀取端就如ASP.Net MVC程序Controller的Action里發起的查詢請求。
在下圖中,讀取端(如MVC Controller里的Action)調用ViewRepository類上的方法來請求它需要的數據。然后,ViewRepository類對數據庫中的非規范化數據運行查詢。
Jana(軟件架構師)發言:
倉儲(Repository)模式使用類似集合的接口在領域和數據映射層之間進行轉換,以訪問領域對象。有關更多信息,請參考Martin Fowler,Catalog of Patterns of Enterprise Application Architecture,Repository。
Contoso的團隊評估了實現ViewRepository類的兩種方法:使用IQueryable接口和使用非通用的數據訪問對象(DAOs)。
使用IQueryable接口
ViewRepository類考慮的一種方法是讓它返回一個IQueryable實例,該實例允許客戶端使用LINQ來指定其查詢。返回IQueryable實例很簡單,很多ORM框架都可以,例如Entity Framework或NHibernate,下面的代碼片段演示了客戶端如何做此類查詢。
var ordersummary = repository.Query<OrderSummary>().Where(LINQ query to retrieve order summary);
var orderdetails = repository.Query<OrderDetails>().Where(LINQ query to retrieve order details);
這種方法有幾個優點:
簡單
- 這種方法在底層數據庫上使用一個薄的抽象層。許多ORM都支持這種方法,它將您必須編寫的代碼量降到最低。
- 您只需要定義一個倉儲和一個查詢方法。
- 您不需要單獨的查詢對象。在讀端,查詢應該很簡單,因為您已經對寫端數據進行了反規范化,以支持讀端。
- 可以使用LINQ在客戶端上提供對過濾、分頁和排序等特性的支持。
可測試性
- 您可以使用LINQ to object進行Mocking。
Markus(軟件開發人員)發言:
在參考實現(RI)中,我們使用Entity Framework,我們根本不需要編寫任何代碼來獲取IQueryable實例。我們也只有一個ViewRepository類。
可能有人反對這個方法,包括:
- 把數據存儲層替換為非關系型數據庫將很不容易,因為需要提供IQueryable實例。但無論如何,您總是可以為不同的限界上下文選擇使用適合的,不同的讀取端實現方式。
- 客戶端在執行操作的時候可能會濫用IQueryable接口,您應該確保非規范化的數據完全滿足客戶的需求。
- 使用IQueryable接口隱藏了查詢辦法。但是,由於在寫端對數據進行過反規范化,因此對關系數據庫表的查詢沒辦法做更復雜的查詢。
- 很難知道您的集成測試是否覆蓋了查詢方法的所有不同用途。
使用非通用DAOs
另一種方法是讓ViewRepository暴露出一個Find方法和一個Get方法,如下面的代碼片段所示。
var ordersummary = dao.FindAllSummarizedOrders(userId);
var orderdetails = dao.GetOrderDetails(orderId);
您還可以選擇使用不同的DAO類。這將使訪問不同數據源變得更容易。
var ordersummary = OrderSummaryDAO.FindAll(userId);
var orderdetails = OrderDetailsDAO.Get(orderId);
這種方法有幾個優點:
簡單
- 對客戶端來說,依賴關系更加清晰。例如,客戶端引用一個顯式的IOrderSummaryDAO實例,而不是一個通用的IViewRepository實例。
對於大多數查詢,只有一到兩種預定義的訪問對象的方法。不同的查詢通常返回不同的投射。
靈活性
- Get和Find方法隱藏了數據存儲分區的細節,還隱藏了使用ORM或顯式執行SQL代碼等數據訪問方法。這使得將來更容易改變這些選擇。
Get和Find方法可以使用ORM、LINQ和IQueryable接口在背后從數據存儲中獲取數據。這是一個選擇,您可以建立在一個方法接一個方法的基礎上。
性能
- 您可以輕松地優化Find和Get方法運行的查詢。數據訪問層執行所有查詢。客戶端沒有任何風險試圖去做復雜的效率低的查詢。
可測試性
- 為Find和Get方法創建單元測試要比為客戶端所有可能的LINQ查詢范圍創建合適的單元測試更容易。
可維護性
- 所有查詢都定義在相同的位置DAO類中,從而更容易一致地修改系統。
對這個方法可能的反對意見包括:
使用IQueryable接口可以更容易地在UI中支持分頁、過濾和排序等功能。無論如何,如果開發人員意識到這一缺點並盡力交付基於任務的UI,那么這應該不是問題。
把部分已完成的訂單信息提供給讀取端
UI層通過在讀取端查詢模型獲得的訂單數據來顯示。UI顯示給注冊者的部分數據是關於部分已完成訂單的信息:訂單中的每種座位類型,請求的座位數量和可用的座位數量。這是系統僅在注冊者使用UI創建訂單時使用的臨時數據。企業只需要存儲關於實際購買座位的信息,而不需要存儲注冊者請求的座位和注冊者購買的座位之間的差異。
這樣做的結果是,關於注冊者請求多少座位的信息只需要存在於讀取端模型中。
Jana(軟件架構師)發言:
您不能將此信息存儲在HTTP Session中,因為注冊者可能在請求座位和完成訂單之間離開站點。
進一步的結果是,讀端的底層存儲不能是簡單的SQL視圖,因為它包含的數據沒有存儲在寫端的底層表存儲中。因此,必須使用事件將此信息傳遞給讀取方。
下面的架構圖顯示了訂單(Order)和可用座位(SeatsAvailability)聚合使用的所有命令和事件,以及訂單(Order)聚合如何通過引發事件將更改推送到讀取端。
OrderViewModelGenerator類處理OrderPlaced、OrderUpdated、OrderPartiallyReserved、OrderRegistrantAssigned和OrderReservationCompleted事件,並使用DraftOrder和DraftOrderItem實例將更改持久化到視圖表中。
Gary(CQRS專家)發言:
如果您提前閱讀第5章“准備發布V1版本”,您將看到團隊擴展了事件的使用,並遷移了訂單和注冊上下文,以使用事件源。
CQRS命令校驗
在實現寫模型時,應該盡量確保命令很少失敗。這將提供最佳的用戶體驗,並使您的應用程序更容易實現異步行為。
團隊采用的一種方法是使用ASP.NET MVC中的模型驗證功能。
您應該小心區分系統錯誤和業務錯誤。系統錯誤的例子包括:
- 由於消息傳遞基礎設施出現故障,無法傳遞消息。
- 由於與數據庫的連接問題,數據沒有持久化。
在許多情況下,特別是在雲中,您可以通過重試操作來處理這些錯誤。
Markus(軟件開發人員)發言:
來自Microsoft patterns & practices的Transient Fault Handling Application Block的設計目的是使任何Transient Fault更容易實現一致的重試行為。它提供了一組針對Azure SQL數據庫、Azure存儲、Azure緩存和Azure服務總線的內置檢測策略,還允許您定義自己的策略。類似地,它提供了一組方便的內置重試策略,並支持自定義策略。更多信息請參見The Transient Fault Handling Application Block
業務錯誤應該有預先定好的邏輯響應。例如:
- 如果系統因為沒有剩余的座位而無法預訂座位,那么它應該將請求添加到等待列表中。
- 如果信用卡支付失敗,用戶應該有機會嘗試另一種信用卡,或者使用發票付款。
Gary(CQRS專家)發言:
您的領域專家應該幫助您識別可能發生的業務失敗,並確定您處理它們的方法:使用自動化流程或手動方式。
倒計時器和讀取模型
向注冊者顯示完成訂單所需時間的倒計時器是系統中的業務的一部分,而不僅僅是基礎設施的一部分。當注冊者創建一個訂單並預訂座位時,倒計時就開始了。即使登記人離開會議網站,倒計時仍在繼續。如果注冊用戶返回網站,UI必須能夠顯示正確的倒計時值,因此,保留過期時間是讀模型中可用數據的一部分。
實現細節
本節描述訂單和注冊限界上下文的實現的一些重要特性。您可能會發現擁有一份代碼副本很有用,這樣您就可以繼續學習了。您可以從Download center下載一個副本,或者在GitHub上查看存儲庫中的代碼:https://github.com/mspnp/cqrs- jourcode
不要期望代碼示例與參考實現中的代碼完全匹配。本章描述了CQRS過程中的一個步驟,但是隨着我們了解更多並重構代碼,實現可能會發生變化。
訂單訪問代碼和記錄定位器
注冊者可能需要檢索訂單,或者查看訂單,或者完成對參會人員座位的分配。這可能發生在不同的web會話中,因此注冊者必須提供一些信息來定位以前保存的訂單。
下面的代碼示例顯示Order類如何生成一個新的五個字符的訂單訪問代碼,該代碼作為Order實例的一部分被持久化。
public string AccessCode { get; set; }
protected Order()
{
...
this.AccessCode = HandleGenerator.Generate(5);
}
要檢索訂單實例,注冊者必須提供其電子郵件地址和訂單訪問代碼。系統將使用這兩項來定位正確的Order。這是讀取端的邏輯。
下面的代碼示例來自web應用程序中的OrderController類,展示了MVC控制器如何使用LocateOrder方法向讀取端提交查詢,以發現唯一的OrderId值。這個Find action將OrderId值傳遞給一個Display action,該action將訂單信息顯示給注冊者。
[HttpPost]
public ActionResult Find(string email, string accessCode)
{
var orderId = orderDao.LocateOrder(email, accessCode);
if (!orderId.HasValue)
{
return RedirectToAction("Find", new { conferenceCode = this.ConferenceCode });
}
return RedirectToAction("Display", new { conferenceCode = this.ConferenceCode, orderId = orderId.Value });
}
倒計時器
當注冊者創建一個訂單並預訂座位時,這些座位將保留一段固定的時間。RegistrationProcessManager實例將預訂從可用座位(SeatsAvailability)聚合中轉發,它將預訂過期的時間傳遞給訂單(Order)聚合。下面的代碼示例顯示訂單(Order)聚合如何接收和存儲預訂過期時間。
public DateTime? ReservationExpirationDate { get; private set; }
public void MarkAsReserved(DateTime expirationDate, IEnumerable<SeatQuantity> seats)
{
...
this.ReservationExpirationDate = expirationDate;
this.Items.Clear();
this.Items.AddRange(seats.Select(seat => new OrderItem(seat.SeatType, seat.Quantity)));
}
Markus(軟件開發人員)發言:
在Order的構造函數中,ReservationExpirationDate最初被設置為在Order實例化后的15分鍾。RegistrationProcessManager類可能會根據實際預訂的時間進行修改。實際時間指的是流程管理器向訂單(Order)聚合發送MarkSeatsAsReserved命令的時間。
當RegistrationProcessManager將MarkSeatsAsReserved命令發送到訂單(Order)聚合(攜帶UI將顯示的過期時間)時,它還向自己發送一條命令,以啟動釋放預訂座位的過程。這個ExpireRegistrationProcess命令在過期區間加上一個5分鍾的緩沖來保存。這個緩沖是為了確保服務器之間的時間差不會導致RegistrationProcessManager類在UI中的倒計時器清零之前就釋放預留的座位。下面的代碼示例展示RegistrationProcessManager類,UI使用MarkSeatsAsReserved命令中的Expiration屬性來顯示倒計時器,而ExpireRegistrationProcess命令中的Delay屬性確定何時釋放保留的座位。
public void Handle(SeatsReserved message)
{
if (this.State == ProcessState.AwaitingReservationConfirmation)
{
var expirationTime = this.ReservationAutoExpiration.Value;
this.State = ProcessState.ReservationConfirmationReceived;
if (this.ExpirationCommandId == Guid.Empty)
{
var bufferTime = TimeSpan.FromMinutes(5);
var expirationCommand = new ExpireRegistrationProcess { ProcessId = this.Id };
this.ExpirationCommandId = expirationCommand.Id;
this.AddCommand(new Envelope<ICommand>(expirationCommand)
{
Delay = expirationTime.Subtract(DateTime.UtcNow).Add(bufferTime),
});
}
this.AddCommand(new MarkSeatsAsReserved
{
OrderId = this.OrderId,
Seats = message.ReservationDetails.ToList(),
Expiration = expirationTime,
});
}
...
}
MVC項目中的RegistrationController類在讀取端檢索訂單信息。DraftOrder類包含控制器使用ViewBag類傳遞給視圖的預約過期時間,如下面的代碼示例所示。
[HttpGet]
public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId)
{
var repo = this.repositoryFactory();
using (repo as IDisposable)
{
var draftOrder = repo.Find<DraftOrder>(orderId);
var conference = repo.Query<Conference>()
.Where(c => c.Code == conferenceCode)
.FirstOrDefault();
this.ViewBag.ConferenceName = conference.Name;
this.ViewBag.ConferenceCode = conference.Code;
this.ViewBag.ExpirationDateUTCMilliseconds =
draftOrder.BookingExpirationDate.HasValue ?
((draftOrder.BookingExpirationDate.Value.Ticks - EpochTicks) / 10000L) : 0L;
this.ViewBag.OrderId = orderId;
return View(new AssignRegistrantDetails { OrderId = orderId });
}
}
然后MVC的視圖使用JavaScript顯示動畫倒計時器。
使用ASP.NET MVC validation來驗證命令
您應該確保應用程序中的MVC控制器發送給寫模型的任何命令都將成功。在將命令發送到寫模型之前,可以使用MVC中的特性在客戶端和服務器端驗證命令。
Markus(軟件開發人員)發言:
客戶端驗證對用戶來說主要是比較方便,因為它不用往返於服務器就可以幫助用戶正確完成表單填寫。但您仍然需要實現服務器端驗證,以確保在將數據轉發到寫模型之前對其進行過驗證。
下面的代碼示例顯示了AssignRegistrantDetails命令類,它使用DataAnnotations指定驗證需求;在本例中,要求FirstName、LastName和Email字段不為空。
using System;
using System.ComponentModel.DataAnnotations;
using Common;
public class AssignRegistrantDetails : ICommand
{
public AssignRegistrantDetails()
{
this.Id = Guid.NewGuid();
}
public Guid Id { get; private set; }
public Guid OrderId { get; set; }
[Required(AllowEmptyStrings = false)]
public string FirstName { get; set; }
[Required(AllowEmptyStrings = false)]
public string LastName { get; set; }
[Required(AllowEmptyStrings = false)]
public string Email { get; set; }
}
MVC視圖使用這個命令類作為它的模型類。下面的代碼示例來自SpecifyRegistrantDetails.cshtml文件,它顯示了如何填充模型。
@model Registration.Commands.AssignRegistrantDetails
...
<div class="editor-label">@Html.LabelFor(model => model.FirstName)</div><div class="editor-field">@Html.EditorFor(model => model.FirstName)</div>
<div class="editor-label">@Html.LabelFor(model => model.LastName)</div><div class="editor-field">@Html.EditorFor(model => model.LastName)</div>
<div class="editor-label">@Html.LabelFor(model => model.Email)</div><div class="editor-field">@Html.EditorFor(model => model.Email)</div>
Web.config文件根據DataAnnotations屬性配置客戶端驗證,如下面的代碼片段所示:
<appSettings>
...
<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />
</appSettings>
服務器端驗證發生在發送命令之前的控制器中。下面來自RegistrationController類的代碼示例展示了控制器如何使用IsValid屬性來驗證命令。請記住,這個示例使用的是命令的一個實例作為模型。
[HttpPost]
public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId, AssignRegistrantDetails command)
{
if (!ModelState.IsValid)
{
return SpecifyRegistrantDetails(conferenceCode, orderId);
}
this.commandBus.Send(command);
return RedirectToAction("SpecifyPaymentDetails", new { conferenceCode = conferenceCode, orderId = orderId });
}
有關其他示例,請參見RegistrationController類中的RegisterToConference命令和StartRegistration action方法。
更多信息,請參考MSDN上的Models and Validation in ASP.NET MVC 。
推送更新到讀端
關於訂單的一些信息只需要存在於讀取端。特別是,關於部分已完成訂單的信息只在UI中使用,而不是寫端領域模型保存的業務信息的一部分。
這意味着系統不能使用SQL視圖作為讀取端上的底層存儲機制,因為視圖不包含它們所基於的表中不存在的數據。
系統將非規范化的訂單數據存儲在SQL數據庫實例中的兩個表中:OrdersView和OrderItemsView表。OrderItemsView表包含RequestedSeats列,該列包含僅存在於讀取端上的數據。
OrdersView表
列 | 說明 |
---|---|
OrderId | Order的唯一ID |
ReservationExpirationDate | 預訂座位的過期時間 |
StateValue | 訂單的狀態,包括:Created, PartiallyReserved, ReservationCompleted, Rejected, Confirmed |
RegistrantEmail | 預訂時填寫的Email地址 |
AccessCode | 訂單的訪問碼 |
OrderItemsView
列 | 說明 |
---|---|
OrderItemId | 訂單項的唯一ID |
SeatType | 預訂的座位類型 |
RequestedSeats | 請求預訂座位的數量 |
ReservedSeats | 預留座位的數量 |
OrderId | 關聯的父Order的ID |
要將這些表填充到讀模型中,讀端需要處理由寫端引發的事件,用它們對這些表進行寫操作。有關詳細信息,請參見上面章節中的架構圖。
OrderViewModelGenerator類處理這些事件並更新讀端存儲庫。
public class OrderViewModelGenerator :
IEventHandler<OrderPlaced>, IEventHandler<OrderUpdated>,
IEventHandler<OrderPartiallyReserved>, IEventHandler<OrderReservationCompleted>,
IEventHandler<OrderRegistrantAssigned>
{
private readonly Func<ConferenceRegistrationDbContext> contextFactory;
public OrderViewModelGenerator(Func<ConferenceRegistrationDbContext> contextFactory)
{
this.contextFactory = contextFactory;
}
public void Handle(OrderPlaced @event)
{
using (var context = this.contextFactory.Invoke())
{
var dto = new DraftOrder(@event.SourceId, DraftOrder.States.Created)
{
AccessCode = @event.AccessCode,
};
dto.Lines.AddRange(@event.Seats.Select(seat => new DraftOrderItem(seat.SeatType, seat.Quantity)));
context.Save(dto);
}
}
public void Handle(OrderRegistrantAssigned @event)
{
...
}
public void Handle(OrderUpdated @event)
{
...
}
public void Handle(OrderPartiallyReserved @event)
{
...
}
public void Handle(OrderReservationCompleted @event)
{
...
}
...
}
下面的代碼示例展示ConferenceRegistrationDbContext類:
public class ConferenceRegistrationDbContext : DbContext
{
...
public T Find<T>(Guid id) where T : class
{
return this.Set<T>().Find(id);
}
public IQueryable<T> Query<T>() where T : class
{
return this.Set<T>();
}
public void Save<T>(T entity) where T : class
{
var entry = this.Entry(entity);
if (entry.State == System.Data.EntityState.Detached)
this.Set<T>().Add(entity);
this.SaveChanges();
}
}
Jana(軟件架構師)發言:
注意,讀端中的這個ConferenceRegistrationDbContext類包含一個Save方法,以保存從寫端發送的更改,並通過OrderViewModelGenerator類來調用。
在讀端查詢
下面的代碼示例顯示了一個非通用的DAO類,MVC控制器使用該類在讀端查詢會議信息。它封裝了前面展示的ConferenceRegistrationDbContext類。
public class ConferenceDao : IConferenceDao
{
private readonly Func<ConferenceRegistrationDbContext> contextFactory;
public ConferenceDao(Func<ConferenceRegistrationDbContext> contextFactory)
{
this.contextFactory = contextFactory;
}
public ConferenceDetails GetConferenceDetails(string conferenceCode)
{
using (var context = this.contextFactory.Invoke())
{
return context
.Query<Conference>()
.Where(dto => dto.Code == conferenceCode)
.Select(x => new ConferenceDetails { Id = x.Id, Code = x.Code, Name = x.Name, Description = x.Description, StartDate = x.StartDate })
.FirstOrDefault();
}
}
public ConferenceAlias GetConferenceAlias(string conferenceCode)
{
...
}
public IList<SeatType> GetPublishedSeatTypes(Guid conferenceId)
{
...
}
}
Jana(軟件架構師)發言:
注意,這個ConferenceDao類只包含返回數據的方法。MVC控制器使用它來檢索要在UI中顯示的數據。
重構可用座位(SeatsAvailability)聚合
在我們CQRS之旅的第一階段,領域包含一個ConferenceSeatsAvailabilty聚合根類,這是對會議剩余座位數量進行的建模。在旅程的現在這個階段,團隊將ConferenceSeatsAvailabilty聚合替換為SeatsAvailability,以反映特定會議可能有多種座位類型。例如,完整會議的席位、會前研討會的席位和雞尾酒會的席位。下圖顯示了新的SeatsAvailability聚合及其組成類。
這個聚合反應了下面兩個模型:
- 一個會議可能有多種座位類型。
- 每個座位類型可能有不同的座位數量。
領域現在包括一個SeatQuantity值類型,您可以使用它來表示特定座椅類型的數量。
之前,聚合會根據是否有足夠的座位數量來引發ReservationAccepted或ReservationRejected事件,現在,聚合引發一個SeatsReserved事件,該事件報告它可以預訂多少個特定類型的座位。這意味着預留的座位數目可能與所要求的座位數目不相符。此信息被傳遞回UI,以便注冊者決定如何繼續預訂。
AddSeats方法
您可能在最上面的架構圖中注意到,SeatsAvailability聚合包含一個AddSeats方法,但沒有相應的命令。AddSeats方法調整給定類型的可用座位總數。業務客戶負責進行任何此類調整,並在Conference Management限界上下文中進行。當可用座位總數發生更改時,Conference Management限界上下文將引發事件。然后,SeatsAvailability類在其處理程序中調用AddSeat方法來處理事件。
對測試的影響
本節將討論在現在這個階段解決的一些測試問題。
驗收測試和領域專家
在第3章“訂單和注冊限界上下文”中,您看到了一些UI原型,開發人員和領域專家一起工作,以改進系統的一些功能需求。這些UI原型的計划用途之一是為系統形成一組驗收測試的基礎。
對於驗收測試方法,團隊有以下目標:
- 驗收測試應該以領域專家能夠理解的格式清楚地表達出來。
- 應該可以自動執行驗收測試。
為了實現這些目標,領域專家與測試團隊的成員配對,並使用SpecFlow來指定核心驗收測試。
使用SpecFlow feature來定義驗收測試
使用SpecFlow定義驗收測試的第一步是使用SpecFlow notation。這些測試被保存為feature文件在一個Visual Studio項目中。以下代碼示例來自於ConferenceConfiguration.feature文件,該文件在Features\UserInterface\Views\Management文件夾下。它顯示了Conference Management限界上下文的驗收測試。典型的SpecFlow測試場景由一組Given、When和Then語句組成。其中一些語句包含測試使用的數據。
Markus(軟件開發人員)發言:
事實上,SpecFlow feature文件使用Gherkin語言,這是一種專門為行為描述創建的領域特定語言(DSL)。
Feature: Conference configuration scenarios for creating and editing Conference settings
In order to create or update a Conference configuration
As a Business Customer
I want to be able to create or update a Conference and set its properties
Background:
Given the Business Customer selected the Create Conference option
Scenario: An existing unpublished Conference is selected and published
Given this conference information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | william@fabrikam.com | CQRS2012P | CQRS summit 2012 conference (Published) | random | 05/02/2012 | 05/12/2012 |
And the Business Customer proceeds to create the Conference
When the Business Customer proceeds to publish the Conference
Then the state of the Conference changes to Published
Scenario: An existing Conference is edited and updated
Given an existing published conference with this information
| Owner | Email | Name | Description | Slug | Start | End |
| William Flash | william@fabrikam.com | CQRS2012U | CQRS summit 2012 conference (Original) | random | 05/02/2012 | 05/12/2012 |
And the Business Customer proceeds to edit the existing settings with this information
| Description |
| CQRS summit 2012 conference (Updated) |
When the Business Customer proceeds to save the changes
Then this information appears in the Conference settings
| Description |
| CQRS summit 2012 conference (Updated) |
...
Carlos(領域專家)發言:
我發現這些驗收測試是我向開發人員闡明系統預期行為定義的好方法。
有關其他示例,請參見源代碼里的Conference.AcceptanceTests解決方案
讓測試可執行
feature文件中的驗收測試不能直接執行。您必須提供一些管道代碼來連接SpecFlow feature文件和應用程序。
有關實現的示例,請參見源代碼Conference.AcceptanceTests解決方案下的Conference.Specflow項目下的Steps文件夾中的類。
這些步驟使用兩種不同的方法實現
第一種運行測試的方法是模擬系統的一個用戶,它通過使用第三方開源庫WatiN直接驅動web瀏覽器來實現。這種方法的優點是,它運行系統的方式和實際用戶與系統交互的的方式完全相同,並且最初實現起來很簡單。然而,這些測試是脆弱的,將需要大量的維護工作來保持它們在UI和系統更改后也會更新成最新的。下面的代碼示例展示了這種方法的一個示例,定義了前面所示的feature文件中的一些Given、When和Then步驟。SpecFlow使用Given、When和Then標記把步驟和feature文件中的子句鏈接起來,並把它當做參數值傳遞給測試方法:
public class ConferenceConfigurationSteps : StepDefinition
{
...
[Given(@"the Business Customer proceeds to edit the existing settings with this information")]
public void GivenTheBusinessCustomerProceedToEditTheExistingSettignsWithThisInformation(Table table)
{
Browser.Click(Constants.UI.EditConferenceId);
PopulateConferenceInformation(table);
}
[Given(@"an existing published conference with this information")]
public void GivenAnExistingPublishedConferenceWithThisInformation(Table table)
{
ExistingConferenceWithThisInformation(table, true);
}
private void ExistingConferenceWithThisInformation(Table table, bool publish)
{
NavigateToCreateConferenceOption();
PopulateConferenceInformation(table, true);
CreateTheConference();
if(publish) PublishTheConference();
ScenarioContext.Current.Set(table.Rows[0]["Email"], Constants.EmailSessionKey);
ScenarioContext.Current.Set(Browser.FindText(Slug.FindBy), Constants.AccessCodeSessionKey);
}
...
[When(@"the Business Customer proceeds to save the changes")]
public void WhenTheBusinessCustomerProceedToSaveTheChanges()
{
Browser.Click(Constants.UI.UpdateConferenceId);
}
...
[Then(@"this information appears in the Conference settings")]
public void ThenThisInformationIsShowUpInTheConferenceSettings(Table table)
{
Assert.True(Browser.SafeContainsText(table.Rows[0][0]),
string.Format("The following text was not found on the page: {0}", table.Rows[0][0]));
}
private void PublishTheConference()
{
Browser.Click(Constants.UI.PublishConferenceId);
}
private void CreateTheConference()
{
ScenarioContext.Current.Browser().Click(Constants.UI.CreateConferenceId);
}
private void NavigateToCreateConferenceOption()
{
// Navigate to Registration page
Browser.GoTo(Constants.ConferenceManagementCreatePage);
}
private void PopulateConferenceInformation(Table table, bool create = false)
{
var row = table.Rows[0];
if (create)
{
Browser.SetInput("OwnerName", row["Owner"]);
Browser.SetInput("OwnerEmail", row["Email"]);
Browser.SetInput("name", row["Email"], "ConfirmEmail");
Browser.SetInput("Slug", Slug.CreateNew().Value);
}
Browser.SetInput("Tagline", Constants.UI.TagLine);
Browser.SetInput("Location", Constants.UI.Location);
Browser.SetInput("TwitterSearch", Constants.UI.TwitterSearch);
if (row.ContainsKey("Name")) Browser.SetInput("Name", row["Name"]);
if (row.ContainsKey("Description")) Browser.SetInput("Description", row["Description"]);
if (row.ContainsKey("Start")) Browser.SetInput("StartDate", row["Start"]);
if (row.ContainsKey("End")) Browser.SetInput("EndDate", row["End"]);
}
}
您可以看到這種方法是如何模擬在Web瀏覽器中點擊UI元素並輸入文本的。
第二種測試方法是通過與MVC控制器類交互來實現。長遠的看,這種方法不會那么脆弱,成本就是在最初需要一個更復雜的實現,這需要對系統的內部實現比較熟悉。下面的代碼示例展示了這種方法的一個示例。
首先,在Features\UserInterface\Controllers\Registration文件夾下的SelfRegistrationEndToEndWithControllers.feature文件展示了一個示例場景:
Scenario: End to end Registration implemented using controllers
Given the Registrant proceeds to make the Reservation
And these Order Items should be reserved
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
And these Order Items should not be reserved
| seat type |
| CQRS Workshop |
And the Registrant enters these details
| first name | last name | email address |
| William | Flash | william@fabrikam.com |
And the Registrant proceeds to Checkout:Payment
When the Registrant proceeds to confirm the payment
Then the Order should be created with the following Order Items
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
And the Registrant assigns these seats
| seat type | first name | last name | email address |
| General admission | William | Flash | William@fabrikam.com |
| Additional cocktail party | Jim | Corbin | Jim@litwareinc.com |
And these seats are assigned
| seat type | quantity |
| General admission | 1 |
| Additional cocktail party | 1 |
然后,展示了SelfRegistrationEndToEndWithControllersSteps類里的一些測試步驟:
[Given(@"the Registrant proceeds to make the Reservation")]
public void GivenTheRegistrantProceedToMakeTheReservation()
{
var redirect = registrationController.StartRegistration(
registration, registrationController.ViewBag.OrderVersion) as RedirectToRouteResult;
Assert.NotNull(redirect);
// Perform external redirection
var timeout = DateTime.Now.Add(Constants.UI.WaitTimeout);
while (DateTime.Now < timeout && registrationViewModel == null)
{
//ReservationUnknown
var result = registrationController.SpecifyRegistrantAndPaymentDetails(
(Guid)redirect.RouteValues["orderId"], registrationController.ViewBag.OrderVersion);
Assert.IsNotType<RedirectToRouteResult>(result);
registrationViewModel = RegistrationHelper.GetModel<RegistrationViewModel>(result);
}
Assert.False(registrationViewModel == null, "Could not make the reservation and get the RegistrationViewModel");
}
...
[When(@"the Registrant proceeds to confirm the payment")]
public void WhenTheRegistrantProceedToConfirmThePayment()
{
using (var paymentController = RegistrationHelper.GetPaymentController())
{
paymentController.ThirdPartyProcessorPaymentAccepted(
conferenceInfo.Slug, (Guid) routeValues["paymentId"], " ");
}
}
...
[Then(@"the Order should be created with the following Order Items")]
public void ThenTheOrderShouldBeCreatedWithTheFollowingOrderItems(Table table)
{
draftOrder = RegistrationHelper.GetModel<DraftOrder>(registrationController.ThankYou(registrationViewModel.Order.OrderId));
Assert.NotNull(draftOrder);
foreach (var row in table.Rows)
{
var orderItem = draftOrder.Lines.FirstOrDefault(
l => l.SeatType == conferenceInfo.Seats.First(s => s.Description == row["seat type"]).Id);
Assert.NotNull(orderItem);
Assert.Equal(Int32.Parse(row["quantity"]), orderItem.ReservedSeats);
}
}
您可以看到這種方法是如何直接使用RegistrationController類的。
在這些代碼示例中,您可以看到是怎樣通過標記把SpecFlow feature文件和測試步驟代碼鏈接起來並傳遞參數的。
團隊選擇使用xUnit.net來實現測試步驟,要在Visual Studio里運行這些測試,您可以使用任何支持xUnit的第三方工具例如:ReSharper, CodeRush, TestDriven.NET等。
Jana(軟件架構師)發言:
請記住,這些驗收測試並不是在系統上執行的唯一測試。主要的解決方案里包括全面的單元測試和集成測試,測試團隊還對應用程序進行了探索性和性能測試。
使用測試來幫助開發人員理解消息流
關於使用CQRS模式和大量使用消息,有一個常見說法是這讓人很難理解系統是如何通過發送和接收消息把各個不同的部分配合在一起的。這里您可以通過設計適當的單元測試來幫助別人理解您的基本代碼。
訂單聚合的第一個單元測試示例:
public class given_placed_order
{
...
private Order sut;
public given_placed_order()
{
this.sut = new Order(
OrderId, new[]
{
new OrderPlaced
{
ConferenceId = ConferenceId,
Seats = new[] { new SeatQuantity(SeatTypeId, 5) },
ReservationAutoExpiration = DateTime.UtcNow
}
});
}
[Fact]
public void when_updating_seats_then_updates_order_with_new_seats()
{
this.sut.UpdateSeats(new[] { new OrderItem(SeatTypeId, 20) });
var @event = (OrderUpdated)sut.Events.Single();
Assert.Equal(OrderId, @event.SourceId);
Assert.Equal(1, @event.Seats.Count());
Assert.Equal(20, @event.Seats.ElementAt(0).Quantity);
}
...
}
這個單元測試只是創建一個Order實例,並直接調用UpdateSeats方法。它不向閱讀測試代碼的人提供有關調用此方法中命令或事件的任何信息。
現在看第二個示例,它執行的是相同的測試,但是在本示例中,是通過發送命令來測試的:
public class given_placed_order
{
...
private EventSourcingTestHelper<Order> sut;
public given_placed_order()
{
this.sut = new EventSourcingTestHelper<Order>();
this.sut.Setup(new OrderCommandHandler(sut.Repository, pricingService.Object));
this.sut.Given(
new OrderPlaced
{
SourceId = OrderId,
ConferenceId = ConferenceId,
Seats = new[] { new SeatQuantity(SeatTypeId, 5) },
ReservationAutoExpiration = DateTime.UtcNow
});
}
[Fact]
public void when_updating_seats_then_updates_order_with_new_seats()
{
this.sut.When(new RegisterToConference { ConferenceId = ConferenceId, OrderId = OrderId, Seats = new[] { new SeatQuantity(SeatTypeId, 20) }});
var @event = sut.ThenHasSingle<OrderUpdated>();
Assert.Equal(OrderId, @event.SourceId);
Assert.Equal(1, @event.Seats.Count());
Assert.Equal(20, @event.Seats.ElementAt(0).Quantity);
}
...
}
這個例子使用了一個helper類,它使您能夠向Order實例發送命令。現在,閱讀測試的人可以明白,當您發送RegisterToConference命令時,您期望看到OrderUpdated事件。
代碼理解之旅
喬什·埃爾斯特講述了一個關於痛苦、解脫和學習的故事
本節描述CQRS咨詢委員會成員喬什·埃爾斯在探索Contoso會議管理系統的源代碼時所經歷的過程。
測試是很重要的
我曾經相信,優秀架構的應用程序很容易理解,不管代碼庫有多么龐大。每當我理解應用程序行為功能時遇到問題,都是代碼的問題,而不是我的問題。
永遠不要讓你的自負掩蓋住常識。
事實上,一直到我職業生涯的某個階段,我都還沒有接觸到一個大型的、架構優秀的代碼基本。如果不是它走過來打我的臉,我根本就不知道它是什么樣子。值得慶幸的是,隨着我閱讀代碼的經驗越來越豐富,我學會了區分那些不同。
備注:在任何結構良好的項目中,測試都是開發人員理解項目的基礎。各種命名約定,編碼風格,設計方法和使用模式的主題都包含在測試套件中,為集成到代碼庫提供了一個很好的起點。這也是很好的代碼專業性實踐,熟能生巧!
克隆會議代碼之后,我的第一個動作是瀏覽測試。在閱讀了會議系統Visual Studio解決方案中的集成和單元測試套件之后,我將注意力集中在Conference.AcceptanceTests Visual Studio解決方案上,其中包含SpecFlow驗收測試。項目團隊的其他成員已經對那些.feature文件做了一些初步的工作,由於我不熟悉業務規則的細節,所以對我來說效果很好。把這些feature和代碼綁定是一種很好的方式,既可以為項目做出貢獻,又可以讓人理解系統如何工作。
領域測試
當時我的目標是得到一個像這樣的feature文件:
Feature: Self Registrant scenarios for making a Reservation for a Conference site with all Order Items initially available
In order to reserve Seats for a conference
As an Attendee
I want to be able to select an Order Item from one or many of the available Order Items and make a Reservation
Background:
Given the list of the available Order Items for the CQRS Summit 2012 conference with the slug code SelfRegFull
| seat type | rate | quota |
| General admission | $199 | 100 |
| CQRS Workshop | $500 | 100 |
| Additional cocktail party | $50 | 100 |
And the selected Order Items
| seat type | quantity |
| General admission | 1 |
| CQRS Workshop | 1 |
| Additional cocktail party | 1 |
Scenario: All the Order Items are available and all get reserved
When the Registrant proceeds to make the Reservation
Then the Reservation is confirmed for all the selected Order Items
And these Order Items should be reserved
| seat type |
| General admission |
| CQRS Workshop |
| Additional cocktail party |
And the total should read $749
And the countdown started
並將其綁定到執行操作、創建期望或作出斷言的代碼:
[Given(@"the '(.*)' site conference")]
public void GivenAConferenceNamed(string conference)
{
...
}
所有這些都位於"UI之下",但是在基礎概念之上。測試緊密關注整個解決方案領域的行為,這就是為什么我將這些類型的測試稱為領域測試。其他術語,如行為驅動開發(BDD),可以用來描述這種類型的測試。
Jana(軟件架構師)發言:
這些“UI之下”測試也被稱為皮下測試(參見Meszaros, G。Melnik, G的Acceptance Test Engineering Guide)。
重寫一遍已經在網站上實現的應用程序邏輯似乎有點多余,但是有以下幾個原因值得花時間:
- 您(由於某些原因)對網站或任何其他基礎設施部分的行為測試不感興趣。你只對領域有興趣,單元級和集成級的測試將驗證代碼的功能是否正確,因此不需要重復這些測試。
- 當與產品所有者迭代用戶故事時,將時間花在純粹的UI關注點上會拖慢反饋周期,降低反饋的質量和有用性。
- 考慮到不同的人在討論技術問題時使用的詞匯之間有時會出現很大的不匹配,用更抽象的術語討論一個功能可以更好的理解業務試圖解決的問題。
- 在實現測試邏輯時遇到的障礙可以幫助提高系統的總體設計質量。基礎設施代碼與應用程序邏輯難以分離通常被視為一種壞味道。
備注:為什么這些類型的測試是一個好主意?還有更多的原因沒有列出來,但是對於本例來說,這里列出的是那些重要的原因。
Contoso會議管理系統的體系結構是松耦合的,利用消息將命令和事件傳遞給相關方。命令通過命令總線路由到單個處理程序,而事件則通過事件總線路由到它們的1個或多個處理程序。就消費應用程序而言,總線不綁定任何特定的技術,允許以對用戶透明的方式在整個系統中創建和使用任意的實現。
當涉及到松耦合消息體系結構的行為測試時,另一個好處是BDD(或類似風格的)測試本身不涉及應用程序代碼的內部工作。它們只關心被測試程序的可觀察行為。這意味着對於SpecFlow測試,我們只需要將一些命令發布到總線,並通過根據實際的流量/數據斷言預期的消息流量和有效負載來檢查外部結果。
備注:在適當的地方,可以使用mock和stub來進行這些類型的測試。一個適當的例子是使用mock出來的ICommandBus對象而不是真正的AzureCommandBus類型。但mock一個完整的領域服務是不合適的例子。盡量少的使用mock,只把它限制在基礎設施方面,這樣你的生活和測試壓力都會小很多。
另一種情況
我剛剛花費了很多來描述事情是多么的棒和簡單,哪里有痛苦呢?痛苦在於理解一個系統中發生了什么。松耦合的體系結構也有不好的一面:控制反轉和依賴注入等技術從本質上阻礙了代碼的可讀性,因為如果不仔細檢查容器的初始化,就永遠無法確定在特定的點注入了什么具體的類。在journey的代碼中,IProcess接口是一種表示長時間運行的業務流程(也稱為Sagas或流程管理器)的類,這些類負責協調不同聚合之間的業務邏輯。為了維護系統數據和狀態的完整性、冪等性和事務性,它發出的命令的實際發送是各個持久化倉儲來實現的。由於控制反轉和依賴注入對消費者隱藏了這些類型的詳細信息,所以它和系統的一些其他屬性會造成一點困難在回答一些表面上瑣碎的問題時,比如:
- 誰會發出或已發出了特定的命令或事件?
- 什么樣的類處理特定的命令或事件?
- 流程或聚合在哪里創建或持久化?
- 什么時候發出與其他命令或事件相關的命令?
- 為什么系統會這樣運行?
- 應用程序的狀態如何由特定的命令改變?
由於應用程序的依賴關系非常松散,許多傳統的代碼分析工具和方法要么變得不那么有用,要么完全沒用。
讓我們以RegistrationProcessManager作為示例,列出一些涉及到回答這些問題的啟發式內容。
-
打開RegistrationProcessManager.cs文件,注意,與許多流程管理器一樣,它有一個ProcessState枚舉。我們注意進程的開始狀態:NotStarted。接下來,我們要找到做下面事情之一的代碼:
- 創建流程的新實例(流程在哪里創建或持久化?)
- 初始狀態被更改為不同的狀態(狀態如何更改?)
-
找到源代碼中出現上述任何一種情況或同時出現上述兩種情況的代碼位置。在本例中,它是RegistrationProcessManagerRouter類中的Handle方法。重要提示:這並不一定意味着該流程是一個命令處理程序!流程管理器負責從存儲中創建和檢索聚合根(AR),以便將消息路由到AR,因此盡管它們的方法在名稱和簽名上與ICommandHandler實現類似,但它們並不實現處理命令的邏輯。
-
請注意當狀態發生變化時接收到的消息類型是作為方法參數被傳入的,因此我們現在需要找出消息的來源。
- 我們還將注意到,RegistrationProcessManager發出了一個新的命令:MakeSeatReservation。
- 如上所述,這個命令實際上不是由發出它的進程發出的,相反,是當進程保存到磁盤時,才會發出。
- 對於其他任何作為進程處理命令的副作用的,被發出的命令,需要一定程度的重復這些啟發。
-
查找OrderPlaced的引用,找到一個或多個頂部(外部)組件,這些組件通過ICommandBus接口上的Send方法發出該類型的消息。
- 由於內部發出的命令是在倉儲的Save方法里,所以可以安全地假設直接調用Send方法的任何非基礎設施邏輯都是外部入口點。
雖然啟發式的內容肯定比這里所提到的要多,但是這里的這些內容很可能足夠證明了。即使討論交互也是一個相當漫長、繁瑣的過程。這很容易造成誤解。您可以通過這種方式理解各種命令/事件消息傳遞交互,但是這種方式不是很有效。
備注:一般來說,一個人在任何時候都只能在腦子里保持四到八個不同的想法。為了說明這一概念,讓我們保守地計算一下你需要在短期記憶中同時保持的東西的數量,同時遵循上面的啟發:
進程類型+進程狀態屬性+初始狀態(NotStarted) + new()的位置+消息類型+中間路由類類型+ 2 *N^ N命令發出(位置、類型、步驟)+判別規則(邏輯也是數據!) > 8
當基礎設施需求混合到等式中時,信息飽和的問題會變得更加明顯。作為我們都是有能力的開發人員(對吧?),我們可以開始尋找方法來優化這些步驟,並提高相關信息的信噪比。
總之,我們有兩個問題:
- 我們被迫記在腦子里的東西太多,無法有效理解。
- 用於消息傳遞交互的討論和文檔冗長、容易出錯且復雜。
幸運的是,使用MIL(消息傳遞中間語言)可以一舉兩得。
MIL一開始是一系列LINQPad腳本和代碼片段,我創建這些腳本和代碼片段是為了在回答問題時幫助處理所有事情。最初,這些腳本完成的所有工作都是通過一個或多個項目程序集反映並輸出各種類型的消息和處理程序。在與團隊成員的討論中,很明顯其他人也遇到了與我相同的問題。在與模式和實踐團隊成員進行了幾次聊天和頭腦風暴會議之后,我們提出了引入一種小型領域特定語言(DSL)的想法,該語言將封裝所討論的交互。暫時命名為SawMIL toolbox,它位於http://jelster.github.com/CqrsMessagingTools/,它提供了實用工具、腳本和示例,使您能夠將MIL用作開發和分析流程管理器的一部分。
在MIL中,消息傳遞組件和交互以特定的方式表示:命令(因為它們是系統執行某些操作的請求)用?表示,比如DoSomething?。事件表示系統中發生的確定的事情,因此獲得一個!后綴,如SomethingHappened!
MIL的另一個重要元素是消息發布和接收。從消息源(如Azure服務總線、NServiceBus等)接收的消息總是在前面加上“->”符號,為了讓示例暫時保持簡單,有一個可選的nil元素(句號.)。用於顯式地指示no-op(換句話說,沒有接收到任何消息)。下面的代碼片段展示了nil元素語法的一個例子:
SendCustomerInvoice? -> .
CustomerInvoiceSent! -> .
一旦發布了命令或事件,就需要對其進行處理。命令只有一個處理程序,而事件可以有多個處理程序。MIL通過將處理程序的名稱放在消息傳遞操作的另一側來表示消息與處理程序之間的這種關系,如下面的代碼片段所示:
SendCustomerInvoice? -> CustomerInvoiceHandler
CustomerInvoiceSent! ->
-> CustomerNotificationHandler
-> AccountsAgeingViewModelGenerator
注意,命令和命令處理程序位於同一行,是因為命令和命令處理程序是1對1的。事件因為可能有多個事件處理程序,所以把他們放到多行上。
聚合根以@符號作為前綴,使用過twitter的人都會很熟悉它。聚合根從不處理命令,但偶爾可能處理事件。聚合根是最常見的事件源,它引發事件以響應在聚合上調用的業務操作。但是,關於這些事件應該清楚的一點是,在大多數系統中,有其他元素決定並實際執行領域事件的發布。這是一個有趣的案例,其中業務和技術需求模糊了邊界,由基礎設施邏輯而不是應用程序或業務邏輯來滿足需求。旅程代碼就是一個例子:為了確保事件源和事件訂閱者之間的一致性,持久化聚合根的存儲庫的實現才是負責將事件實際發布到總線的。下面的代碼片段顯示了AggregateRoot語法的一個示例:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! -> .
在上面的示例中,一個名為Scope上下文操作符的新語言元素出現在@AggregateRoot旁邊。范圍上下文元素由雙冒號(::)表示,它的兩個字符之間可能有空格,也可能沒有空格,用於標識兩個對象之間的關系。上面,聚合根 '@Invoice'生成CustomerSent!事件來響應CustomerInvoiceHandler調用的邏輯。下一個例子演示了在聚合根上使用Scope元素,它生成多個事件來響應單個命令:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice:
:CustomerInvoiceSent! -> .
:InvoiceAged! -> .
Scope上下文還用於表示不涉及基礎設施消息傳遞設備的元素內路由:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! ->
-> InvoiceAgeingProcessRouter::InvoiceAgeingProcess
我將介紹的最后一個元素是State Change。狀態變化是跟蹤系統中發生的事情的最好方法之一,因此MIL將它們視為一等公民。這些語句必須出現在它們自己的文本行中,並以“*”字符作為前綴。這是MIL中唯一一次提到或出現任務,因為它非常重要!下面的代碼片段顯示了State Change元素的一個例子:
SendCustomerInvoice? -> CustomerInvoiceHandler
@Invoice::CustomerInvoiceSent! ->
-> InvoiceAgegingProcessRouter::InvoiceAgeingProcess
*InvoiceAgeingProcess.ProcessState = Unpaid
總結
我們剛剛介紹了在松耦合應用程序中描述消息傳遞交互時使用的基本步驟。盡管所描述的交互只是可能交互的子集,但是MIL正在發展成為一種簡潔地描述基於消息的系統交互的方法。不同的名詞和動詞(元素和動作)由不同的、有記憶意義的符號表示。這提供了一種跨基板(粘糊糊的人腦< - >硅CPU)的方法來通信有關整個系統的有意義的信息。盡管該語言很好地描述了某些類型的消息傳遞交互,但它仍然是一項正在進行的工作,需要開發或改進該語言的許多元素和工具。這提供了一些很好的機會去為OSS貢獻代碼,如果你一直在觀望或思考參與OSS去貢獻代碼,沒有時間猶豫了,現在就去http://jelster.github.com/CqrsMessagingTools/,fork倉庫,馬上開始吧!