上篇隨筆討論了CQRS中Command的一種基本實現。
面對UI中的各種命令,Controller會創建相應的Command對象,然后將其交給CommandBus,由CommandBus統一派發到相應的CommandExecutor中去執行,我們的ICommandBus的接口聲明如下:
public interface ICommandBus
{
void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
}
當在實際項目中應用CQRS時,我們會發現上面的做法存在一個問題:有時候我們希望Command在執行完后返回一些結果,但上面的Send方法返回void,也就意味着我們沒有辦法得到執行結果。我們以一個用戶注冊的例子來說明。
數據庫中用戶表(User)的定義為User (Id, Email, NickName, Password),括號中的為字段,其中Id為varchar(36)的Guid字符串。
注冊用戶的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 RegisterCommand()
{
}
}
假設在注冊后需要將新注冊用戶的Id存到Session中,那Controller的實現就變得有點糾結:
[HttpPost]
public ActionResult Register(RegisterCommand command)
{
CommandBus.Send(command);
Session["CurrentUserId"] = ???
return Redirect("/");
}
上面的???處要怎么寫?我們無法直接得到新注冊用戶的Id,因為Id只有在Command執行時才會生成。
所以只能在CommandBus.Send(command)的下一行添加一個查詢:
[HttpPost]
public ActionResult Register(RegisterCommand command)
{
CommandBus.Send(command);
var newUserId = Query<User>().OrderByDescending(u => u.Id).Select(u => u.Id).First();
Session["CurrentUserId"] = newUserId;
return Redirect("/");
}
這樣雖可以勉強解決問題,但比較繁瑣,而且也無法保證在Send之后查詢之前的這一小片時間里不會有其它新用戶產生。於是,我們開始反思Command的設計......
解決方案1
這個方案也正是Sharp Architecture所采用的,即添加一個帶兩個泛型參數的ICommandExecutor<TCommand, TResult>接口,這樣我們就有了兩個ICommandExecutor接口:
public interface ICommandExecutor<TCommand>
where TCommand : ICommand
{
void Execute(TCommand cmd);
}
// 這是新添加的帶有兩個泛型參數的接口
public interface ICommandExecutor<TCommand, TResult>
where TCommand : ICommand
{
// Execute方法返回值變成TResult
TResult Execute(TCommand cmd);
}
為了適應這種變化,我們也需要相應修改ICommandBus的接口:
public interface ICommandBus
{
void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
// 這個Send方法的返回值是TResult
TResult Send<TCommand, TResult>(TCommand cmd) where TCommand : ICommand;
}
看起來不錯,現在對於注冊用戶的例子,我們只要調用第二個Send方法即可:
[HttpPost]
public ActionResult Register(RegisterCommand command)
{
var newUserId = CommandBus.Send<RegisterCommand, string>(command);
Session["CurrentUserId"] = newUserId;
return Redirect("/");
}
但如果我們仔細看看,會發現這是一個非常糟糕的設計!Controller的開發人員怎么知道RegisterCommand執行完會返回結果?怎么知道返回的是string而不是int?Controller中可以寫成CommandBus.Send(command),也可以寫成CommandBus.Send<RegisterCommand, int>(command),也可以寫成CommandBus.Send<RegisterCommand, string>(command),同樣是發送RegisterCommand命令,這三種調用全都可以編譯通過,但是只有第三個才不會在運行時出現問題。
漸漸的,我們就會變成每調用一次CommandBus.Send()方法就要去查看對應的CommandExecutor是怎么實現的,這就讓Command和CommandExecutor相分離的設計變得一點意義都沒有。所以我們需要尋求其它的解決方案。
PS: Sharp Architecture中的ICommandHandler對應本文中的ICommandExecutor,ICommandProcessor對應本文中的ICommandBus,但我覺得它的ICommandProcessor的取名也太容易讓人誤解了,單從名字上看,誰能分清楚ICommandHandler和ICommandProcessor的區別?
解決方案2
其實這個方案非常簡單:在Command對象中添加一個ExecutionResult的屬性(這個屬性要放在具體的Command類中,不要放於ICommand接口中)。如上面的用戶注冊的例子,我們可以添加一個RegisterCommandResult的類,然后將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 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進行賦值):
class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
{
public IRepository<User> _repository;
public RegisterCommandExecutor(IRepository<User> repository)
{
_repository = repository;
}
public void Execute(RegisterCommand cmd)
{
var service = new RegistrationService(_repository);
var user = service.Register(cmd.Email, cmd.NickName, cmd.Password);
// 亮點在這里
cmd.ExecutionResult = new RegisterCommandResult
{
GeneratedUserId = user.Id
};
}
}
然后Controller就很簡單了:
[HttpPost]
public ActionResult Register(RegisterCommand command)
{
CommandBus.Send(command);
// 亮點在這里
Session["CurrentUserId"] = command.ExecutionResult.GeneratedUserId;
return Redirect("/");
}
這個方案和第一個方案的關鍵區別就在於,RegisterCommand中定義的ExecutionResult屬性可以讓開發人員清楚的知道這個屬性會在Command執行完后被賦上合適的值。對於一個Command,如果開發人員在其中找到類似ExecutionResult這樣的屬性,他就知道這個Command執行完后會返回執行結果,並且結果是以賦值的形式賦給Command中的ExecutionResult屬性,若Command中沒有發現ExecutionResult這樣的屬性,那開發人員便知道這個Command執行完不會返回執行結果。
PS: 因為本例中User.Id采用的是Guid字符串,它可以在創建User對象時立刻生成,所以下載中的代碼可以跑得還不錯,但如果User.Id是使用SQL Server的自增長int類型,那就跑不了了,因為UnitOfWork是在Command執行完后才Commit的,所以,要處理自增Id的情況,我們需要稍微修改相應代碼,比如將IUnitOfWork實例作為參數傳給ICommandExecutor.Execute()方法,並把IUnitOfWork的提交轉交給CommandExecutor負責,然后在對RegisterCommand.ExecutionResult屬性賦值前先調用IUnitOfWork.Commit()方法,這樣便可以解決問題。
到目前為止,我們所討論的Command都是同步執行的,如果Command被設計為異步執行,那本文所討論的內容便可以直接忽略。
如果系統的性能可以滿足需求,同步Command無疑是最好的。