为什么要用 CQRS

先不管 CQRS 这玩意儿是什么东西.

首先我们先要问下自己, 我们在实现 查询数据 这个操作的时候, 通常会遇到什么问题或者阻碍, 或者有没有觉得有些 code smell, 然后采用什么思路来解决这些问题. 包括但不限于采用 DDD 设计思想指导开发.

thinking

什么是 CQRS

Command-Query Responsibility Segregation (命令-查询 职责隔离). 这个名字起得不够望文生义, 它包含了自己的上下文.

首先是 CommandQuery 的区别.

Object Oriented Software Construction(面向对象的软件构建) 这本书里提出了一个词: Command–query separation (命令-查询 分离). 指出了命令和查询两个概念:

Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么是执行一个操作的 “命令”,要么是一个向调用方返回数据的 “查询”,不会同时做两种事情.)

这个是概念放到今天来看, 可能有点过于绝对. 不过我们可以取其精华.

于是, 后来有个老哥 在遵循 CQS 的原则, 提出了 CQRS.

CQRS 继承了 CQS 概念上的好处后, 将 CQS 的概念实践到了模型层面. 在面向对象的世界里, 将命令和查询分成了不同的对象.

回到 为什么要用 CQRS

总的来说, CQRS 的思想为 OOPDDD 带来了一些好的实现思路:

  • 将领域模型和查询模型分开. 不会产生先有鸡还是先有蛋的问题.
  • 不会将查询和业务逻辑耦合在一起, 减少了逻辑混乱.
  • 领域模型不需要关心查询的逻辑. 查询的结果也可以按照需求自己组合或过滤需要的数据.

正统的 CQRS 落地稍显复杂

参考CQRS 的文档. 里面其实是有提到 Event Sourcing (事件溯源) 这么一个概念.

简单来说就是

  • 命令对领域模型的所有操作, 都会以事件的形式被记录下来. 每一次事件发生后都可以被溯源.
  • 同时命令和查询的操作的数据源是分开的. 命令执行的变更记录需要用某种同步机制同步到查询部分的记录中.

说人话:

  • 每次命令操作记流水账.
  • 每次命令操作完的变动要同步到查询端的系统中.

记流水账无可厚非. 用来溯源和数据校验都很好. 就是用空间和复杂性来换取稳定性.

同步操作这个就显得更为繁重. 首先查询端就要有专门的逻辑来保存命令端的变动记录, 同时还要将记录落地. 如果是不同的物理数据源的话, 还要保证数据一致性和可用性. 如果系统不是复杂到一定程度, 投入人力做这种事情收益实在是太低了.

CQRS 变种实现思路参考

既然 CQRS 的实现这么复杂, 那为什么还要搞它?

回到前面提到的 为什么要用 CQRS.

  • 将领域模型和查询模型分开. 不会产生先有鸡还是先有蛋的问题.
  • 不会将查询和业务逻辑耦合在一起, 减少了逻辑混乱.
  • 领域模型不需要关心查询的逻辑. 查询的结果也可以按照需求自己组合或过滤需要的数据.

其实我们想要的是上面这几个 CQRS 思想带来的优势. 上面提到的这几点, 已经足以诠释 命令-查询 责任分离 这个组合词汇的意思了.

我们需要的正是 将命令和查询的逻辑分开. 防止领域模型的腐化, 避免因为查询而修改了领域对象的属性和业务逻辑.

既然如此, 我们从繁入简, 直接抹掉复杂的事件溯源. 从而享受 CQRS 思想带来的优势.

以下很多内容从 Thoughtworks 的 后端开发实践系列——简单可用的CQRS编码实践 这遍文章受到启发, 强烈建议花时间看下这篇文章. 文章从实践落地的角度为诸位拨冗去繁, 带来不少操作性非常高地实现方案思路.

  • 在设计领域模型的时候, 通常会把 db 的表定义为模型里的实体.
  • 查询操作所需的结果通常是某一种实体或者某几种实体组合和某些值对象而来的模型.

于是可以采取一种 共享存储/分离模型 的方案. 在获得 CQRS 的优势的同时, 尽量降低实现成本.

共享存储/分离模型

共享数据存储,代码中分别建立写模型和读模型,读模型通过最适合于查询的方式进行建模.

如此一来, 便不再需要担心领域模型里耦合了纯查询的逻辑, 防止领域腐化.

同时, 查询操作可以按照具体的业务需要, 通过组合不同的实体和值对象, 形成一个查询模型.

共享存储/分离模型

单实体

共享存储/分离模型-单实体

多实体

共享存储/分离模型-多实体


单进程/跨进程?

在 Thoughtworks 的文章中有提到 单进程和跨进程 这么一个区别.

  • 单进程: 所读数据来源于同一个进程空间,这里的进程空间指某个单体应用或者单个微服务;
  • 跨进程: 所读数据来源于不同进程空间

个人感觉, 其实并不太需要区分实体或者值对象是否存在同一个进程中. 因为在领域模型里面, 并不关心实体或者值对象是从 db 获得还是从其它服务中获得.

区别仅仅是在于两者在实现上不同, 从领域设计的角度来看, 实体或者值对象的来源不重要. 重要的是领域对象所拥有的属性和自身相关的业务逻辑.

结语

本文尝试从个人对 CQRS 原则的理解, 同时站在巨人的肩膀上, 尝试对 CQRS 的落地实现提出自己一点微不足道的思路. 望能抛砖引玉, 引起读者对采用 CQRS 的实现方案上的思考.

因地制宜, 从实际角度出发提出方案, 方能体现出自己独立的思考过程. 一味地吸收, 而不假思索的采纳, 终将沦为一台复读机.

参考

附录

为什么要用 CQRS - 思考

  • 前端查询的结果, 很多时候并不适合直接返回整个领域模型或者 db 里面的存储的对象. 为了处理这种情况, 目前通常都会新增一个 vo, dto 来组合或者排除掉不同对象的属性
  • 如果采用了 DDD 思想来指导开发, 会直接遇上 先有鸡还是先有蛋 的问题. 例如, 要根据订单号查询一个 订单. 按照 DDD 的思想来看的话, 是先构建一个订单, 再调用订单的方法来查询订单的具体信息. 但这样做的话就会令人迷惑.