天天看點

CQRS之旅——旅程4(擴充和增強訂單和注冊限界上下文)

旅程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)聚合如何通過引發事件将更改推送到讀取端。

CQRS之旅——旅程4(擴充和增強訂單和注冊限界上下文)

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聚合及其組成類。

CQRS之旅——旅程4(擴充和增強訂單和注冊限界上下文)

這個聚合反應了下面兩個模型:

  • 一個會議可能有多種座位類型。
  • 每個座位類型可能有不同的座位數量。

領域現在包括一個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 | [email protected] | 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 | [email protected] | 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     | [email protected] |
    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     | [email protected]   |
    | Additional cocktail party | Jim        | Corbin   | [email protected]     |
    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作為示例,列出一些涉及到回答這些問題的啟發式内容。

  1. 打開RegistrationProcessManager.cs檔案,注意,與許多流程管理器一樣,它有一個ProcessState枚舉。我們注意程序的開始狀态:NotStarted。接下來,我們要找到做下面事情之一的代碼:
    • 建立流程的新執行個體(流程在哪裡建立或持久化?)
    • 初始狀态被更改為不同的狀态(狀态如何更改?)
  2. 找到源代碼中出現上述任何一種情況或同時出現上述兩種情況的代碼位置。在本例中,它是RegistrationProcessManagerRouter類中的Handle方法。重要提示:這并不一定意味着該流程是一個指令處理程式!流程管理器負責從存儲中建立和檢索聚合根(AR),以便将消息路由到AR,是以盡管它們的方法在名稱和簽名上與ICommandHandler實作類似,但它們并不實作處理指令的邏輯。
  3. 請注意當狀态發生變化時接收到的消息類型是作為方法參數被傳入的,是以我們現在需要找出消息的來源。
    • 我們還将注意到,RegistrationProcessManager發出了一個新的指令:MakeSeatReservation。
    • 如上所述,這個指令實際上不是由發出它的程序發出的,相反,是當程序儲存到磁盤時,才會發出。
    • 對于其他任何作為程序處理指令的副作用的,被發出的指令,需要一定程度的重複這些啟發。
  4. 查找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倉庫,馬上開始吧!