CQRS實踐(2): Command的實現


CQRS

概述

繼續引用上篇文章中的圖片(來源於Udi Dahan博客),UI中的寫入操作都將被封裝為一個命令中,發送給Domain Model來處理。

我們遵循Domain Driven Design的設計思想,因此所有的業務邏輯都只在Domain Model中處理,Command中將不會帶有業務邏輯。Command中的代碼無非是通過Repository獲取某些個聚合根(Aggregate Root),然后將操作委托給相應的領域對象或領域服務來處理,僅此而已。

實現

實現上,我們會涉及三個東西:

(1) Command對象

Command對象的作用是用來封裝命令數據,所以這類對象以屬性為主,少量簡單方法,但注意這些方法中不能包含業務邏輯。

舉個用戶注冊的例子,用戶注冊是一個命令,所以我們需要一個RegisterCommand類,這個類定義如下:

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 Gender Gender { get; set; }

public RegisterCommand()
{
}
}


這個類的每個屬性基本上都對應着注冊表單中的一個輸入(為了方便起見,上面的每個屬性都是public set,但若屬性不多不影響編碼,最好把屬性都改成private set,然后將屬性的值通過構造函數傳入)。當用戶點擊“注冊”按鈕時,Controller(假設使用MVC作為表現層模式)中會創建一個RegisterCommand的實例,設置相應的值,然后調用CommandBus.Send(registerCommand),然后根據執行的情況顯示相應的信息給用戶。(CommandBus后面會講到)

(2) CommandExecutor

CommandExecutor的作用是執行一個命令,對於注冊的例子,我們會有一個RegisterCommandExecutor的類,它只有一個Execute方法,接受RegisterCommand參數:

public class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
{
private IRepository<User> _repository;

public RegisterCommandExecutor(IRepository<User> repository)
{
_repository = repository;
}

public void Execute(RegisterCommand cmd)
{
if (String.IsNullOrEmpty(cmd.Email))
throw new InvalidOperationException("Email is required.");

if (cmd.Password != cmd.ConfirmPassword)
throw new InvalidOperationException("Password not match.");

// other "Command parameter" validations

var service = new RegistrationService(_repository);
service.Register(cmd.Email, cmd.NickName, cmd.Password, cmd.Gender);
}
}

在Execute方法中,我們需要先驗證Command的正確性,但需要注意的是,這里的驗證只是驗證RegisterCommand中的數據是否合法,並非驗證業務邏輯。例如,這里會驗證郵箱是否為空且格式是否正確,但郵箱格式正確並不意味着就可以注冊,因為系統可能要求18歲以上的成年人才能注冊,而這屬於業務邏輯,RegistrationService將會負責確保所有的業務規則不被破壞,RegistrationService屬於Domain Service,存在於Domain Model中。

可以看到,CommandExecutor中主要有兩部分工作,一是驗證傳入的Command對象是否合法,二是調用領域模型完成操作。上一篇文章中提到的Command是一個概念層次的Command,它不單指(1)中的Command,而是包含了(1)和(2)等。

PS: 記得三四年前糾結於“三層架構”的時候,最搞不懂的應該算是“業務邏輯”了,現在似乎有點領悟。“業務邏輯”中關鍵的詞是“業務”,這也是它和其它邏輯如應用邏輯區分開來的關鍵因素,如果一個邏輯帶有“業務價值”,那它就算“業務”邏輯,否則就不算。比如下訂單時,如果客戶的退款次數超過100,那就不允許下單,這是業務邏輯;而"注冊時兩次輸入的密碼必須一致"則不算業務邏輯。但我仍有個問題,要求Email必須唯一算不算業務邏輯呢?我個人傾向於認為它是業務邏輯。那郵箱格式必須正確(即中間必須有@符號等等)算業務邏輯嗎?個人傾向於認為是不算,如果不算業務邏輯,領域模型中需要對其進行驗證嗎?個人傾向於不用在領域模型中驗證,這些邏輯應該在CommandExecutor中進行驗證。不知道大家的看法如何?

(3) Command Bus

用於執行Command的是CommandExecutor,但CommandExecutor卻並不用來在UI層調用,UI層中只會用到Command對象和即將提到的Command Bus。Command Bus的作用是將一個Command派發給相應的CommandExecutor去執行。在開發UI層時,我們不需要關心Command會被哪個Executor執行了,而只要知道,上帝賜予了我們一個CommandBus,我們只要創建好Command對象,扔給它,神奇的CommandBus就會幫我們把它執行完。這樣一來,對於UI層的開發來說,所涉及的概念很簡單,涉及的類也少,大部分的工作都是得到表單中的輸入,封裝成Command對象,扔給CommandBus。

下面是注冊的例子的Controller:

public class AccountController : Controller 
{
[HttpPost]
public ActionResult Register(RegisterCommand command)
{
if (ModelState.IsValid)
{
try
{
CommandBus.Execute(command);
FormsAuthentication.SetAuthCookie(command.Email, false);

return RedirectToAction("Index", "Home");
}
catch (Exception ex)
{
ModelState.AddModelError("Error", ex);
}
}

return View(command);
}
}


CommandBus的實現也很簡單。首先,我們需要讓CommandExecutor都實現一個泛型接口:

public interface ICommandExecutor<TCommand>
where TCommand : ICommand
{
void Execute(TCommand cmd);
}

其中ICommand是一個空接口,沒有任何方法(即Marker Interface),它的作用是實現編譯時約束,這樣我們可以限制傳入CommandExecutor的都是Command對象,而不是不小心傳錯的User對象(所有的Command對象都必須實現ICommand接口)。

然后,把CommandBus寫成這樣:

public static class CommandBus
{
public static void Send<TCommand>(TCommand cmd) where TCommand : ICommand {
var type = typeof(TCommand);
var executorType = FindExecutorType(type);
var executor = Activator.CreateInstance(executorType);
executor.Executor(cmd);
}
}

在這個Send方法中,我們通過反射獲取到泛型參數為傳入的Command對象的具體類型的Executor類,再調用其Execute方法即可。上面的代碼是偽代碼,實際實現中我們可以通過IoC框架來簡化這個過程,另外也可以做一些改進,例如將CommandBus設計為擴展點之一。另外我們還可以將UnitOfWork(相當於平常的EntityFramework中的IDbContext,Linq 2 SQL中的DataContext)的生命周期在CommandBus中進行控制。

比較完整的CommandBus代碼如下(仍有小部分偽代碼):

public interface ICommandBus
{
void Execute<TCommand>(TCommand cmd) where TCommand : ICommand;
}
public class DefaultCommandBus : ICommandBus
{
public void Send<TCommand>(TCommand cmd) where TCommand : ICommand
{
UnitOfWorkContext.StartUnitOfWork();

var executor = ObjectContainer.Resolve<ICommandExecutor<TCommand>>();
executor.Execute(cmd);

UnitOfWorkContext.Commit();
}
}

其它的代碼不貼在文章中,所有代碼可以文末處下載。

這樣我們就完成了CQRS中Command的一個基本實現。

一些注意點

(1) Command表示想要執行的命令,所以Command類的類名應當是動詞的形式。例如RegisterCommand, ChangePasswordCommand等。不過Command后綴則是可選的,只要能保持一致即可。

(2) Command和CommandExecutor是一一對應的。也就是說,一個Command只會對應一個CommandExecutor,這和后面的事件有區別,事件是一對多的,一個Event可以對應多個EventHandler。

(3) 從文中的AccountController的Register Action中可以看到,Command對象也起到了DTO(Data Transfer Object,在這個例子中感覺稱作View Model也無妨)的作用,這也是把Command和Executor相分離,不把Execute方法直接寫在Command類中的原因之一。

(4) 注意Command的類名的重要作用,每個Command類的名稱都清晰地表達了一個意圖,例如ChangePasswordCommand清晰的表達了這個命令是要修改密碼,所以千萬不要隨意"復用"Command,這里的“復用”指的是,看到某兩個Command中有完全一樣的屬性,就覺得沒有必要使用兩個Command,而把它們合並成一個Command,這樣的"復用"會讓系統變得越來越難以理解,雖然它可能的確減少了幾行代碼。

(5) 命令通常是用“發送”來描述,而事件則是用“發布”來描述,所以CommandBus中的方法名稱個人認為應該用Send比較合適,而不用Publish之類的。 

代碼下載

http://files.cnblogs.com/mouhong-lin/CQRS.zip

說明:下載的代碼和文章中的代碼不完全一致,但也不會有太大差別。示例代碼中只實現了Command和用戶注冊功能,其它的如事件之類皆未包含。

 

PS: 關於技術文章的寫作,我最怕的是自己的理解有偏差,以致於造成不好的影響,但不寫又沒有討論。今晚突然想到一個自我感覺比較不錯的建議:有興趣的童鞋在閱讀的過程中,若感覺某句或某觀點不准確,可以以評論的形式提出,之后作者以不刪原句的形式進行修改(將原句子用刪除線划掉),這樣既可以讓文章變得更嚴謹,同時也會清楚的看到哪些觀點經過了什么樣的修正。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM