CQRS实践(2): Command的实现

只愿长相守 提交于 2020-03-23 10:07:21

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: 关于技术文章的写作,我最怕的是自己的理解有偏差,以致于造成不好的影响,但不写又没有讨论。今晚突然想到一个自我感觉比较不错的建议:有兴趣的童鞋在阅读的过程中,若感觉某句或某观点不准确,可以以评论的形式提出,之后作者以不删原句的形式进行修改(将原句子用删除线划掉),这样既可以让文章变得更严谨,同时也会清楚的看到哪些观点经过了什么样的修正。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!