團(tuán)隊開發(fā)框架實戰(zhàn)—CQRS架構(gòu)
CQRS架構(gòu)圖
什么是CQRS?
這里只通過Udi Dahan的《Clarified CQRS》文章中的一張圖片簡要介紹一下:
UI上有兩種類型的操作:命令和查詢,例如顯示銷量最好的5個產(chǎn)品就屬于查詢,而提交一個訂單娩嚼、修改密碼等則屬于命令。因為大部分系統(tǒng)都是讀多寫少贴铜,而且業(yè)務(wù)邏輯基本都出現(xiàn)在寫入的一端逊躁,所以查詢和命令的分離可以讓我們獨立的去優(yōu)化查詢。
查詢 (Query)
上圖中分别,可以看到Query不是通過DB來查詢淮菠,而是通過一個專門用于查詢的Read DB(上圖中的Cache男公,它不一定是數(shù)據(jù)庫,但為方便起見合陵,下面統(tǒng)稱Read DB)枢赔,Read DB中的表(方便起見澄阳,暫且認(rèn)為這個Read DB是一個RDBMS)是專門針對UI優(yōu)化過的,例如里面可能會有LatestProductListModel(ProductId, ProductName, Price, BrandName, AddedTime)踏拜、BestSoldProductListModel(ProductId, ProductName, TotalSold)這樣的表碎赢,分別表示最新的產(chǎn)品列表,銷量最好的產(chǎn)品列表(它們其實就相當(dāng)于是View Model)执隧。LatestProductListModel中有一個BrandName的字段揩抡,注意,不是BrandId镀琉,因此峦嗤,對于界面中的查詢,幾乎全都可以通過SELECT * FROM [TABLE]這樣的SQL語句來實現(xiàn)屋摔,可能有少數(shù)Where烁设,但基本沒有Join,這對于界面的加載速度絕對是有利無弊的(其實也是在用空間換時間)钓试。
命令 (Command)
業(yè)務(wù)邏輯大部分都發(fā)生在寫入的時候装黑,例如用戶購買商品提交訂單時,我們要驗證庫存弓熏,用戶信息訂單數(shù)據(jù)是否有效等恋谭。如果從傳統(tǒng)DDD的角度看,Command類似于Application Service挽鞠,用戶的命令(如提交訂單)會以Command的形式得到執(zhí)行疚颊,而Command中也不會帶有業(yè)務(wù)邏輯,Command中做的事情基本上是:通過Repository得到相關(guān)的領(lǐng)域?qū)ο笮湃希{(diào)用某些領(lǐng)域服務(wù)(Domain Service)執(zhí)行一些操作(業(yè)務(wù)邏輯都將保留在領(lǐng)域模型中)材义,然后執(zhí)行Commit或SaveChanges之類的方法提交改動,之后嫁赏,相關(guān)的數(shù)據(jù)就會寫入到Write DB中(圖的DB其掂,下文統(tǒng)稱Write DB)。需要注意的是潦蝇,UI上的查詢都是查Read DB款熬,而不是Write DB。
領(lǐng)域模型 (Domain Model)
這和Evans的DDD中說的領(lǐng)域模型沒有太多區(qū)別护蝶,是“the heart of software”华烟。
領(lǐng)域事件 (Domain Event)
領(lǐng)域事件占據(jù)的地位非常重要,不僅限于CQRS持灰。相信會有一部分人曾和我一樣碰到過這樣的問題:
Account實體(表示帳戶)有個Balance屬性(表示帳戶余額),我們一般不會公開這個屬性的setter负饲,而是通過寫一些IncreaseBalance(decimal amount)之類的方法來實現(xiàn)帳戶余額的變動堤魁。
這時問題就來了喂链,我們想在帳戶變動時添加一條AccountLog記錄,但Log記錄成千上萬妥泉,我們不能直接通過ORM的一對多映射把AccountLog集合實現(xiàn)成Account的一個集合屬性椭微,那我們就需要在IncreaseBalance()中得到AccountLogRepository,這樣才有辦法插入AccountLog(從DDD的角度盲链,AccountLog不是聚合根蝇率,所以不能有AccountLogRepository,但在性能影響嚴(yán)重的時候刽沾,也只好做些取舍了)本慕。
不管用了依賴注入還是什么的,總之侧漓,Account已經(jīng)依賴上Repository了锅尘,這就讓領(lǐng)域?qū)ο笞兊煤懿患儍簦⑶也颊幔偃缥覀円院蟛粌H要記錄log藤违,還要短信通知用戶呢?那要修改源代碼嗎纵揍?這也很不OCP顿乒。
而領(lǐng)域事件正好可以解決這種問題:只要在IncreaseBalance()方法的末尾,觸發(fā)一個領(lǐng)域事件泽谨,然后我們獨立寫一個EventHandler的類去實現(xiàn)log的添加(框架可以保證EventHandler可以和領(lǐng)域事件綁定到一起)璧榄。
回到CQRS,因為Command將數(shù)據(jù)寫到了Write DB中隔盛,而UI查詢的是Read DB犹菱,那我們就需要用某種方式實現(xiàn)這兩個數(shù)據(jù)庫的同步,解決辦法已經(jīng)很明顯了吮炕,寫一堆的EventHandler類去監(jiān)聽領(lǐng)域事件腊脱。例如我們有一個更改產(chǎn)品價格的命令ChangePriceCommand,它執(zhí)行后龙亲,一個叫做PriceChangedEvent會被觸發(fā)陕凹,那我們只要寫一個PirceChangedEventHandler的類,在這里面將Read DB中相關(guān)的價格信息更改到最新值即可實現(xiàn)同步(這里會涉及到Read DB中表結(jié)構(gòu)改變的問題鳄炉,后面再說)杜耙。
Command的實現(xiàn)
概述
UI中的寫入操作都將被封裝為一個命令中,發(fā)送給Domain Model來處理拂盯。
我們遵循Domain Driven Design的設(shè)計思想佑女,因此所有的業(yè)務(wù)邏輯都只在Domain Model中處理,Command中將不會帶有業(yè)務(wù)邏輯。Command中的代碼無非是通過Repository獲取某些個聚合根(Aggregate Root)团驱,然后將操作委托給相應(yīng)的領(lǐng)域?qū)ο蠡蝾I(lǐng)域服務(wù)來處理摸吠,僅此而已。
實現(xiàn)
實現(xiàn)上嚎花,我們會涉及三個東西:
- Command對象
Command對象的作用是用來封裝命令數(shù)據(jù)寸痢,所以這類對象以屬性為主,少量簡單方法紊选,但注意這些方法中不能包含業(yè)務(wù)邏輯啼止。
舉個用戶注冊的例子,用戶注冊是一個命令兵罢,所以我們需要一個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()
{
}
}
}
這個類的每個屬性基本上都對應(yīng)著注冊表單中的一個輸入(為了方便起見,上面的每個屬性都是public set趣些,但若屬性不多不影響編碼仿荆,最好把屬性都改成private set,然后將屬性的值通過構(gòu)造函數(shù)傳入)坏平。當(dāng)用戶點擊“注冊”按鈕時拢操,Controller(假設(shè)使用MVC作為表現(xiàn)層模式)中會創(chuàng)建一個RegisterCommand的實例,設(shè)置相應(yīng)的值舶替,然后調(diào)用CommandBus.Send(registerCommand)令境,然后根據(jù)執(zhí)行的情況顯示相應(yīng)的信息給用戶。(CommandBus后面會講到)
- CommandExecutor
CommandExecutor的作用是執(zhí)行一個命令顾瞪,對于注冊的例子舔庶,我們會有一個RegisterCommandExecutor的類,它只有一個Execute方法陈醒,接受RegisterCommand參數(shù):
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中的數(shù)據(jù)是否合法弥鹦,并非驗證業(yè)務(wù)邏輯。例如爷辙,這里會驗證郵箱是否為空且格式是否正確彬坏,但郵箱格式正確并不意味著就可以注冊,因為系統(tǒng)可能要求18歲以上的成年人才能注冊膝晾,而這屬于業(yè)務(wù)邏輯栓始,RegistrationService將會負(fù)責(zé)確保所有的業(yè)務(wù)規(guī)則不被破壞,RegistrationService屬于Domain Service血当,存在于Domain Model中幻赚。
可以看到禀忆,CommandExecutor中主要有兩部分工作,一是驗證傳入的Command對象是否合法坯屿,二是調(diào)用領(lǐng)域模型完成操作油湖。上一篇文章中提到的Command是一個概念層次的Command巍扛,它不單指(1)中的Command领跛,而是包含了(1)和(2)等。
- Command Bus
用于執(zhí)行Command的是CommandExecutor撤奸,但CommandExecutor卻并不用來在UI層調(diào)用吠昭,UI層中只會用到Command對象和即將提到的Command Bus。Command Bus的作用是將一個Command派發(fā)給相應(yīng)的CommandExecutor去執(zhí)行胧瓜。在開發(fā)UI層時矢棚,我們不需要關(guān)心Command會被哪個Executor執(zhí)行了,而只要知道府喳,上帝賜予了我們一個CommandBus蒲肋,我們只要創(chuàng)建好Command對象,扔給它钝满,神奇的CommandBus就會幫我們把它執(zhí)行完兜粘。這樣一來,對于UI層的開發(fā)來說弯蚜,所涉及的概念很簡單孔轴,涉及的類也少,大部分的工作都是得到表單中的輸入碎捺,封裝成Command對象路鹰,扔給CommandBus。
CommandBus的實現(xiàn)也很簡單收厨。首先晋柱,我們需要讓CommandExecutor都實現(xiàn)一個泛型接口:
namespace Tdf.CQRS.Commanding
{
public interface ICommandExecutor<TCommand>
where TCommand : ICommand
{
void Execute(TCommand cmd);
}
}
其中ICommand是一個空接口,沒有任何方法(即Marker Interface)诵叁,它的作用是實現(xiàn)編譯時約束雁竞,這樣我們可以限制傳入CommandExecutor的都是Command對象,而不是不小心傳錯的User對象(所有的Command對象都必須實現(xiàn)ICommand接口)黎休。
然后浓领,把CommandBus寫成這樣:
通過IoC框架來簡化這個過程,另外也可以做一些改進(jìn)势腮,例如將CommandBus設(shè)計為擴(kuò)展點之一联贩。另外我們還可以將UnitOfWork(相當(dāng)于平常的EntityFramework中的IDbContext,Linq 2 SQL中的DataContext)的生命周期在CommandBus中進(jìn)行控制捎拯。
比較完整的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表示想要執(zhí)行的命令泪幌,所以Command類的類名應(yīng)當(dāng)是動詞的形式。例如RegisterCommand, ChangePasswordCommand等。不過Command后綴則是可選的祸泪,只要能保持一致即可吗浩。
- Command和CommandExecutor是一一對應(yīng)的。也就是說没隘,一個Command只會對應(yīng)一個CommandExecutor懂扼,這和后面的事件有區(qū)別,事件是一對多的右蒲,一個Event可以對應(yīng)多個EventHandler阀湿。
- Command對象也起到了DTO(Data Transfer Object,在這個例子中感覺稱作View Model也無妨)的作用瑰妄,這也是把Command和Executor相分離陷嘴,不把Execute方法直接寫在Command類中的原因之一。
- 注意Command的類名的重要作用间坐,每個Command類的名稱都清晰地表達(dá)了一個意圖灾挨,例如ChangePasswordCommand清晰的表達(dá)了這個命令是要修改密碼,所以千萬不要隨意"復(fù)用"Command竹宋,這里的“復(fù)用”指的是劳澄,看到某兩個Command中有完全一樣的屬性,就覺得沒有必要使用兩個Command逝撬,而把它們合并成一個Command浴骂,這樣的"復(fù)用"會讓系統(tǒng)變得越來越難以理解,雖然它可能的確減少了幾行代碼宪潮。
- 命令通常是用“發(fā)送”來描述溯警,而事件則是用“發(fā)布”來描述,所以CommandBus中的方法名稱個人認(rèn)為應(yīng)該用Send比較合適狡相,而不用Publish之類的梯轻。
Command執(zhí)行結(jié)果的返回
面對UI中的各種命令,Controller會創(chuàng)建相應(yīng)的Command對象尽棕,然后將其交給CommandBus喳挑,由CommandBus統(tǒng)一派發(fā)到相應(yīng)的CommandExecutor中去執(zhí)行,我們的ICommandBus的接口聲明如下:
namespace Tdf.CQRS.Commanding
{
public interface ICommandBus
{
void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
}
}
當(dāng)在實際項目中應(yīng)用CQRS時滔悉,我們會發(fā)現(xiàn)上面的做法存在一個問題:有時候我們希望Command在執(zhí)行完后返回一些結(jié)果伊诵,但上面的Send方法返回void,也就意味著我們沒有辦法得到執(zhí)行結(jié)果回官。我們以一個用戶注冊的例子來說明曹宴。
在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; }
}
}
在調(diào)用CommandBus.Send()之前,我們完全不用理會這個ExecutionResult屬性版扩,對于Controller的開發(fā)人員來說废离,他只要知道在Command執(zhí)行完后,ExecutionResult的值就會被賦上礁芦,如果沒有蜻韭,那就是CommandExecutor的bug。
而我們的RegisterCommandExecutor就可以改成(User類的構(gòu)造函數(shù)會調(diào)用Id = Guid.NewGuid().ToString()對自己的Id進(jìn)行賦值):
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屬性可以讓開發(fā)人員清楚的知道這個屬性會在Command執(zhí)行完后被賦上合適的值宴偿。對于一個Command湘捎,如果開發(fā)人員在其中找到類似ExecutionResult這樣的屬性,他就知道這個Command執(zhí)行完后會返回執(zhí)行結(jié)果窄刘,并且結(jié)果是以賦值的形式賦給Command中的ExecutionResult屬性,若Command中沒有發(fā)現(xiàn)ExecutionResult這樣的屬性舷胜,那開發(fā)人員便知道這個Command執(zhí)行完不會返回執(zhí)行結(jié)果娩践。
到目前為止,我們所討論的Command都是同步執(zhí)行的烹骨,如果Command被設(shè)計為異步執(zhí)行翻伺,那本文所討論的內(nèi)容便可以直接忽略。
如果系統(tǒng)的性能可以滿足需求沮焕,同步Command無疑是最好的吨岭。
CQRS架構(gòu)的優(yōu)點
- CQ兩端架構(gòu)分離、相互不受束縛峦树,各自獨立設(shè)計辣辫、擴(kuò)展
- C端通常結(jié)合DDD,解決復(fù)雜的業(yè)務(wù)邏輯魁巩;Q端輕量級查詢急灭,多種不同的查詢視圖通過訂閱事件來更新
- C端通過分布式消息隊列水平擴(kuò)展,天然支持削峰
- EDA架構(gòu)谷遂,整個系統(tǒng)各個部分松耦合葬馋,可擴(kuò)展性好
- 架構(gòu)層面做到無并發(fā),實現(xiàn)Command的高吞吐
- 技術(shù)架構(gòu)和業(yè)務(wù)代碼完全分離肾扰,程序員不用關(guān)心技術(shù)問題
- 更方便的分工合作
CQRS架構(gòu)的缺點
- 不是強(qiáng)一致性畴嘶,而是面向最終一致性
- 強(qiáng)依賴高性能可靠的分布式消息隊列
- 必須有強(qiáng)大可靠的CQRS框架,從頭做起成本高集晚、風(fēng)險大
- 必須結(jié)合Event Sourcing模式窗悯,否則CQ分離意義不大
- Event Sourcing模式的缺點
- 一些CQRS的最佳原則提高了開發(fā)人員的門檻