简述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 映射。
文中链接:
clean architecture https://www.pluralsight.com/courses/clean-architecture-patterns-practices-principles
Command-query separation https://en.wikipedia.org/wiki/Command%E2%80%93query_separation
Martin Fowler’s 谈 CQS https://martinfowler.com/bliki/CommandQuerySeparation.html
分离点关注1 https://en.wikipedia.org/wiki/Separation_of_concerns
分离点关注2 https://www.goodreads.com/quotes/tag/separation-of-concerns
最终一致性 https://theacetechnologist.com/post/eventually-consistent-architecture-pattern/
英文原文:
https://levelup.gitconnected.com/3-cqrs-architectures-that-every-software-architect-should-know-a7f69aae8b6c
团队开发框架实战—CQRS架构
这里只通过Udi Dahan的《Clarified CQRS》文章中的一张图片简要介绍一下:
UI上有两种类型的操作:命令和查询,例如显示销量最好的5个产品就属于查询,而提交一个订单、修改密码等则属于命令。因为大部分系统都是读多写少,而且业务逻辑基本都出现在写入的一端,所以查询和命令的分离可以让我们独立的去优化查询。
上图中,可以看到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,这对于界面的加载速度绝对是有利无弊的(其实也是在用空间换时间)。
业务逻辑大部分都发生在写入的时候,例如用户购买商品提交订单时,我们要验证库存,用户信息订单数据是否有效等。如果从传统DDD的角度看,Command类似于Application Service,用户的命令(如提交订单)会以Command的形式得到执行,而Command中也不会带有业务逻辑,Command中做的事情基本上是:通过Repository得到相关的领域对象,调用某些领域服务(Domain Service)执行一些操作(业务逻辑都将保留在领域模型中),然后执行Commit或SaveChanges之类的方法提交改动,之后,相关的数据就会写入到Write DB中(图的DB,下文统称Write DB)。需要注意的是,UI上的查询都是查Read DB,而不是Write DB。
这和Evans的DDD中说的领域模型没有太多区别,是“the heart of software”。
领域事件占据的地位非常重要,不仅限于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中表结构改变的问题,后面再说)。
UI中的写入操作都将被封装为一个命令中,发送给Domain Model来处理。
我们遵循Domain Driven Design的设计思想,因此所有的业务逻辑都只在Domain Model中处理,Command中将不会带有业务逻辑。Command中的代码无非是通过Repository获取某些个聚合根(Aggregate Root),然后将操作委托给相应的领域对象或领域服务来处理,仅此而已。
实现上,我们会涉及三个东西:
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的作用是执行一个命令,对于注册的例子,我们会有一个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
{
public IRepository _repository;
public RegisterCommandExecutor(IRepository 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的是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
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 cmd) where TCommand : ICommand;
}
}
using Tdf.CQRS.Data;
namespace Tdf.CQRS.Commanding
{
public class CommandBus : ICommandBus
{
public void Send(TCommand cmd) where TCommand : ICommand
{
try
{
var unitOfWork = UnitOfWork.StartUnitOfWork();
var executor = ObjectContainer.Resolve>();
executor.Execute(cmd);
UnitOfWork.Commit();
}
finally
{
UnitOfWork.Close();
}
}
}
}
面对UI中的各种命令,Controller会创建相应的Command对象,然后将其交给CommandBus,由CommandBus统一派发到相应的CommandExecutor中去执行,我们的ICommandBus的接口声明如下:
namespace Tdf.CQRS.Commanding
{
public interface ICommandBus
{
void Send(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
{
public IRepository _repository;
public RegisterCommandExecutor(IRepository 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无疑是最好的。