简述3种CQRS架构模式

简述3种CQRS架构模式简述 3 种 CQRS 架构模式 朱小厮的博客 CSDN 博客团队开发框架实战 CQRS 架构 简书关注点分离是一种有效整理架构思想的技巧 你应当把注意力集中在一个方面 EdsgerW Dijkstra 命令 查询分离 CQS 1988 年 BertrandMeye 在面向对象的软件设计一书中设计了 CQS 原则 简单来说 这个原则是说程序应当要么修改系统 Command 要么返回查询结果 Query 软件中应当保持命令与查询的分离 尽管 MartinFowler 在他

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

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

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

Edsger W. Dijkstra

简述3种CQRS架构模式

命令/查询分离(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,我也选择这个命名。

简述3种CQRS架构模式

单数据库CQRS

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

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

双数据库 CQRS

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

简述3种CQRS架构模式

双数据库CQRS

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

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

事件源 (Event source) CQRS

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

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

简述3种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架构图

简述3种CQRS架构模式

 

简述3种CQRS架构模式

 

什么是CQRS?

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

简述3种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的实现

概述

实现

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

  • Command对象
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 
  
    { 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 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 
  
    where TCommand : ICommand { void Execute(TCommand cmd); } } 
  

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

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(); } } } } 
    
  

一些注意点

  • 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 cmd) where TCommand : ICommand; } } 
  

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 }; } } } 
     
    
  

CQRS架构的优点

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

CQRS架构的缺点

  • 不是强一致性,而是面向最终一致性
  • 强依赖高性能可靠的分布式消息队列
  • 必须有强大可靠的CQRS框架,从头做起成本高、风险大
  • 必须结合Event Sourcing模式,否则CQ分离意义不大
  • Event Sourcing模式的缺点
  • 一些CQRS的最佳原则提高了开发人员的门槛
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。

发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/217628.html原文链接:https://javaforall.net

(0)
上一篇 2026年3月18日 上午9:08
下一篇 2026年3月18日 上午9:09


相关推荐

  • 计算机网络常见面试题总结

    计算机网络常见面试题总结

    2021年10月5日
    35
  • 使用Django 测试客户端一起测试视图,模板和URL

    使用Django 测试客户端一起测试视图,模板和URL

    2022年4月2日
    43
  • 大约apache 2.4.X虚拟主机配置问题的版本号后,

    大约apache 2.4.X虚拟主机配置问题的版本号后,

    2022年1月2日
    47
  • JVM调优工具总结(jConsole jmc jvisualvm)

    JVM调优工具总结(jConsole jmc jvisualvm)一、环境准备,centos7服务器一台,安装jdk1.8,一个可运行java的jar包,最好是有个服务器运维工具,我用的是宝塔面板,主要是用来开放端口,当然你是用命令开放也可以的。本地win10上安装jdk1.8二、启动1.将jar包放在linux服务器上,我的在/usr/java/jar目录下,ay.jarhostname远程主机地址port:10991这个不用改,默认就…

    2022年5月31日
    34
  • 如何锁定工作站[通俗易懂]

    如何锁定工作站[通俗易懂]如何锁定工作站        使用LockWorkStation函数即可锁定工作站。系统会显示一个锁定对话框,告诉用户此工作站正在使用并且已经被锁定,可以被执行锁定的用户或管理员解锁,解锁的方式是按下CTRL_ALT_DEL并用正确的帐号和密码登陆。      LockWorkStation函数成功调用的条件是:      调用者必须是运行在系统交互桌面上的一般进程。   

    2022年7月21日
    18
  • Docker的Flannel网络配置

    Docker的Flannel网络配置一 简介 1 介绍 Docker 跨主机容器间网络通信实现的工具有 Pipework Flannel Weave OpenvSwitch 虚拟交换机 CalicoFlanne 是 CoreOS 团队针对 Kubernetes 设计的一个网络规划服务 简单来说 它的功能是让集群中的不同节点主机创建的 Docker 容器都具有全集群唯一的虚拟 IP 地址 但在默认的 Docker 配置中 每个节点上的 Docker 服务会分别负责所在节点容器的 IP 分配 这样导致的一个问题是 不同节点上容器可能获得相同的内外 IP 地

    2026年3月17日
    2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注全栈程序员社区公众号