前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >CQRS架构实战

CQRS架构实战

作者头像
IT云清
发布2021-12-06 14:13:37
5890
发布2021-12-06 14:13:37
举报
文章被收录于专栏:IT云清IT云清

1.CQRS架构图

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.什么是CQRS 这里只通过Udi Dahan的《Clarified CQRS》文章中的一张图片简要介绍一下:

在这里插入图片描述
在这里插入图片描述

UI上有两种类型的操作:命令和查询,例如显示销量最好的5个产品就属于查询,而提交一个订单、修改密码等则属于命令。因为大部分系统都是读多写少,而且业务逻辑基本都出现在写入的一端,所以查询和命令的分离可以让我们独立的去优化查询。

查询 (Query)

上图中,可以看到Query不是通过DB来查询,而是通过一个专门用于查询的Read DB(上图中的Cache,它不一定是数据库,但为方便起见,下面统称Read DB),Read DB中的表(方便起见,暂且认为这个Read DB是一个RDBMS)是专门针对UI优化过的,例如里面可能会有LatestProductListModel(ProductId, ProductName, Price, BrandName, AddedTime)、BestSoldProductListModel(ProductId, ProductName, TotalSold)这样的表,分别表示最新的产品列表,销量最好的产品列表(它们其实就相当于是View Model)。LatestProductListModel中有一个BrandName的字段,注意,不是BrandId,因此,对于界面中的查询,几乎全都可以通过SELECT * FROM [TABLE]这样的SQL语句来实现,可能有少数Where,但基本没有Join,这对于界面的加载速度绝对是有利无弊的(其实也是在用空间换时间)。

命令 (Command)

业务逻辑大部分都发生在写入的时候,例如用户购买商品提交订单时,我们要验证库存,用户信息订单数据是否有效等。如果从传统DDD的角度看,Command类似于Application Service,用户的命令(如提交订单)会以Command的形式得到执行,而Command中也不会带有业务逻辑,Command中做的事情基本上是:通过Repository得到相关的领域对象,调用某些领域服务(Domain Service)执行一些操作(业务逻辑都将保留在领域模型中),然后执行Commit或SaveChanges之类的方法提交改动,之后,相关的数据就会写入到Write DB中(图的DB,下文统称Write DB)。需要注意的是,UI上的查询都是查Read DB,而不是Write DB。

领域模型 (Domain Model)

这和Evans的DDD中说的领域模型没有太多区别,是“the heart of software”。

领域事件 (Domain Event)

领域事件占据的地位非常重要,不仅限于CQRS。相信会有一部分人曾和我一样碰到过这样的问题: Account实体(表示帐户)有个Balance属性(表示帐户余额),我们一般不会公开这个属性的setter,而是通过写一些IncreaseBalance(decimal amount)之类的方法来实现帐户余额的变动。 这时问题就来了,我们想在帐户变动时添加一条AccountLog记录,但Log记录成千上万,我们不能直接通过ORM的一对多映射把AccountLog集合实现成Account的一个集合属性,那我们就需要在IncreaseBalance()中得到AccountLogRepository,这样才有办法插入AccountLog(从DDD的角度,AccountLog不是聚合根,所以不能有AccountLogRepository,但在性能影响严重的时候,也只好做些取舍了)。 不管用了依赖注入还是什么的,总之,Account已经依赖上Repository了,这就让领域对象变得很不纯净,并且,假如我们以后不仅要记录log,还要短信通知用户呢?那要修改源代码吗?这也很不OCP。 而领域事件正好可以解决这种问题:只要在IncreaseBalance()方法的末尾,触发一个领域事件,然后我们独立写一个EventHandler的类去实现log的添加(框架可以保证EventHandler可以和领域事件绑定到一起)。 回到CQRS,因为Command将数据写到了Write DB中,而UI查询的是Read DB,那我们就需要用某种方式实现这两个数据库的同步,解决办法已经很明显了,写一堆的EventHandler类去监听领域事件。例如我们有一个更改产品价格的命令ChangePriceCommand,它执行后,一个叫做PriceChangedEvent会被触发,那我们只要写一个PirceChangedEventHandler的类,在这里面将Read DB中相关的价格信息更改到最新值即可实现同步(这里会涉及到Read DB中表结构改变的问题,后面再说)。

Command的实现

概述

UI中的写入操作都将被封装为一个命令中,发送给Domain Model来处理。 我们遵循Domain Driven Design的设计思想,因此所有的业务逻辑都只在Domain Model中处理,Command中将不会带有业务逻辑。Command中的代码无非是通过Repository获取某些个聚合根(Aggregate Root),然后将操作委托给相应的领域对象或领域服务来处理,仅此而已。

实现

实现上,我们会涉及三个东西:

Command对象 Command对象的作用是用来封装命令数据,所以这类对象以属性为主,少量简单方法,但注意这些方法中不能包含业务逻辑。 举个用户注册的例子,用户注册是一个命令,所以我们需要一个RegisterCommand类,这个类定义如下:

代码语言:javascript
复制
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()
        {
        }
    }
}

这个类的每个属性基本上都对应着注册表单中的一个输入(为了方便起见,上面的每个属性都是public set,但若属性不多不影响编码,最好把属性都改成private set,然后将属性的值通过构造函数传入)。当用户点击“注册”按钮时,Controller(假设使用MVC作为表现层模式)中会创建一个RegisterCommand的实例,设置相应的值,然后调用CommandBus.Send(registerCommand),然后根据执行的情况显示相应的信息给用户。(CommandBus后面会讲到)

CommandExecutor CommandExecutor的作用是执行一个命令,对于注册的例子,我们会有一个RegisterCommandExecutor的类,它只有一个Execute方法,接受RegisterCommand参数:

代码语言:javascript
复制
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中的数据是否合法,并非验证业务逻辑。例如,这里会验证邮箱是否为空且格式是否正确,但邮箱格式正确并不意味着就可以注册,因为系统可能要求18岁以上的成年人才能注册,而这属于业务逻辑,RegistrationService将会负责确保所有的业务规则不被破坏,RegistrationService属于Domain Service,存在于Domain Model中。

可以看到,CommandExecutor中主要有两部分工作,一是验证传入的Command对象是否合法,二是调用领域模型完成操作。上一篇文章中提到的Command是一个概念层次的Command,它不单指(1)中的Command,而是包含了(1)和(2)等。

Command Bus 用于执行Command的是CommandExecutor,但CommandExecutor却并不用来在UI层调用,UI层中只会用到Command对象和即将提到的Command Bus。Command Bus的作用是将一个Command派发给相应的CommandExecutor去执行。在开发UI层时,我们不需要关心Command会被哪个Executor执行了,而只要知道,上帝赐予了我们一个CommandBus,我们只要创建好Command对象,扔给它,神奇的CommandBus就会帮我们把它执行完。这样一来,对于UI层的开发来说,所涉及的概念很简单,涉及的类也少,大部分的工作都是得到表单中的输入,封装成Command对象,扔给CommandBus。

CommandBus的实现也很简单。首先,我们需要让CommandExecutor都实现一个泛型接口:

代码语言:javascript
复制
namespace Tdf.CQRS.Commanding
{
    public interface ICommandExecutor<TCommand>
        where TCommand : ICommand
    {
        void Execute(TCommand cmd);
    }
}

其中ICommand是一个空接口,没有任何方法(即Marker Interface),它的作用是实现编译时约束,这样我们可以限制传入CommandExecutor的都是Command对象,而不是不小心传错的User对象(所有的Command对象都必须实现ICommand接口)。

然后,把CommandBus写成这样: 通过IoC框架来简化这个过程,另外也可以做一些改进,例如将CommandBus设计为扩展点之一。另外我们还可以将UnitOfWork(相当于平常的EntityFramework中的IDbContext,Linq 2 SQL中的DataContext)的生命周期在CommandBus中进行控制。 比较完整的CommandBus代码如下

代码语言:javascript
复制
namespace Tdf.CQRS.Commanding
{
    public interface ICommandBus
    {
        void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
    }
}
代码语言:javascript
复制
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表示想要执行的命令,所以Command类的类名应当是动词的形式。例如RegisterCommand, ChangePasswordCommand等。不过Command后缀则是可选的,只要能保持一致即可。 Command和CommandExecutor是一一对应的。也就是说,一个Command只会对应一个CommandExecutor,这和后面的事件有区别,事件是一对多的,一个Event可以对应多个EventHandler。 Command对象也起到了DTO(Data Transfer Object,在这个例子中感觉称作View Model也无妨)的作用,这也是把Command和Executor相分离,不把Execute方法直接写在Command类中的原因之一。 注意Command的类名的重要作用,每个Command类的名称都清晰地表达了一个意图,例如ChangePasswordCommand清晰的表达了这个命令是要修改密码,所以千万不要随意"复用"Command,这里的“复用”指的是,看到某两个Command中有完全一样的属性,就觉得没有必要使用两个Command,而把它们合并成一个Command,这样的"复用"会让系统变得越来越难以理解,虽然它可能的确减少了几行代码。 命令通常是用“发送”来描述,而事件则是用“发布”来描述,所以CommandBus中的方法名称个人认为应该用Send比较合适,而不用Publish之类的。 Command执行结果的返回

面对UI中的各种命令,Controller会创建相应的Command对象,然后将其交给CommandBus,由CommandBus统一派发到相应的CommandExecutor中去执行,我们的ICommandBus的接口声明如下:

代码语言:javascript
复制
namespace Tdf.CQRS.Commanding
{
    public interface ICommandBus
    {
        void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
    }
}

当在实际项目中应用CQRS时,我们会发现上面的做法存在一个问题:有时候我们希望Command在执行完后返回一些结果,但上面的Send方法返回void,也就意味着我们没有办法得到执行结果。我们以一个用户注册的例子来说明。 在Command对象中添加一个ExecutionResult的属性(这个属性要放在具体的Command类中,不要放于ICommand接口中)。如上面的用户注册的例子,我们可以添加一个RegisterCommandResult的类,然后将RegisterCommand改成如下所示:

代码语言:javascript
复制
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; }
    }
}

在调用CommandBus.Send()之前,我们完全不用理会这个ExecutionResult属性,对于Controller的开发人员来说,他只要知道在Command执行完后,ExecutionResult的值就会被赋上,如果没有,那就是CommandExecutor的bug。

而我们的RegisterCommandExecutor就可以改成(User类的构造函数会调用Id = Guid.NewGuid().ToString()对自己的Id进行赋值):

代码语言:javascript
复制
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属性可以让开发人员清楚的知道这个属性会在Command执行完后被赋上合适的值。对于一个Command,如果开发人员在其中找到类似ExecutionResult这样的属性,他就知道这个Command执行完后会返回执行结果,并且结果是以赋值的形式赋给Command中的ExecutionResult属性,若Command中没有发现ExecutionResult这样的属性,那开发人员便知道这个Command执行完不会返回执行结果。 到目前为止,我们所讨论的Command都是同步执行的,如果Command被设计为异步执行,那本文所讨论的内容便可以直接忽略。 如果系统的性能可以满足需求,同步Command无疑是最好的。

CQRS架构的优点

CQ两端架构分离、相互不受束缚,各自独立设计、扩展 C端通常结合DDD,解决复杂的业务逻辑;Q端轻量级查询,多种不同的查询视图通过订阅事件来更新 C端通过分布式消息队列水平扩展,天然支持削峰 EDA架构,整个系统各个部分松耦合,可扩展性好 架构层面做到无并发,实现Command的高吞吐 技术架构和业务代码完全分离,程序员不用关心技术问题 更方便的分工合作 CQRS架构的缺点

不是强一致性,而是面向最终一致性 强依赖高性能可靠的分布式消息队列 必须有强大可靠的CQRS框架,从头做起成本高、风险大 必须结合Event Sourcing模式,否则CQ分离意义不大 Event Sourcing模式的缺点 一些CQRS的最佳原则提高了开发人员的门槛

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-12-25 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档