31 CQRS(上):实现查询功能有什么诀窍?
你好,我是钟敬。
前面几节课我们讲完了限界上下文。接下来的两节课,我们将学习一个和查询功能相关的模式——CQRS。虽然 《领域驱动设计》原书里没有这个模式,但近年来,CQRS 常常和 DDD 结合使用。
不过也有人对 CQRS有不同意见。这是因为,CQRS 实际上也有不同的变化,这就造成了不同的人对 CQRS 的理解也不太一样。学完这两节课,我想你就知道怎么分辨了。
CQRS 是 Command Query Responsibility Segregation 的简称,中文是 “命令查询职责分离”。这个名字乍听起来也不太好理解,咱们还是从业务需求开始,一步一步地理解。
查询功能遇到的问题
在第三个迭代中,在工时管理上下文,增加了更多的查询和统计需求。
我们先看一个简单的需求,给定一个工时项,要求查询出这个工时项下的所有工时记录,并显示在屏幕上。要求每条返回记录的字段包括“员工号”“员工姓名”“日期”“工时”和“备注”,并根据员工号和日期升序排序。为了简化问题,我们先不考虑“不在本级报工时”以及“父子工时项”的问题。
我们回忆一下工时项管理的领域模型。
根据之前学习的数据库设计以及上下文映射的方法,假定我们选择的是在本地建立员工表,并从“基础信息管理”上下文映射 员工 信息的策略。那么本地数据库会有 员工(emp)表和 工时记录(effort_record)表。
在没有学习DDD之前,其实这个需求很简单,只要一条SQL语句就可以搞定了。大概是后面这样。
select em.num, em.name, er.work_date, er.effort, er.notes
from effort_record as er
left join emp as em
on er.emp_id = em.id
and er.tenant_id = em.tenant_id
where er.tenant_id = ?
and er.effort_item_id = ?
order by em.num, er.work_date;
这个查询是把 effort_record(工时记录表)和 emp(员工)表做了一个表连接,然后根据 effort_item_id (工时项ID)查出相应的数据。
但是,现在学了 DDD,再要实现这个功能,你可能会觉得反而更麻烦了。因为这时候要经过领域模型得到这个查询结果。我把设计层面的类图画出来,来辅助我们的思考。
我们从 EffortRecordService(也就是工时记录服务)开始看。这个服务中的方法会接收一个EffortItemID(工时项ID),最后返回一个含有所需数据的列表。每一条数据记录封装在名为 EmpEffortRecordDto 的DTO里。
具体包括三步。
第一步,工时记录服务会调用 EffortRecordRepository,根据工时项ID取得一堆 EffortRecord (工时记录)实体。
第二步,再根据每个工时记录实体中的员工ID,调用 EmpRepository ,为每个工时记录取得相应的员工实体。这里要注意避免对同一个员工实体重复查询数据库。
第三步,把所有工时记录和对应的员工实体信息拼成相应的 EmpEffortRecordDto ,排序后组装成列表返回。
顺便说一下,我在这里没有贴代码,而是给出了设计图。这个图和代码其实是一一对应的。希望你能够多练一下看着图写出代码、看着代码反推出图的技巧。
上面的做法确实符合我们之前说的“代码和模型一致”这个要求,但是你可能已经发现这么做会带来的问题了。
- 程序编写比较麻烦,原来一句 SQL 就可以解决的问题,现在要分几步实现。
- 这样的程序可能带来性能问题。
那么该怎么解决呢?这里的核心问题是,查询功能是否必须经过领域模型呢?
要回答这个问题,我们首先要考虑一下为什么之前的逻辑要经过领域模型。其中一个主要原因是,如果绕过领域模型,领域逻辑和数据就可能分散在程序各个地方,无法保证数据的完整和一致性,程序也将很难理解和维护。对于增、删、改这样的逻辑,这样的原因确实说得通。但是,查询的逻辑并不会改变数据,所以并不会造成数据的不完整和不一致。
事实上,前人已经意识到了查询和其他功能的不同之处,主张采用不同的方式来处理查询逻辑,并提出了所谓 CQRS 架构。
最早提出这个说法的是 Greg Young。他把增、删、改功能称为 Command(命令),把查询称为 Query,这两种功能的职责不同,应该采用不同的方式来处理,因此叫做“命令查询职责分离”(Command Query Responsibility Segregation ),简称 CQRS。我们可以先粗放一点来理解,一共是两条规则。
第一,命令要走领域模型。
第二,查询不走领域模型,直接用 SQL 和 DTO。
后来,业界对 CQRS 又形成了一些不太一样的理解。这是因为,CQRS 其实可从几个不同的层面来考虑,我们分别来看看。
代码结构分离
第一个层面是代码结构的分离。为了说明这个问题,咱们先看图。
在这个图里,代码首先分成了两个包,一个是 command processor(命令处理器),另一个是 query processor(查询处理器)。其中,命令处理器采用的就是之前基于领域模型的分层架构。
关键看 查询处理器,你会发现里面根本就没有领域层了,当然也就没有领域对象了。这里直接用 EmpEffortRecordDto 来表示数据。应用服务调用仓库,仓库里用 SQL 语句进行连表查询,得到的数据直接填到 DTO 里。应用服务可能还要对DTO再做少许加工,就可以直接返回了。
由于 EmpEffortRecordDto 表示的是要查询的数据的结构,所以,也有人把由这些对象组成的模型称为“查询模型”(query model)或者“读模型”(read model)。在命名上,如果简洁一些,也可以不用 Dto 这个后缀。
最后,在图里我们把数据库也看做组件,用组件图来表示,它对外暴露的接口就是 SQL。
这种代码结构的分离,是最简单的 CQRS,多数采用了 DDD 分层架构的程序都可以尝试使用。
除了这种最简单的用法,我们再来看看其他几种策略,它们在带来收益的同时,也会有比较明显的代价,这时我们就要多权衡一下了。
数据库结构分离
前面程序里的 SQL 用了连表查询,有时候会造成性能问题。
本质原因是,程序里的查询模型和数据库里的表架构不一致。这是因为,数据库里的表结构,或者说物理数据模型,是根据领域模型,而不是查询模型设计的。从物理数据模型到程序中的查询模型的转换,是通过 SQL完成的。而这种转换需要表连接,就可能造成性能问题。
那怎么解决这种性能问题呢?
一种解决思路是为查询单独创建一套表,其中采用“反规范化设计”,也就是引入冗余字段,使表结构和查询模型吻合,从而避免或减少表连接。
我画出了这种设计的架构图,供你参考。
由于包内部的结构是类似的,所以这张图忽略了内部细节,只包含外层的包结构。
数据库里的表分成了两套——命令模型(command model)和查询模型(query model),分别由命令处理器和查询处理器访问。其中命令模型中的表是根据领域模型设计的,查询模型部分的表就是根据查询需求进行了反规范化设计。
命令处理器对命令模型里的表进行操作后,要把数据同步给查询模型。有多种同步方案可供选择,比如说,命令模型中的仓库,同时写两边的数据表,或者使用触发器,还可以用同步或异步的事件驱动机制。
反规范化设计
为了实现数据库结构分离,需要进行反规范化设计。为了帮你深入了解这一点,我给你
举例说明一下反规范化的表结构设计。
命令模型里的 工时记录 表是后面这样。
这个表我们在迭代一见过类似的,现在唯一的不同是原来的 project_id (项目ID)换成了现在的 effort_item_id(工时项ID)。这是因为最初的需求只是为项目报工时,而现在要为更广义的工时项报工时了。
查询模型里的 员工工时记录 表是后面这样。
其实表设计的变化很简单,我们只是在表里增加了 emp_num(员工号)和 emp_name(员工姓名)两个字段。同时在原来的表名前面增加了 emp ,表示是员工表和工时记录表合并的表。
为什么说这样就“反规范化”了呢?
这是因为在这个表里,emp_num 和 emp_name 是可以由emp_id 唯一确定的。这种情况在数据库设计术语上叫做 “emp_num 和 emp_name 函数依赖于 emp_id”,简称 “emp_num 和 emp_name 依赖于 emp_id”。注意,这里说的数据库设计意义上的依赖,和 UML 里的依赖不是一个概念。
另一方面,emp_id 又是依赖于这个表的 id 的。这时就可以说,emp_num 和 emp_name 传递依赖于 id 。凡是一个表里有传递依赖关系的,这个表就违反了第三范式(3NF)。
至于第二范式是怎么回事,你有兴趣的话,找一本数据库设计的书看看就可以了。我这里只说一句,如果一个表里只有一个字段(例如 id)做主键,那么在原理上就是不可能违反第二范式的。
在命令模型里,一般是不主张违反范式的,这是因为违反范式必然造成数据冗余,数据冗余就会造成潜在的数据不一致风险。而在查询模型中,不仅允许,甚至是鼓励反规范化设计。这是因为,对于查询模型,反规范化的风险是可控的。就算数据发生了不一致,总可以从命令模型里按正确的逻辑重新生成一遍。
如果你不满足于仅仅在物理数据模型上表示出查询模型,还想在领域模型层面把命令模型和查询模型的关系表示得更清楚,可以把图画成这样。
在这个图里, 工时记录 和 员工 是原来领域模型里的实体,没有变化。而 员工工时记录 则是查询模型中的“查询实体”(我们姑且这么命名吧)。我们用自定义的<
员工工时记录 中的数据是来源于 工时记录 和 员工 的,所以对这两个实体有依赖关系。另外,我们在依赖关系上加了<
顺便说一句,我们原来说过领域模型和设计模型的区别,如果你愿意的话,领域模型里的一个实体和设计模型里的对应实体,也可以用 <
我把这种图称为“查询模型图”,一般会单独画出来,只保留和查询模型有关的实体和关联。如果都画在领域模型图里,就太混乱了。当查询模型里的表比较多的时候,如果靠拍脑袋设计,就可能设计出混乱重复的表结构,最终导致设计失控。有了查询模型图,我们就可以用一种受控的方式进行反规范化的模型设计了。
数据库结构分离策略的好处是提高了性能。但这么做也有明显代价,包括后面三个方面。
1.增加了两种数据模型同步的复杂性和出错的可能性。
2.数据同步可能带来性能损失。
3.会占用额外的存储空间。
总结
好,这节课先讲到这,下面来总结一下。
今天我们学习了 CQRS (Command Query Responsibility Segregation),也就是“命令和查询职责分离”模式。
尽管通过 DDD 的领域模型完成增、删、改等功能是很适合的,但是通过领域模型来实现查询功能,常常是比较繁琐的,而且性能也不高。因此, CQRS 就成了 DDD 的有力补充。
根据 CQRS ,命令(也就是增、删、改功能)和查询功能的实现逻辑应该是不一样的。为了处理这种不一样的逻辑,我们今天讲了两种策略:代码结构分离和数据库结构分离。
为了实现数据库结构分离,我们也讨论了怎样进行反规范化设计,以及这种设计是怎样违反第三范式的,从而让你了解背后的所以然。
另外,我们还介绍了“查询模型图”,有了它,我们就可以在更高的层面,对查询模型进行系统化的设计。
思考题
最后给你留两道思考题。
1.今天课程中的需求其实做了简化,没有考虑“父子工时项”以及“不在本级报工时”的情况,如果考虑了这两种情况,甚至本级和下级子工时项都能报工时,那么 SQL 语句就不太好写了。这时候,你觉得可以用什么办法处理呢?
2.我们今天谈反规范化的时候,是另外建立了一套表。还有一种策略,是直接在原来为领域模型建的表上进行反规范化。你觉得这两种策略各自的利弊是什么?
好,今天的课程结束了,有什么问题欢迎你在评论区留言。下节课,我再带你了解实现 CQRS 的另外几种策略,敬请期待。