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

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

CQRS之旅的第一站

「寓言家和鱷魚是同樣的,只是名字不一樣」 --約翰·勞森
複製代碼

描述:

訂單和註冊上下文有一部分職責在會議預訂的過程當中,在此上下文中,一我的(註冊者)能夠購買特定會議的座位。還能夠爲已購買的座位分配與會者的名稱(這在第5章「準備發佈V1版本」中進行了描述)。html

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

由於這是團隊處理的第一個限界上下文,因此咱們還實現了系統的一些基礎設施來支持領域域的功能。包括命令和事件消息總線以及聚合的持久化機制。git

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

在未來的某個旅程中,對這個限界上下文的改進計劃包括支持等待列表(若是沒有足夠的座位可用,對座位的請求將放在等待列表中),以及容許業務客戶爲座位類型設置各類類型的折扣。web

備註:在這個版本中沒有實現等待列表,可是社區成員正在開發這個特性和其餘特性。任何帶外發布和更新都將在「CQRS之旅」網站上公佈。數據庫

本章的工做術語定義:

本章使用了一些術語,咱們將在後面定義它們。有關更多細節和可能的替代定義,請參閱參考指南中的「深刻CQRS和ES」。json

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

    Gary(CQRS專家)發言:
    有一些討論是關於優化的可能性,這涉及到命令不一樣的定義,這些不一樣點是微小的。請參閱第6章「咱們系統的版本管理」。windows

  • 事件(Event):事件就是系統中發生的一些事情,一般是一個命令的結果。領域模型中的聚合會引起(raise)事件。多個事件訂閱者(subscribers)能夠處理特定的事件。聚合將事件發佈到事件總線, 處理程序訂閱特定類型的事件,事件總線(event bus)將事件傳遞給訂閱者。在這個限界上下文中,惟一的訂閱者是流程管理器。後端

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

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

    Markus(軟件開發人員)發言:
    對於剛接觸代碼的人來講,跟蹤命令和事件在系統中的流動是很困難的。第4章「擴展和加強訂單和註冊有界上下文」中的「對測試的影響」一節,討論了怎樣能夠幫助你搞清楚它們。

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

    Gary(CQRS專家)發言:
    在訂單和註冊限界上下文中,起初,提到流程管理器,團隊把它看成一個Saga(一種可用於處理事務的模式),要了解後來爲何咱們決定更改術語,請參閱本章後面的「模式和概念」一節。

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

領域定義(通用語言)

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

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

  • 註冊者:註冊者是與系統交互下訂單併爲這些訂單付款的人。註冊者還建立與訂單關聯的註冊。註冊者也能夠是與會者。

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

    Carlos(領域專家)發言:
    咱們刻意實現了一個記錄定位器機制,以經過該機制返回之前提交的訂單數據。這消除了用戶必須註冊,登陸系統才能得到訪問權限的煩人要求。咱們的客戶堅決要求這樣。

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

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

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

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

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

  • 座位可用性:每一個會議都將追蹤每種類型座位的可用性。最初,全部的座位均可以預訂和購買。當一個座位被預訂時,該類型的可用座位數量將減小。若是系統取消預訂,則增長該類型的可用座位數量。業務客戶能夠定義每種可用座位類型的初始數量,這是會議的一個特色。會議全部者能夠根據不一樣的座位類型調整數量。

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

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

開發人員1:這裏是對與會者定義的初步嘗試。與會者是指花錢參加會議的人。與會者能夠與系統交互,好比管理議程、打印徽標以及會後提供反饋。」

領域專家1:並非全部與會者都會付費參加會議。例如,一些會議會有志願者,並且演講者一般不付錢。並且,在某些狀況下,出席者能夠得到100%的折扣。

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

開發者1:因此咱們須要說與會者是被受權參加會議的人?

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

開發人員1:如何命名?

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

開發人員1:如今咱們有了這個定義,它捕獲了咱們討論過的全部內容。與會者是有權參加會議的人。與會者能夠與系統交互,好比管理議程、打印徽標以及會後提供反饋。與會者也能夠是不花錢參加會議的人,好比志願者、演講者或享受100%折扣的人。與會者能夠有多種相關的與會者類型(演講者、學生、志願者、track chair等等)。
複製代碼

建立訂單的需求

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

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

訂單功能的用戶界面原型圖

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

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

架構

此應用程序設計爲部署到Microsoft Azure。到旅程的這個階段,應用程序將包含一個ASP.NET MVC web應用程序和消息處理程序以及領域模型對象。應用程序使用Azure SQL Database實例進行數據存儲,讀和寫二者都包括。應用程序使用Azure Service Bus來進行消息傳遞。

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

在研究和測試解決方案時,能夠在本地運行它,可使用Azure compute emulator,也能夠直接運行MVC web應用程序,並運行承載消息處理程序和領域域對象的控制檯應用程序。在本地運行應用程序時,可使用本地SQL Server Express數據庫,並使用一個在SQL Server Express數據庫實現的簡單的消息傳遞基礎設施。

有關運行應用程序的選項的更多信息,請參見附錄1「發佈說明」。

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

模式和概念

爲了保持簡單,團隊決定在不使用事件源(Event Sourcing)的狀況下先實現第一個限界上下文。固然,他們也肯定,若是未來肯定事件源能爲這個限界上下文帶來特定的好處,那麼他們將從新考慮這個決定。

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

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

「我認爲開發人員須要收穫一個觀念,那就是把對象的屬性存儲在關係型數據庫中是不重要的。教會他們避免將領域模型做爲關係存儲,我認爲這樣將會更容易介紹和理解領域驅動設計(DDD)和CQRS」 --Josh Elster, CQRS Advisors Mail List
複製代碼

Gary(CQRS專家)發言:
這些圖刻意排除了系統如何經過命令和事件處理程序處理命令和事件的細節。這些圖主要關注領域中的聚合之間的邏輯關係。

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

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

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

方法1:兩個分開的聚合

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

  1. 從UI發送一個命令用來註冊參會者X和Y到157號會議,這個命令被路由到一個新的訂單(Order)聚合。

  2. 訂單聚合引起(Raise)一個事件,該事件報告已經建立了一個訂單。這個事件被路由到可用座位(SeatsAvailability)聚合。

  3. ID爲157的可用座位(SeatsAvailability)聚合是從數據庫中取回還原(re-hydrated)的。

  4. 可用座位(SeatsAvailability)聚合更新它本身的預約座位總數。

  5. 更新後的可用座位(SeatsAvailability)聚合被持久化到數據庫中。

  6. ID爲4239的新的訂單聚合被持久化到數據庫中。

    Markus(軟件開發人員)發言:
    術語re-hydrated是指從數據庫中反序列化聚合實例的過程。

    Jana(軟件架構師)發言:
    你能夠考慮使用Memento模式來處理持久化和rehydration。



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

方法2:單個聚合

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

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



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

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

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

  1. 從UI發送一個命令用來註冊參會者X和Y到157號會議,這個命令被路由到訂單(Order)聚合。

  2. 這個新的訂單(Order)聚合,ID爲4239,被持久化到數據庫中

  3. 訂單(Order)聚合引起(Raise)一個事件,這個事件將被RegistrationProcessManager類處理

  4. RegistrationProcessManager類將發送一個命令到ID爲157的可用座位(SeatsAvailability)聚合

  5. 這個可用座位(SeatsAvailability)聚合從數據庫中取回還原(rehydrated)

  6. 可用座位(SeatsAvailability)聚合更新本身的預約座位總數,而後持久化回數據庫

    Gary(CQRS專家)發言:
    流程管理器或Saga,起初,團隊將RegistrationProcessManager類看作一個Saga,可是,當他們從新閱讀Hector Garcia-Molina和Kenneth Salem合著的《Saga》一文中對「Saga」的最初定義後,他們修改了本身的決定。主要緣由是預約流程並不包含明確的補償步驟,因此並不須要一個長生命週期的事務。

有關流程管理器和Saga的更多信息,請參見參考指南中的第6章「A Saga on Sagas

團隊還明確了下列問題:

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

驗證

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

Jana(軟件架構師)發言:
當咱們在這裏談UI驗證時,咱們指的是模型-視圖-控制器(MVC)執行的驗證,而不是瀏覽器前端。

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

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

第二個模型的行爲相似,除了訂單(Order)聚合和可用座位(SeatsAvailability)聚合是在會議(Conference)聚合裏協做的。

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

全部這三個模型都須要實體就驗證過程進行通訊,可是與流程管理器進行通訊的第三個模型看起來比其餘兩個模型更復雜一些。

事務邊界

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

備註:術語「一致性邊界」指的是你能夠假設全部元素始終保持一致的邊界。
複製代碼

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

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

併發

註冊過程發生在多用戶環境中,許多註冊者能夠嘗試同時購買座位。團隊決定使用預定模式來解決註冊過程當中的併發問題。在這種狀況下,這意味着爲註冊者最初保留了座位(而後其餘註冊者沒法使用這些座位)。如註冊者在超時時間內完成付款,系統保留預訂,不然,系統將取消預訂。

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

這個超時還要求系統在某個地方添加一個計時器來跟蹤預訂什麼時候過時。

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

聚合和聚合根

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

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



實現細節

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

不要指望代碼示例與參考實現中的代碼徹底匹配。本章描述了CQRS過程當中的一個步驟,隨着咱們瞭解更多並重構代碼,實現可能會發生變化。
複製代碼

高層架構

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

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

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

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

  1. 使用讀模型(Read Model)查詢數據

    ConferenceController類包含一個名爲Display的action,該action建立一個包含特定會議信息的視圖(View)。這個控制器類使用如下代碼從讀模型裏查詢:

    public ActionResult Display(string conferenceCode)
    {
        var conference = this.GetConference(conferenceCode);
    
        return View(conference);
    }
    
    private Conference.Web.Public.Models.Conference GetConference(string conferenceCode)
    {
        var repo = this.repositoryFactory();
        using (repo as IDisposable)
        {
            var conference = repo.Query<Conference>().First(c => c.Code == conferenceCode);
    
            var conferenceModel =
                new Conference.Web.Public.Models.Conference { Code = conference.Code, Name = conference.Name, Description = conference.Description };
    
            return conferenceModel;
        }
    }
    複製代碼

讀模型(Read Model)從數據存儲中檢索信息,並使用數據傳輸對象(DTO)將信息返回給控制器。

  1. 發出命令

    Web應用經過命令總線(Command Bus)向寫模型(Write Model)發送命令。命令總線是系統中的可靠消息傳遞基礎設施組件。在這個場景中,它異步將命令發送給接受者,而且只發送一次。

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

    下面的代碼示例展現了RegistrationController如何發送RegisterToConference命令:

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

    備註:全部的命令都是異步發送的,不須要等待返回。

  2. 處理命令

    命令處理程序在命令總線上註冊,而後,命令總線能夠將命令轉發給正確的處理程序。

    OrderCommandHandler類處理從UI發送的RegisterToConference命令。一般,處理程序負責調用領域裏的某些業務邏輯,並將某些狀態更新持久化到數據存儲中。

    下面的代碼示例展現了OrderCommandHandler類如何處理RegisterToConference命令:

    public void Handle(RegisterToConference command)
    {
        var repository = this.repositoryFactory();
    
        using (repository as IDisposable)
        {
            var seats = command.Seats.Select(t => new OrderItem(t.SeatTypeId, t.Quantity)).ToList();
    
            var order = new Order(command.OrderId, Guid.NewGuid(), command.ConferenceId, seats);
    
            repository.Save(order);
        }
    }
    複製代碼
  3. 在領域中初始化業務邏輯

    在前面的代碼示例中,OrderCommandHandler類建立了一個新的Order實例。Order對象是一個聚合根,它的構造函數包含初始化領域邏輯的代碼。有關此聚合根執行哪些操做的詳細信息,請參閱下面的「在寫模型內部」一節。

  4. 把改動持久化

    在前面的代碼示例中,命令處理程序經過調用repository類中的Save方法來持久化一個新的訂單(Order)聚合。這個Save方法還將在命令總線(Command Bus)上發佈訂單(Order)聚合引起的各類事件。

  5. 輪詢讀模型(Read Model)

    要向用戶提供反饋,UI端必須可以檢查RegisterToConference命令是否成功。與系統中的全部命令同樣,此命令異步執行,不返回結果。UI端經過輪詢讀模型(Read Model)來檢查命令是否成功。

    下面的代碼示例展現了一個初始實現,其中RegistrationController類裏的WaitUntilUpdated方法輪詢讀模型,直到它發現訂單已經被持久化成功或超時。

    [HttpPost]
    public ActionResult StartRegistration(string conferenceCode, OrderViewModel contentModel)
    {
        ...
    
        this.commandBus.Send(command);
    
        var draftOrder = this.WaitUntilUpdated(viewModel.Id);
    
        if (draftOrder != null)
        {
            if (draftOrder.State == "Booked")
            {
                return RedirectToAction("SpecifyPaymentDetails", new { conferenceCode = conferenceCode, orderId = viewModel.Id });
            }
            else if (draftOrder.State == "Rejected")
            {
                return View("ReservationRejected", viewModel);
            }
        }
    
        return View("ReservationUnknown", viewModel);
    }
    複製代碼

    後來,團隊用Post-Redirect-Get模式的實現替換了這種檢查系統是否保存訂單的機制。下面的代碼示例展現了StartRegistration方法的新版本。

    備註:更多關於Post-Redirect-Get模式的信息,請在Wikipedia查看Post/Redirect/Get

    [HttpPost]
    public ActionResult StartRegistration(string conferenceCode, OrderViewModel contentModel)
    {
        ...
    
        this.commandBus.Send(command);
    
        return RedirectToAction("SpecifyRegistrantDetails", new { conferenceCode = conferenceCode, orderId = command.Id });
    }
    複製代碼

    新的StartRegistration action方法如今發送命令後當即重定向到SpecifyRegistrantDetails action。下面的代碼示例顯示了SpecifyRegistrantDetails action如何在返回視圖以前輪詢數據庫中的訂單。

    [HttpGet]
    public ActionResult SpecifyRegistrantDetails(string conferenceCode, Guid orderId)
    {
        var draftOrder = this.WaitUntilUpdated(orderId);
    
        ...
    }
    複製代碼

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

    譯者注:此文章編寫時間較早。文中的UI端指的是asp.net mvc web應用程序。如今流行的方式是先後端分離。後端入口服務通常是一個web api程序。並且基於輪詢的方案也不太理想。能夠在web api端經過消息總線訂閱數據持久化的各種事件,當在數據存儲層引起這些事件時。web api中的事件處理程序能夠接收到視圖模型改變的數據。再將數據經過SignalR或web socket方式推送至前端。

在寫模型內部

聚合

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

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

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

    ...

    public Guid Id { get; private set; }

    public Guid UserId { get; private set; }

    public Guid ConferenceId { get; private set; }

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

    public int State { get; private set; }

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

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

        this.State = States.Booked;
    }

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

        this.State = States.Rejected;
    }
}
複製代碼

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

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

  • 開發人員2:在默認狀況下,我更喜歡開放和可擴展性。你永遠不知道未來會出現什麼需求,把屬性標記爲virtual並不費什麼事。這固然是有爭議的,在.net中有點不標準。這樣吧,咱們可能只須要給延遲加載的集合屬性標記爲virtual。

  • 開發人員1:使用CQRS模式一般會使延遲加載的效果消失,因此你也不該該須要它。這樣會讓代碼更簡單。

  • 開發人員2:CQRS並無說要使用事件源(Event Sourcing),但若是使用包含對象的聚合根,不管如何都須要它,對嗎?

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

  • 開發人員2:須要明確的是,聚合邊界在這裏是爲了將應該一塊兒更改的內容分組,以保持一致性。延遲加載就意味着已經分組在一塊兒的東西其實並不須要分組。

  • 開發人員1:我贊成。我發如今命令端延遲加載意味着建模錯誤。若是我不須要命令端的值,那麼它就不該該在那裏。此外,我不喜歡virtual,除非它們有特定的用途(或者對象關係映射(ORM)工具的需求)。在我看來,這違反了開閉原則:你以各類可能有意也可能無心的方式敞開了本身接受修改的大門,並且即便發生了什麼影響,也可能沒法當即發現。

    譯者注:ORM要求屬性必須爲虛,Java裏著名的Hibernate就是這麼搞得,因此NHibernate也是這樣的。

  • 開發人員2:模型中的訂單聚合有一個訂單項列表。肯定咱們不須要加載就能把它標記爲已訂好的嗎?咱們創建的模型有問題嗎?

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

    -感謝Jeremie Chassaing和Craig Wilson

聚合和流程管理器

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

下圖中的表展現了流程管理器在給定當前狀態和特定類型消息時的行爲。

寫模型中的領域對象

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

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

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

備註:要查看基礎設施組件如何傳遞命令和事件,在後面的圖裏有。

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

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

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

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

Markus(軟件開發人員)發言:
注意咱們生成一個新的全局唯一標識符(GUID)來標識新的預訂。咱們使用這些Guid將消息關聯到正確的流程管理器實例和聚合實例。

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

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

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

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

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

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

若是預訂被接受,流程管理器將經過向自身發送ExpireOrder命令啓動計時器,並向訂單(Order)聚合發送MarkOrderAsBooked命令。不然,它將向訂單(Order)聚合發送一條ReservationRejected消息。

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

您能夠借鑑SeatsAvailability和RegistrationProcessManager類裏的代碼,以查看其餘消息處理程序是如何實現的。它們都遵循相同的模式:接收消息、執行一些邏輯併發送消息。

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

基礎設施

下面的序列圖展現了基礎設施組件如何與領域對象交互消息的。

當UI中的MVC控制器使用命令總線發送消息時,典型的交互就開始了。消息發送方異步調用命令總線上的Send方法。而後命令總線存儲消息,直到消息接收者收到消息並將其轉發給適當的處理程序。系統包含許多命令處理程序,這些命令處理程序向命令總線註冊,以處理特定類型的命令。例如,OrderCommandHandler類爲RegisterToConference、Markorderasbooking和RejectOrder命令定義了處理程序方法。下面的代碼示例顯示了Markorderasbooking命令的處理程序方法。處理程序方法負責尋找正確的聚合實例,調用該實例上的方法,而後保存該實例。

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

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

        if (order != null)
        {
            order.MarkAsBooked();
            repository.Save(order);
        }
    }
}
複製代碼

實現IRepository接口的類負責在事件總線上持久化聚合對象併發布聚合裏引起的任何事件,全部的這些都是事務的一部分。

Carlos(領域專家)發言:
稍後,當團隊試圖使用Azure服務總線做爲消息傳遞基礎設施時,發現了一個問題。Azure服務總線不支持帶有數據庫的分佈式事務。有關這個問題的討論,請參閱第5章「準備發佈V1版本」。

在註冊限界上下文中,唯一的事件訂閱者是RegistrationProcessManager類。它的Router訂閱者從訂閱事件總線訂閱,來處理特定的事件,下面的代碼示例展現了RegistrationProcessManager類。

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

            repo.Save(process);
        }
    }
}
複製代碼

一般,事件處理程序方法獲取流程管理器實例,將事件傳遞給流程管理器,而後保存流程管理器實例。在本例中,IRepository實例負責持久化流程管理器實例,並負責將任何命令從流程管理器實例發送到命令總線。

使用Azure服務總線(Service Bus)

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

Jana(軟件架構師)發言:
Contoso的開發團隊決定使用Azure服務總線,由於它爲會議管理系統中的消息傳遞場景提供了開箱即用的支持。這將最小化團隊須要編寫的代碼量,並提供健壯的、可伸縮的消息傳遞基礎設施。該團隊計劃使用重複消息檢測和保證消息排序等功能。要了解Azure服務總線和Azure隊列之間的區別,請參閱MSDN上的「Microsoft Azure Queues and Microsoft Azure Service Bus Queues - Compared and Contrasted」。

下圖顯示了命令和事件消息如何在系統中流動。MVC控制器和領域對象使用CommandBus和EventBus實例將BrokeredMessage消息發送給Azure服務總線中的兩個Topic之一。接收消息時,消息處理類是CommandProcessor和EventProcessor實例,CommandProcessor類肯定哪一個處理程序應該接收命令消息,EventProcessor類肯定哪些處理程序應該接收事件消息。後者使用SubscriptionReceiver類從Topic獲取事件。處理程序實例負責調用領域對象上的方法。

Azure服務總線的Topic能夠有多個訂閱者。Azure服務總線將發送到Topic的消息傳遞給它的全部訂閱者。所以,一條消息能夠有多個接收者。
複製代碼

在最初的實現中,CommandBus和EventBus類很是類似。Send方法和Publish方法之間的唯一區別是,Send方法指望消息被包裝在Envelope類中。Envelope類容許發送方指定消息傳遞的時間延遲。

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

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

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

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

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

將命令傳遞給單個接收者

本討論假設您已經基本瞭解了Azure服務總線隊列和Topic之間的區別。有關Azure服務總線的介紹,請參閱參考指南中的「參考實現中使用的技術」。

使用上圖所示的實現,有兩件事是必要的,以確保命令只有單個處理程序。首先,Azure服務總線中應該保證只有一個會議/命令Topic的訂閱。請記住,Azure服務總線主題是能夠有多個訂閱者的。其次,CommandProcessor應該爲它接收到的每一個命令調用一個處理程序。Azure服務總線中沒有辦法將主題限制爲單個訂閱。所以,開發人員必須本身當心的爲命令的Topic建立單個訂閱。

Gary(CQRS專家)發言:
另外一個問題是確保處理程序從Topic獲取命令後只處理一次。您必須確保命令是冪等的,或者系統保證只處理命令一次。該團隊將在旅程的後期處理這個問題。有關更多信息,請參見旅程第7章「增長彈性和優化性能」。

備註:可能會運行多個SubscriptionReceiver實例,由於能夠同時部署運行多個工做服務。若是多個SubscriptionReceiver實例能夠接收來自同一主題訂閱的消息,那麼第一個調用SubscriptionClient對象上的Receive方法的實例將獲取並處理該命令。
複製代碼

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

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

private SubscriptionClient client;

...

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

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

            throw;
        }

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

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

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

Jana(軟件架構師)發言:
此代碼示例展現了系統如何使用Transient Fault Handling Application Block可靠地從Topic獲取消息。

Azure服務總線SubscriptionClient類使用peek/lock技術從訂閱中獲取消息。在代碼示例中,Receive方法在訂閱時鎖定消息。當消息被鎖定時,其餘客戶端沒法看到它。而後Receive方法嘗試處理消息。若是客戶端成功處理消息,則調用Complete方法:這將從訂閱中刪除消息。不然,若是客戶端未能成功處理該消息,則調用Abandon方法:這將釋放消息上的鎖,而後相同的客戶端或不一樣的客戶端就能夠繼續接收它。若是客戶端在固定的時間內沒有調用Complete方法或Abandon方法,則也會釋放消息上的鎖。

MessageReceived事件將一個引用傳遞給SubscriptionReceiver實例,以便處理程序在處理消息時能夠調用Complete方法或Abandon方法。
複製代碼

下面來自MessageProcessor類的代碼示例展現瞭如何使用BrokeredMessage實例做爲MessageReceived事件的參數以及如何使用它調用Complete和Abandon方法。

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

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

    try
    {
        ...

        ProcessMessage(payload);

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

        return;
    }

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

備註:本示例使用可靠的Transient Fault Handling Application Block,並使用擴展方法調用BrokeredMessage的Complete方法和Abandon方法。

爲何分爲CommandBus和EventBus?

儘管在會議管理系統開發的早期階段,CommandBus和EventBus類的實現很是類似,您可能想知道爲何咱們同時擁有這兩個分開的類,由於團隊預計它們在將來會出現區別。

Markus(軟件開發人員)發言:
在調用處理程序的方式和爲它們捕獲什麼樣的上下文方面可能存在差別:命令可能但願捕獲額外的運行時狀態,而事件一般不須要這樣作。因爲這些潛在的將來差別,我不想統一實現。我之前也遇到過這種狀況,一旦有進一步的要求時,我就把它們分開。

這個方案的可擴展性如何?

使用這種方案,您能夠在不一樣的Azure工做角色實例中運行SubscriptionReceiver類的多個實例和各類處理程序,這使您可以擴展您的解決方案。您還能夠在不一樣的Azure工做角色實例中擁有CommandBus、EventBus和TopicSender類的多個實例。

有關擴展Azure服務總線基礎設施的信息,請參閱MSDN上的 Best Practices for Performance Improvements Using Service Bus Brokered Messaging

這個方案的健壯性如何?

方案使用Azure服務總線的代理消息傳遞選項來提供異步消息傳遞。服務總線老是可靠地存儲消息,直到用戶鏈接並獲取這些消息。

另外,從隊列或Topic訂閱獲取消息的peek/lock方法爲消息消費者在處理消息失敗的場景中增長了可靠性。若是消費者在調用Complete方法以前失敗,則當消費者從新啓動時,任然能夠處理該消息。

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

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

未來,團隊會研究使用多個Topic,例如,爲每一個限界上下文使用單獨的命令Topic和多個訂閱(一個事件類型一個訂閱)。這些替代方案能夠簡化代碼,並促進擴展應用程序跨多個Azure工做角色,來工做。

Jana(軟件架構師)發言:
使用多個Topic、訂閱或隊列沒有額外的成本。Azure服務總線是根據發送的消息數量和從Azure子區域傳輸的數據量來進行計費的。

命令和事件如何序列化?

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

您應該考慮是否須要爲命令使用Azure服務總線。命令一般使用在有邊界的上下文中,您可能不須要跨進程邊界發送它們(在寫入端,您可能不須要額外的層),在這種狀況下,您可使用內存隊列來傳遞命令。」 -- Greg Young,與模式與實踐團隊的對話
複製代碼

對測試的影響

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

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

  • 開發人員2:那麼咱們爲何不發起事件呢?咱們沒有使用事件源(ES)並不意味着咱們的領域對象不能引起事件。讓領域對象引起事件,而後咱們能夠按照一般的方法設計測試,以檢查響應命令時觸發的正確事件。

  • 開發人員1:這難道不是讓事情變得比須要的更復雜了嗎?使用CQRS的動機之一就是簡化事情!如今咱們有了領域對象,它們須要使用ORM層來持久化它們的狀態。而後咱們又要引起事件來報告它們所持久化的內容,由於這樣咱們就能夠運行單元測試了。

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

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

  • 開發人員2:這應該很容易作到,畢竟,領域對象把咱們想要檢查的全部數據都存儲在屬性中,以便ORM能夠將正確的信息持久化到存儲中。

  • 開發人員1:因此咱們只須要考慮在這個場景中使用另外一種不一樣的風格進行測試。

  • 開發人員2:咱們還須要考慮這個問題的另外一個方面:咱們可能有一組測試來測試領域對象,而且全部這些測試均可能經過。咱們還可能有一組測試來驗證ORM層是否可以成功地保存和獲取對象。可是,咱們還必須測試領域對象在ORM層上運行時是否正確。領域對象有可能執行正確的業務邏輯,但沒法正確的持久化其狀態,這多是由於ORM處理特定數據類型的方式存在問題。

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

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

下面的代碼示例展現了使用上面討論的行爲方法編寫的兩個測試示例。

Markus(軟件開發人員)發言:
這些是咱們剛開始時使用的測試,可是咱們隨後用基於狀態的測試替換了它們。

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

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

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

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

若是沒有聚合引起事件,則很難以任何其餘方式測試行爲。例如,檢查是否進行了正確的調用以將聚合持久化到數據存儲裏,若是您試圖用這個來測試行爲,那麼測試就會和數據存儲實現耦合(這是一種壞氣味):若是但願更改數據存儲的實現,那麼就須要更改領域模型中對聚合的測試。

下面的代碼示例展現了使用被測試對象的狀態編寫的測試示例。這是在項目中使用的一種測試風格。

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

    private SeatsAvailability sut;
    private IPersistenceProvider sutProvider;

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

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

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

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

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

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

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

這裏展現的兩個測試在調用MakeReservation方法後測試可用座位(SeatsAvailability)聚合的狀態。第一個用來測試有足夠座位可用的場景。第二個用來測試沒有足夠的座位可用的場景。第二個測試能夠利用可用座位(SeatsAvailability)聚合的行爲,由於若是該聚合拒絕預訂,它確實會引起一個事件。

彙總

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

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

相關文章
相關標籤/搜索