CQRS实践(2): Command的实现
概述
继续引用上篇文章中的图片(来源于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之类的。
代码下载
https://files.cnblogs.com/mouhong-lin/CQRS.zip
说明:下载的代码和文章中的代码不完全一致,但也不会有太大差别。示例代码中只实现了Command和用户注册功能,其它的如事件之类皆未包含。
PS: 关于技术文章的写作,我最怕的是自己的理解有偏差,以致于造成不好的影响,但不写又没有讨论。今晚突然想到一个自我感觉比较不错的建议:有兴趣的童鞋在阅读的过程中,若感觉某句或某观点不准确,可以以评论的形式提出,之后作者以不删原句的形式进行修改(将原句子用删除线划掉),这样既可以让文章变得更严谨,同时也会清楚的看到哪些观点经过了什么样的修正。