團隊開發框架實戰—CQRS架構
CQRS架構圖
什么是CQRS?
這里只通過Udi Dahan的《Clarified CQRS》文章中的一張圖片簡要介紹一下:
UI上有兩種類型的操作:命令和查詢,例如顯示銷量最好的5個產品就屬于查詢,而提交一個訂單、修改密碼等則屬于命令。因為大部分系統都是讀多寫少,而且業務邏輯基本都出現在寫入的一端,所以查詢和命令的分離可以讓我們獨立的去優化查詢。
查詢 (Query)
上圖中,可以看到Query不是通過DB來查詢,而是通過一個專門用于查詢的Read DB(上圖中的Cache,它不一定是數據庫,但為方便起見,下面統稱Read DB),Read DB中的表(方便起見,暫且認為這個Read DB是一個RDBMS)是專門針對UI優化過的,例如里面可能會有LatestProductListModel(ProductId, ProductName, Price, BrandName, AddedTime)、BestSoldProductListModel(ProductId, ProductName, TotalSold)這樣的表,分別表示最新的產品列表,銷量最好的產品列表(它們其實就相當于是View Model)。LatestProductListModel中有一個BrandName的字段,注意,不是BrandId,因此,對于界面中的查詢,幾乎全都可以通過SELECT * FROM [TABLE]這樣的SQL語句來實現,可能有少數Where,但基本沒有Join,這對于界面的加載速度絕對是有利無弊的(其實也是在用空間換時間)。
命令 (Command)
業務邏輯大部分都發生在寫入的時候,例如用戶購買商品提交訂單時,我們要驗證庫存,用戶信息訂單數據是否有效等。如果從傳統DDD的角度看,Command類似于Application Service,用戶的命令(如提交訂單)會以Command的形式得到執行,而Command中也不會帶有業務邏輯,Command中做的事情基本上是:通過Repository得到相關的領域對象,調用某些領域服務(Domain Service)執行一些操作(業務邏輯都將保留在領域模型中),然后執行Commit或SaveChanges之類的方法提交改動,之后,相關的數據就會寫入到Write DB中(圖的DB,下文統稱Write DB)。需要注意的是,UI上的查詢都是查Read DB,而不是Write DB。
領域模型 (Domain Model)
這和Evans的DDD中說的領域模型沒有太多區別,是“the heart of software”。
領域事件 (Domain Event)
領域事件占據的地位非常重要,不僅限于CQRS。相信會有一部分人曾和我一樣碰到過這樣的問題:
Account實體(表示帳戶)有個Balance屬性(表示帳戶余額),我們一般不會公開這個屬性的setter,而是通過寫一些IncreaseBalance(decimal amount)之類的方法來實現帳戶余額的變動。
這時問題就來了,我們想在帳戶變動時添加一條AccountLog記錄,但Log記錄成千上萬,我們不能直接通過ORM的一對多映射把AccountLog集合實現成Account的一個集合屬性,那我們就需要在IncreaseBalance()中得到AccountLogRepository,這樣才有辦法插入AccountLog(從DDD的角度,AccountLog不是聚合根,所以不能有AccountLogRepository,但在性能影響嚴重的時候,也只好做些取舍了)。
不管用了依賴注入還是什么的,總之,Account已經依賴上Repository了,這就讓領域對象變得很不純凈,并且,假如我們以后不僅要記錄log,還要短信通知用戶呢?那要修改源代碼嗎?這也很不OCP。
而領域事件正好可以解決這種問題:只要在IncreaseBalance()方法的末尾,觸發一個領域事件,然后我們獨立寫一個EventHandler的類去實現log的添加(框架可以保證EventHandler可以和領域事件綁定到一起)。
回到CQRS,因為Command將數據寫到了Write DB中,而UI查詢的是Read DB,那我們就需要用某種方式實現這兩個數據庫的同步,解決辦法已經很明顯了,寫一堆的EventHandler類去監聽領域事件。例如我們有一個更改產品價格的命令ChangePriceCommand,它執行后,一個叫做PriceChangedEvent會被觸發,那我們只要寫一個PirceChangedEventHandler的類,在這里面將Read DB中相關的價格信息更改到最新值即可實現同步(這里會涉及到Read DB中表結構改變的問題,后面再說)。
Command的實現
概述
UI中的寫入操作都將被封裝為一個命令中,發送給Domain Model來處理。
我們遵循Domain Driven Design的設計思想,因此所有的業務邏輯都只在Domain Model中處理,Command中將不會帶有業務邏輯。Command中的代碼無非是通過Repository獲取某些個聚合根(Aggregate Root),然后將操作委托給相應的領域對象或領域服務來處理,僅此而已。
實現
實現上,我們會涉及三個東西:
- Command對象
Command對象的作用是用來封裝命令數據,所以這類對象以屬性為主,少量簡單方法,但注意這些方法中不能包含業務邏輯。
舉個用戶注冊的例子,用戶注冊是一個命令,所以我們需要一個RegisterCommand類,這個類定義如下:
using Tdf.CQRS.Commanding;
namespace Tdf.CQRSSample.Commands
{
public class RegisterCommand : ICommand
{
public string Email { get; set; }
public string NickName { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public RegisterCommand()
{
}
}
}
這個類的每個屬性基本上都對應著注冊表單中的一個輸入(為了方便起見,上面的每個屬性都是public set,但若屬性不多不影響編碼,最好把屬性都改成private set,然后將屬性的值通過構造函數傳入)。當用戶點擊“注冊”按鈕時,Controller(假設使用MVC作為表現層模式)中會創建一個RegisterCommand的實例,設置相應的值,然后調用CommandBus.Send(registerCommand),然后根據執行的情況顯示相應的信息給用戶。(CommandBus后面會講到)
- CommandExecutor
CommandExecutor的作用是執行一個命令,對于注冊的例子,我們會有一個RegisterCommandExecutor的類,它只有一個Execute方法,接受RegisterCommand參數:
using System;
using Tdf.CQRS.Commanding;
using Tdf.CQRS.Data;
using Tdf.CQRSSample.Domain.Entities;
using Tdf.CQRSSample.Domain.Services;
namespace Tdf.CQRSSample.Commands
{
class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
{
public IRepository<User> _repository;
public RegisterCommandExecutor(IRepository<User> repository)
{
_repository = repository;
}
public void Execute(RegisterCommand cmd)
{
if (String.IsNullOrEmpty(cmd.Email))
throw new ArgumentException("Email is required.");
if (cmd.Password != cmd.ConfirmPassword)
throw new ArgumentException("Password not match.");
// other command validation logics
var service = new RegistrationService(_repository);
service.Register(cmd.Email, cmd.NickName, cmd.Password);
}
}
}
在Execute方法中,我們需要先驗證Command的正確性,但需要注意的是,這里的驗證只是驗證RegisterCommand中的數據是否合法,并非驗證業務邏輯。例如,這里會驗證郵箱是否為空且格式是否正確,但郵箱格式正確并不意味著就可以注冊,因為系統可能要求18歲以上的成年人才能注冊,而這屬于業務邏輯,RegistrationService將會負責確保所有的業務規則不被破壞,RegistrationService屬于Domain Service,存在于Domain Model中。
可以看到,CommandExecutor中主要有兩部分工作,一是驗證傳入的Command對象是否合法,二是調用領域模型完成操作。上一篇文章中提到的Command是一個概念層次的Command,它不單指(1)中的Command,而是包含了(1)和(2)等。
- Command Bus
用于執行Command的是CommandExecutor,但CommandExecutor卻并不用來在UI層調用,UI層中只會用到Command對象和即將提到的Command Bus。Command Bus的作用是將一個Command派發給相應的CommandExecutor去執行。在開發UI層時,我們不需要關心Command會被哪個Executor執行了,而只要知道,上帝賜予了我們一個CommandBus,我們只要創建好Command對象,扔給它,神奇的CommandBus就會幫我們把它執行完。這樣一來,對于UI層的開發來說,所涉及的概念很簡單,涉及的類也少,大部分的工作都是得到表單中的輸入,封裝成Command對象,扔給CommandBus。
CommandBus的實現也很簡單。首先,我們需要讓CommandExecutor都實現一個泛型接口:
namespace Tdf.CQRS.Commanding
{
public interface ICommandExecutor<TCommand>
where TCommand : ICommand
{
void Execute(TCommand cmd);
}
}
其中ICommand是一個空接口,沒有任何方法(即Marker Interface),它的作用是實現編譯時約束,這樣我們可以限制傳入CommandExecutor的都是Command對象,而不是不小心傳錯的User對象(所有的Command對象都必須實現ICommand接口)。
然后,把CommandBus寫成這樣:
通過IoC框架來簡化這個過程,另外也可以做一些改進,例如將CommandBus設計為擴展點之一。另外我們還可以將UnitOfWork(相當于平常的EntityFramework中的IDbContext,Linq 2 SQL中的DataContext)的生命周期在CommandBus中進行控制。
比較完整的CommandBus代碼如下
namespace Tdf.CQRS.Commanding
{
public interface ICommandBus
{
void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
}
}
using Tdf.CQRS.Data;
namespace Tdf.CQRS.Commanding
{
public class CommandBus : ICommandBus
{
public void Send<TCommand>(TCommand cmd) where TCommand : ICommand
{
try
{
var unitOfWork = UnitOfWork.StartUnitOfWork();
var executor = ObjectContainer.Resolve<ICommandExecutor<TCommand>>();
executor.Execute(cmd);
UnitOfWork.Commit();
}
finally
{
UnitOfWork.Close();
}
}
}
}
一些注意點
- Command表示想要執行的命令,所以Command類的類名應當是動詞的形式。例如RegisterCommand, ChangePasswordCommand等。不過Command后綴則是可選的,只要能保持一致即可。
- Command和CommandExecutor是一一對應的。也就是說,一個Command只會對應一個CommandExecutor,這和后面的事件有區別,事件是一對多的,一個Event可以對應多個EventHandler。
- Command對象也起到了DTO(Data Transfer Object,在這個例子中感覺稱作View Model也無妨)的作用,這也是把Command和Executor相分離,不把Execute方法直接寫在Command類中的原因之一。
- 注意Command的類名的重要作用,每個Command類的名稱都清晰地表達了一個意圖,例如ChangePasswordCommand清晰的表達了這個命令是要修改密碼,所以千萬不要隨意"復用"Command,這里的“復用”指的是,看到某兩個Command中有完全一樣的屬性,就覺得沒有必要使用兩個Command,而把它們合并成一個Command,這樣的"復用"會讓系統變得越來越難以理解,雖然它可能的確減少了幾行代碼。
- 命令通常是用“發送”來描述,而事件則是用“發布”來描述,所以CommandBus中的方法名稱個人認為應該用Send比較合適,而不用Publish之類的。
Command執行結果的返回
面對UI中的各種命令,Controller會創建相應的Command對象,然后將其交給CommandBus,由CommandBus統一派發到相應的CommandExecutor中去執行,我們的ICommandBus的接口聲明如下:
namespace Tdf.CQRS.Commanding
{
public interface ICommandBus
{
void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
}
}
當在實際項目中應用CQRS時,我們會發現上面的做法存在一個問題:有時候我們希望Command在執行完后返回一些結果,但上面的Send方法返回void,也就意味著我們沒有辦法得到執行結果。我們以一個用戶注冊的例子來說明。
在Command對象中添加一個ExecutionResult的屬性(這個屬性要放在具體的Command類中,不要放于ICommand接口中)。如上面的用戶注冊的例子,我們可以添加一個RegisterCommandResult的類,然后將RegisterCommand改成如下所示:
using Tdf.CQRS.Commanding;
namespace Tdf.CQRSSample.Commands
{
public class RegisterCommand : ICommand
{
public string Email { get; set; }
public string NickName { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
// 亮點在這里
public RegisterCommandResult ExecutionResult { get; set; }
public RegisterCommand()
{
}
}
// 亮點在這里
public class RegisterCommandResult
{
public string GeneratedUserId { get; set; }
}
}
在調用CommandBus.Send()之前,我們完全不用理會這個ExecutionResult屬性,對于Controller的開發人員來說,他只要知道在Command執行完后,ExecutionResult的值就會被賦上,如果沒有,那就是CommandExecutor的bug。
而我們的RegisterCommandExecutor就可以改成(User類的構造函數會調用Id = Guid.NewGuid().ToString()對自己的Id進行賦值):
using System;
using Tdf.CQRS.Commanding;
using Tdf.CQRS.Data;
using Tdf.CQRSSample.Domain.Entities;
using Tdf.CQRSSample.Domain.Services;
namespace Tdf.CQRSSample.Commands
{
class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
{
public IRepository<User> _repository;
public RegisterCommandExecutor(IRepository<User> repository)
{
_repository = repository;
}
public void Execute(RegisterCommand cmd)
{
if (String.IsNullOrEmpty(cmd.Email))
throw new ArgumentException("Email is required.");
if (cmd.Password != cmd.ConfirmPassword)
throw new ArgumentException("Password not match.");
// other command validation logics
var service = new RegistrationService(_repository);
var user = service.Register(cmd.Email, cmd.NickName, cmd.Password);
// 亮點在這里
cmd.ExecutionResult = new RegisterCommandResult
{
GeneratedUserId = user.Id
};
}
}
}
RegisterCommand中定義的ExecutionResult屬性可以讓開發人員清楚的知道這個屬性會在Command執行完后被賦上合適的值。對于一個Command,如果開發人員在其中找到類似ExecutionResult這樣的屬性,他就知道這個Command執行完后會返回執行結果,并且結果是以賦值的形式賦給Command中的ExecutionResult屬性,若Command中沒有發現ExecutionResult這樣的屬性,那開發人員便知道這個Command執行完不會返回執行結果。
到目前為止,我們所討論的Command都是同步執行的,如果Command被設計為異步執行,那本文所討論的內容便可以直接忽略。
如果系統的性能可以滿足需求,同步Command無疑是最好的。
CQRS架構的優點
- CQ兩端架構分離、相互不受束縛,各自獨立設計、擴展
- C端通常結合DDD,解決復雜的業務邏輯;Q端輕量級查詢,多種不同的查詢視圖通過訂閱事件來更新
- C端通過分布式消息隊列水平擴展,天然支持削峰
- EDA架構,整個系統各個部分松耦合,可擴展性好
- 架構層面做到無并發,實現Command的高吞吐
- 技術架構和業務代碼完全分離,程序員不用關心技術問題
- 更方便的分工合作
CQRS架構的缺點
- 不是強一致性,而是面向最終一致性
- 強依賴高性能可靠的分布式消息隊列
- 必須有強大可靠的CQRS框架,從頭做起成本高、風險大
- 必須結合Event Sourcing模式,否則CQ分離意義不大
- Event Sourcing模式的缺點
- 一些CQRS的最佳原則提高了開發人員的門檻