【AICon】探索RAG 技术在实际应用中遇到的挑战及应对策略!AICon精华内容已上线73%>>> 了解详情
写点什么

从 CQS 到 CQRS

  • 2017-12-18
  • 本文字数:4440 字

    阅读完需:约 15 分钟

一个以数据为中心的应用程序,它实现了基本的 CRUD 操作,并把业务流程留给用户去操心,那么用户就可以在不修改应用程序的情况下改变业务流程。但反过来看,用户必须了解所有业务流程的细节,如果我们有很多繁杂的业务流程,而且需要很多人了解它们,那么就会成为一个大问题。

以数据为中心的应用程序对业务流程一无所知,所以除了修改原始数据,它们什么也做不了。于是它们就变成了数据模型的抽象体,而业务流程仅存在于应用程序用户的脑子里。

一个真正有用的应用程序应该能够为用户分忧解难,通过捕捉用户的意图将“业务流程”的重担从用户肩上移走,它不仅能存储数据,还应该具备处理业务流程的能力。

有一些技术为应用程序提供了准确的领域映射,它们组合在一起,演化成 CQRS,突破了技术上常见的一些局限。

命令查询分离(Command Query Separation)

Bertrand Meyer 在 1988 年出版的《面向对象软件架构》一书中提出了“命令查询分离”的概念,这本书被认为是早期面向对象领域最具影响力的著作之一。

Meyer 说,原则上一个方法不应该既修改数据又返回数据,所以我们就有了两类方法:

  1. 查询:返回数据,但不修改数据,所以不会产生副作用;
  2. 命令:修改数据,但不返回数据。这与单一职责原则(Single Resposibility Principle)一脉相承。

不过,仍然会有一些模式逃逸于这个原则之外。比如,正如 Martin Fowler 所说的,从一个队列或栈弹出一个元素时,不仅改变了这个队列或栈,还返回了一个元素。

命令模式

命令模式旨在将以数据为中心的应用程序变成以流程为中心,以流程为中心的应用程序具备了领域知识和流程知识。

从应用层面看,用户不需要为了注册一个账号而执行一系列动作,如“创建用户”、“激活用户”和“发送邮件通知”,他们只需要执行一个“注册用户”命令就可以了,其他步骤已经被封装到业务流程里。

举一个更有趣的例子,假设我们要填写一份表单来修改客户数据,我们可以修改客户的名字、地址、电话号码以及他是否是一个有优先权的客户。我们再假设只有已经支付过账单的客户才能成为有优先权的客户。在一个 CRUD 应用程序里,我们会先接收客户的数据,检查这个客户是否支付过账单,然后决定是接受还是拒绝修改这个客户的数据。我们还有另一个业务流程:不管客户是否支付过账单,都应该能修改客户的名字、地址和电话号码。如果使用命令模式,我们可以创建两个命令,分别代表这两个不同的业务流程:一个用于修改客户数据,一个用于更新客户的状态,这两个流程都可以通过同一个 UI 来触发。

在修改数据时,命令提供了恰到好处的粒度和意图。
——《CQRS 详解》,Udi Dahan,2009

命令模式里仍然可以存在像“创建用户”这样的简单命令。CRUD 可以与带有意图的操作共存,形成复杂的业务流程,只要不滥用它们就可以了。

从技术角度来看,《Head First Design Patterns》一书中所描述的命令模式把所有相关的动作封装了起来。如果我们有一系列不同的业务流程(也就是命令)需要在同一个位置上执行,那么这样做就很有用,前提是它们必须要有同样的接口。例如,所有的命令都需要有 execute() 方法,这样它们就可以在适当的时候被执行。业务流程(命令)就可以被加入队列,在适当的时候再执行,既可以同步执行也可以异步执行。

《Head First Design Patterns》在解释命令模式时,以远程控制房子里的电灯为例。我在这里也会使用这个作为例子,虽然我觉得它并不是非常恰当。

假设我们有一个控制面板用于控制房子里的电灯,面板上有一个按钮用来打开厨房的灯,另一个用来关闭它们。每一个按钮都代表了一个命令,用于控制房子里的电灯。

这个系统可以设计成这样:

当然,这样的设计其实是不成熟的,它甚至都没有考虑使用 DIC(密度指示控制器),也没有使用合适的 UML。不过,我们假设它可以实现我们的目的:初始化 LightController,传入构造参数 CommandInvoker,并触发控制器动作 KitchenLightOnAction。这个动作将初始化 KitchenLight 和 KitchenLightOnCommand,并将 KitchenLight 对象作为构造参数传给 KitchenLightOnCommand。CommandInvoker 将会在某个时间点执行 KitchenLightOnCommand。我们需要创建另一个 Action 和 Command 来关闭电灯,不过设计过程基本上是一样的。

这样我们就有了分别用于打开和关闭电灯的两个命令。如果我们要设置 50% 的亮度该怎么办?我们需要创建另一个命令!而如果要设置 25% 或 75% 的亮度呢?我们需要创建更多的命令!如果我们使用调光器代替按钮来设置任意亮度又该怎么办?我们总不能创建不计其数的命令吧!

这种实现方式的问题在于,命令是一样的,但数据(也就是亮度)一直在变。所以,我们应该只使用一个包含相同逻辑的命令,然后使用不同的数据来执行,但问题是命令接口的 execute() 方法是不接受参数的。如果它接受参数,就会破坏命令模式的设计初衷(封装所有的业务逻辑,不需要知道具体要执行的是什么)。

当然,我们可以将数据作为构造参数传给命令,但这样也不优雅。实际上,这样做带有侵入性,因为数据变成了执行业务逻辑的前提,也就是说,数据成为这个方法的依赖项。

命令总线

为了打破命令模式的局限性,我们能够做的是使用最古老的面向对象原则:让变化部分和不变部分相分离。

在这种情况下,数据就是会发生变化的部分,命令里的逻辑就是不变的部分,所以我们可以把它们分别放到两个类里面。一个是简单的包含了数据的 DTO 对象(我们把它叫作命令),另一个则包含了要执行的逻辑(我们把它叫作处理器),它有一个用于触发执行逻辑的方法,也就是 execute(CommandInterface $command):void。我们还把 CommandInvoker 变成可以接收命令并为命令分配处理器的实体,我们称之为命令总线。

另外,通过修改用户接口,很多命令可以不需要马上执行,它们可以被放到队列里等待异步执行。这样可以让系统更健壮:

  1. 返回给用户的响应会更快,因为我们不需要立即执行命令;
  2. 如果系统出现 bug 或数据库离线导致命令执行失败,用户并不会感知到,我们可以在系统修复之后重放命令。

我们在一个中心位置触发逻辑(触发处理器),同时可以在启动处理器之前或执行完处理器之后加入逻辑。例如,我们可以在将数据传给处理器之前对其进行验证,或者将处理器放进一个数据库事务里,我们也可以让命令总线支持复杂的队列操作、异步命令或异步处理器。

命令总线通常使用装饰器(decorator)来实现这些功能,装饰器就像俄罗斯套娃一样层层包装命令总线。

我们可以创建自己的装饰器,按照任意的顺序来包装命令总线,并添加自定义功能。如果要用到命令队列,就加入一个装饰器来管理命令队列。如果没有用到事务性数据库,就不需要装饰器。

命令查询责任分离(Command Query Responsibility Segregation)

将 CQS、命令、查询和命令总线放在一起,我们就得到了 CQRS。CQRS 有不同的实现方式,可以只有命令端,也可以不使用命令总线。下图展示了一个完整的 CQRS 实现:

查询端

在 CQS 里,查询端只返回数据,完全不修改数据。因为我们不打算在这些数据上执行业务逻辑,所以不需要业务对象(也就是实体),也没必要使用 ORM 框架。我们只需要查询原始数据,并把它们嵌到视图模板展示给用户!

这在性能方面具有一定的优势:在查询数据时我们不需要经过业务层,我们只获取必要的数据。

这里还存在一种优化的可能性——将数据完全独立地保存到两个数据存储里:一个为写优化,另一个为读优化。举个例子,如果我们正在使用一个 RDBMS:

  1. 读操作不需要做数据完整性检查,因为在写入数据时已经做过数据完整性检查,所以连外键约束也不需要了。我们可以从读数据库中移除数据完整性约束。
  2. 我们还可以结合使用数据库视图和视图模板来加快查询速度(尽管在修改模板时需要同步数据库视图,从而给系统增加一定的复杂性)。

如果每个模板都有对应的数据库视图,那我们还需要专门用于读操作的 RDBMS 吗?或许我们可以改用文档数据库,比如 MongoDB 或 Redis。但谁知道呢,我只是觉得在遇到性能问题时可以多想想其他的解决方案。

查询本身可以通过查询对象实现,查询对象返回一组数据,并应用在模板上。我们也可以使用更复杂的查询总线,它接收模板名称,使用查询对象查询数据,并返回模板所需要的视图模型(ViewModel)。

这种方式可以解决 Greg Young 提出的一些问题:

  • 读取数据操作通常包含分页和排序信息;
  • Getter 方法会暴露领域对象的内部状态;
  • 使用预加载路径会导致 ORM 加载更多的数据;
  • 通过聚合的方式构建 DTO 会导致不必要的查询;
  • 最大的问题是查询优化变得相当困难:因为查询先是作用在对象模型上,然后被转译成数据模型(比如使用了 ORM 框架),难以对其进行优化。

命令端

通过使用命令,应用程序从以数据为中心的设计变成以行为为中心的设计,这与领域驱动设计一脉相承。

将读操作从处理命令的代码和领域中移出之后,Greg Young 之前所说的问题也就不存在了:

  • 领域对象不需要暴露内部状态;
  • 除了 GetById 之外,只需要少量的查询方法;
  • 聚合边界聚焦在行为上。

实体之间的“one-to-many”和“many-to-many”关系对 ORM 的性能有重大影响。所幸的是,在处理命令时很少需要用到这些关系,它们一般用于查询,而我们已经将查询移出了命令处理流程,所以也就可以移除这些关系。当然,这里所说的关系并不是指数据库的表间关系,表间的外键约束仍然会存在,这里所说的关系指的是 ORM 层面的实体之间的关系。

业务流程事件

在成功处理完一个命令后,处理器会触发一个事件,用于通知应用程序的其他部分。事件的名称应该要与其对应的命令一样,而作为一个事件,应该使用过去式。

总结

CQRS 让读模型和写模型完全分离,因此可以对读操作和写操作进行优化。这样会带来性能上的提升,同时也会让代码更清晰、更简单,代码反映了领域模型,提升了代码的可维护性。

这一切都是关于封装、低耦合、高聚合和单一职责原则。

不过,尽管 CQRS 可以让应用程序变得更健壮,但并不是说所有的应用程序都要使用 CQRS:我们应该在必要的时候选择正确的方案。

参考资料:

1994 – Gamma, Helm, Johnson, Vlissides – Design Patterns: Elements of Reusable Object-Oriented Software
1999 – Bala Paranj – Java Tip 68: Learn how to implement the Command pattern in Java <
2004 – Eric Freeman, Elisabeth Robson – Head First Design Patterns
2005 – Martin Fowler – Command Query Separation
2009 – Udi Dahan – Clarified CQRS
2010 – Greg Young – CQRS, Task Based UIs, Event Sourcing agh!
2010 – Greg Young – CQRS Documents
2010 – Udi Dahan – Race Conditions Don’t Exist
2011 – Martin Fowler – CQRS
2011 – Udi Dahan – When to avoid CQRS
2014 – Greg Young – CQRS and Event Sourcing – Code on the Beach 2014
2015 – Matthias Noback – Responsibilities of the command bus
2017 – Martin Fowler – What do you mean by “Event-Driven”?
2017* – Doug Gale – Command Pattern
2017* – Wikipedia – Command Pattern

查看英文原文 From CQS to CQRS

感谢雨多田光对本文的审校。

公众号推荐:

2024 年 1 月,InfoQ 研究中心重磅发布《大语言模型综合能力测评报告 2024》,揭示了 10 个大模型在语义理解、文学创作、知识问答等领域的卓越表现。ChatGPT-4、文心一言等领先模型在编程、逻辑推理等方面展现出惊人的进步,预示着大模型将在 2024 年迎来更广泛的应用和创新。关注公众号「AI 前线」,回复「大模型报告」免费获取电子版研究报告。

AI 前线公众号
2017-12-18 16:472959
用户头像

发布了 322 篇内容, 共 133.7 次阅读, 收获喜欢 142 次。

关注

评论

发布
暂无评论
发现更多内容

三策略,六步骤,Jenkins 迁移到极狐GitLab CI 的终极指南

极狐GitLab

ci DevOps gitlab 持续集成 jenkins

交易所开发:加密货币交易平台开发的见解

区块链软件开发推广运营

交易所开发 dapp开发 区块链开发 链游开发 公链开发

代码迭代:软件开发者在众包平台的发展之路

知者如C

PDF Squeezer for Mac(强大的PDF文件压缩工具)

晴雯哥

Adobe InDesign 2024 for mac v19.0 专业排版工具

晴雯哥

Xmind for Mac(思维导图软件) 24.01永久激活版

mac

XMind 思维导图软件 苹果mac Windows软件

亲身体验云原生顶会北美 KubeCon,5个要点和4个 Fun Facts

小猿姐

Kubernetes 云原生 cncf KubeCON

MatrixOne完成与欧拉、麒麟信安的兼容互认

MatrixOrigin

分布式数据库 云原生数据库 MatrixOrigin MatrixOne HTAP数据库

又一个小而美的涵盖多个实际场景的高并发项目完结了

这我可不懂

软件开发 TDD 测试驱动开发

超过5000+企业使用的ETL平台

RestCloud

ETL

大数据的技术运用:探索未来的无限可能性

EquatorCoco

大数据 技术应用 城市智能化 医疗健康

情感语音识别技术的发展趋势与前景

来自四九城儿

SecureFX for Mac(ftp文件传输工具)附注册码 v9.4.2永久激活版

mac

苹果mac Windows软件 SecureFX 文件传输客户端

AI机器学习实战:构建智能系统的关键步骤

不在线第一只蜗牛

人工智能 机器学习 AI

云电脑运行原理分析

天翼云开发者社区

虚拟化 云平台 云电脑

聚焦数据安全,神州数码联合多方发布《数据分类分级自动化建设指南》

科技热闻

Linux 爱好者线下沙龙:成都场圆满结束 & 下一场西子湖畔相见 | LLUG·第五站

OpenAnolis小助手

操作系统 杭州 龙蜥社区 LLUG Linux中国

item_get_pro-获得淘宝商品详情高级版api接口

技术冰糖葫芦

API 文档

使用CURL获取速卖通详情的API接口

Noah

AppLink上的小鹅通能实现什么操作呢?

RestCloud

APPlink

MatrixOne 支持多样化生态工具,持续提升开发者体验

MatrixOrigin

分布式数据库 云原生数据库 MatrixOrigin MatrixOne HTAP数据库

避免defer陷阱:拆解延迟语句,掌握正确使用方法

伤感汤姆布利柏

有限元分析初学者需要关注哪些问题?

思茂信息

仿真软件 仿真技术 有限元分析 有限元仿真 有限元技术

小程序转换工具—Antmove 使用教学

FN0

小程序 Antmove

MatrixOne 实战系列回顾 | 建模与多租户

MatrixOrigin

分布式数据库 云原生数据库 MatrixOrigin MatrixOne HTAP数据库

情感语音识别的研究方法与实践

来自四九城儿

SaaS与PaaS平台的区别

树上有只程序猿

低代码 PaaS SaaS

情感语音识别技术的挑战与未来发展

来自四九城儿

来听B站音乐UP主从容老师讲解GuitarPro和Earmaster

淋雨

Guitar Pro EarMaster 吉他 声乐 视唱

专家分享——CAE仿真软件学习心得

智造软件

仿真 CAE 仿真软件 CAE软件 altair

弹性云主机支持多种规格

天翼云开发者社区

云计算 云主机 云平台

从CQS到CQRS_数据库_hgraca_InfoQ精选文章