简述3种CQRS架构模式

简述3种CQRS架构模式_朱小厮的博客-CSDN博客

团队开发框架实战—CQRS架构 - 简书

关注点分离是一种有效整理架构思想的技巧,你应当把注意力集中在一个方面。

Edsger W. Dijkstra

命令/查询分离(CQS)

1988 年,Bertrand Meyer 在面向对象的软件设计一书中设计了 CQS 原则。简单来说,这个原则是说程序应当要么修改系统(Command),要么返回查询结果(Query),软件中应当保持命令与查询的分离。

尽管 Martin Fowler 在他 2005 年的博客文章中也提到,这种分离并非总是可能的,一个很好的例子是返回一个刚插入的记录的 id。首先,你要把记录持久化(Command),其次,你要获得它新分配的 id(Query)。

CQRS 架构

CQRS 建议将应用程序层分为两个方面,即命令端(Command)和查询端(Query)。

查询端负责优化读取数据。从持久化获取数据,然后将它们映射到展现层表单,这些表单通常被标识为数据传输对象(DTO)。

命令端关注优化写入数据。命令执行各种用例,修改实体状态并将其持久化。

通过分离读写操作,我们提高了性能,并在系统中支持关注点分离原则

本文介绍 3 种主要的 CQRS 架构实现。

单数据库 CQRS

单一数据库CQRS 模式没有正式名称,Mattew Renze 在他的课程Clean Architecture 中将其命名为单一数据库 CQRS,我也选择这个命名。

单数据库CQRS

顾名思义,双方都在和一个数据库对话。Command 在域中执行用例,从而修改实体的状态,然后通过 ORM 如 Entity Framework Core 或 Hibernate 将实体保存到数据库中。

Query 直接通过数据访问层执行,数据访问层要么是使用各种 ORM,要么通过存储过程。

双数据库 CQRS

在“双数据库”方式中,我们需要两个数据库,一个用于写操作,一个用于读操作。命令端使用针对写操作优化的数据库。查询端使用针对读取操作优化的数据库。

双数据库CQRS

命令每改变一个状态,修改后的数据就必须从写数据库推送到读数据库中,或者作为一个跨两个数据库的分布式事务,或者使用最终一致性模型

这种架构给软件的查询端带来了数量级的性能提升,这是有利的,因为一般系统在读数据上花费的时间一般比写数据要更多。

事件源 (Event source) CQRS

最后一种是最复杂的 CQRS 架构。与前面两种方式相比,事件源存储数据的思路完全不同。

在事件源方法中,我们并不只存储实体的当前状态,而且将实体发生的每一个状态作为快照来存储。实体并不是以标准化数据的形式保存,而是通过事件的时间戳来保存它们的变更。

事件源CQRS

事件源带有以下好处:

  • 事件存储包括完整的审计跟踪,可以在需要严格监管的场景中派上用场。

  • 可以在任何时间点重建任何实体的任何状态,这对于调试非常有用。

  • 可以重放事件,查看系统中任何时候到底发生了什么。这个功能对于压力测试和 bug 修复非常有用。

  • 可以轻松地重建生产数据库。

  • 有多个为读优化的数据存储。

但在另一方面,这种方式实现很复杂,如果你不能从其中受益,那么用这个模式可能适得其反。

小结

CQRS 真正的威力在于可以对写和读操作进行不同的优化。但在另一方面,系统会变得更加复杂,命令端和查询端代码不完全一致。并且由于存在多个数据库,管理更复杂,需要更繁琐的 ORM 映射。

文中链接:

  1. clean architecture https://www.pluralsight.com/courses/clean-architecture-patterns-practices-principles

  2. Command-query separation https://en.wikipedia.org/wiki/Command%E2%80%93query_separation

  3. Martin Fowler’s 谈 CQS https://martinfowler.com/bliki/CommandQuerySeparation.html

  4. 分离点关注1 https://en.wikipedia.org/wiki/Separation_of_concerns

  5. 分离点关注2 https://www.goodreads.com/quotes/tag/separation-of-concerns

  6. 最终一致性 https://theacetechnologist.com/post/eventually-consistent-architecture-pattern/

英文原文:

https://levelup.gitconnected.com/3-cqrs-architectures-that-every-software-architect-should-know-a7f69aae8b6c

团队开发框架实战—CQRS架构

CQRS架构图

 

 

什么是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类,这个类定义如下:

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参数:

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都实现一个泛型接口:

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代码如下

namespace Tdf.CQRS.Commanding
{
    public interface ICommandBus
    {
        void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
    }
}

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的接口声明如下:

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改成如下所示:

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进行赋值):

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的最佳原则提高了开发人员的门槛

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值