【ArchSummit】如何通过AIOps推动可量化的业务价值增长和效率提升?>>> 了解详情
写点什么

从 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

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

公众号推荐:

跳进 AI 的奇妙世界,一起探索未来工作的新风貌!想要深入了解 AI 如何成为产业创新的新引擎?好奇哪些城市正成为 AI 人才的新磁场?《中国生成式 AI 开发者洞察 2024》由 InfoQ 研究中心精心打造,为你深度解锁生成式 AI 领域的最新开发者动态。无论你是资深研发者,还是对生成式 AI 充满好奇的新手,这份报告都是你不可错过的知识宝典。欢迎大家扫码关注「AI前线」公众号,回复「开发者洞察」领取。

2017-12-18 16:472978
用户头像

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

关注

评论

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

GO训练营第6、7周——可用性设计

Glowry

GO训练营第9周——网络编程

Glowry

Python基础之:Python的数据结构

程序那些事

Python 程序那些事 python数据结构

看完了进程同步与互斥机制,我终于彻底理解了 PV 操作

飞天小牛肉

Java 程序员 面试 操作系统

共筑“新基建” 京东云全面开启渠道合作伙伴招募计划

京东科技开发者

云服务

高频量化交易系统开发功能丨量化交易机器人系统开发详情

系统开发咨询1357O98O718

fil挖矿系统开发|fil挖矿系统软件APP开发

系统开发

@Component,@Service等注解是如何被解析的?

Java小咖秀

spring 面试 工作 注解 经验

华为云数据库GaussDB(for openGauss):初次见面,认识一下

华为云开发者联盟

数据库 分布式 华为云 GaussDB(for openGauss) 开源数据库

Nirvana NA公链 NAC公链的两面观

区块链第一资讯

「 视频云大赛 — 大咖驾到 」下一代技术新浪潮,正由视频云驱动

阿里云视频云

阿里云 音视频 intel

【实战问题】-- 并发的时候分布式锁setnx细节

秦怀杂货店

Java 分布式 高并发

万物摩尔定律

soolaugust

AI

Go训练营第5周——评论系统架构设计

Glowry

全球币系统开发案例(源码)

系统开发咨询1357O98O718

马特机器人系统开发(成品案例,快速上线)

系统开发咨询1357O98O718

JVM - 类加载机制

insight

3月日更

《2020年IT行业项目管理调查报告》重磅发布

禅道项目管理

开源 项目管理 项目 调查报告 互联网行业薪资

「SaaS第一股」微盟集团财报业绩大涨,超预期财报揭示多元投资布局

ToB行业头条

SaaS 微盟

小树系统开发案例(源码)丨小树机器人系统开发流程

系统开发咨询1357O98O718

百度大脑开放日重庆站-智能物流专场报名啦

百度大脑

百度大脑 智能物流 智能物流开放日 重庆站

华为云应用服务网格最佳实践之从Spring Cloud 到 Istio

华为云开发者联盟

微服务 Spring Cloud istio 华为云 服务网格

GO训练营第8周——分布式缓存与事务

Glowry

2021 OS 大赛来了,为中国操作系统发展按下加速键

InfoQ写作社区官方

热门活动

企业大数据实战:Kyuubi 与 Spark ThriftServer 的全面对比分析

网易数帆

大数据 spark Kyuubi Thrift HiveServer2

(28DW-S8-Day27) 销售流程重整

mtfelix

28天写作

超简单的网站暗黑模式,它真的超简单!

HelloGitHub

大前端

设计有意义的选择——再谈心流

Justin

心理学 28天写作 游戏设计

Continue 玩转像素点,Python 图像处理学习的第 3 天

梦想橡皮擦

28天写作 3月日更

IPFS云矿机系统开发|IPFS云矿机APP软件开发

系统开发

uniapp实现音视频通讯

anyRTC开发者

uni-app 音视频 WebRTC 跨平台 sdk

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