本文只讲了一件事情:软件模型中存在读模型和写模型之分,CQRS便为此而生。
20多年前,Bertrand Meyer在他的《Object-Oriented Software Construction》一书中提出了CQS(Command Query Seperation,命令查询分离)的概念,指出:
Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“命令”执行一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)
这里的“命令”可以理解为更新软件状态的写操作,Martin Fowler将此称为“Modifier”;而“查询”即为读操作,是无副作用的。这种分离的好处在于使程序变得更容易推理与维护,由于查询操作不会更新软件状态,在编码时我们将更加有信心。试想,如果程序中出了一个bug,如果这个bug出现在查询过程中,那么我们至少可以消除这个bug可能给软件带来脏数据的恐惧。
后来,Greg Young在此基础上提出了CQRS(Command Query Resposibility Segregation,命令查询职责分离),将CQS的概念从方法层面提升到了模型层面,即“命令”和“查询”分别使用不同的对象模型来表示。
采用CQRS的驱动力除了从CQS那里继承来的好处之外,还旨在解决软件中日益复杂的查询问题,比如有时我们希望从不同的维度查询数据,或者需要将各种数据进行组合后返回给调用方。此时,将查询逻辑与业务逻辑糅合在一起会使软件迅速腐化,诸如逻辑混乱、可读性变差以及可扩展性降低等等一些列问题。
一个例子
设想电商系统中的订单(Order)对象,一开始其对应的OrderRepository类可以简单到只包含2个方法:
public interface OrderRepository {
void save(Order order);
Order byId(String id);
}
在项目的演进中,你可能需要依次实现以下需求:
- 查询某个Order详情,详情中不用包含Order的某些字段;
- 查询Order列表,列表中所展示的数据比Order详情更少;
- 根据时间、类别和金额等多种筛选条件查询Order列表;
- 展示Order中的产品(Product)概要信息,而Product属于另一个业务实体;
- 展示Order下单人的昵称,下单人信息属于另一个单独的账户系统,用户修改昵称之后,Order下单人昵称也需要相应更新;
- …
当这些需求实现完后,你可能会发现OrderRepository和领域模型已经被各种“查询”功能淹没了。什么?OrderRepository不是给领域模型提供Order聚合根对象的吗,为什么却充斥着如此多的查询逻辑?
CQRS通过单独的读模型解决上述问题,其大致的架构图如下:
对于Command侧,主要的讲究是将业务用例建模成对应的Command对象,然后在对Command的处理流程中应用核心的业务逻辑,其中最重要的是领域模型的建模,关于此的内容请参考笔者的《领域驱动设计(DDD)编码实践》文章,本文着重介绍Query侧的编码实践。
在本文中,查询模型(Query Model)也被表达为读模型(Read Model);命令模型(Command Model)也被表达为写模型(Write Model)。
CQRS实现模式概览
常见误解
在网上搜索一番,你会发现很多关于CQRS的文章都将CQRS与Event Sourcing(事件溯源)结合起来使用,这容易让人觉得采用CQRS就一定需要同时使用Event Sourcing,事实上这是一种误解。CQRS究其本意只是要求“读写模型的分离”,并未要求使用Event Sourcing;再者,Event Sourcing会极大地增加软件的复杂度,而本文追求的是“简单可用的CQRS”,因此本文将不会涉及Event Sourcing相关内容。更多内容,请参考简化版CQRS的文章。
另外需要指出的是,读写模型的分离并不一定意味着数据存储的分离,不过在实际应用中,数据存储分离是一种常见的CQRS实践模式,在这种模式中,写模型的数据会同步到读模型数据存储中,同步过程通常通过消息机制完成,在DDD场景下,消息通常承载的是领域事件(Domain Event)。
查询模型的数据来源
无论是单体还是微服务,所读数据的唯一正确来源(Single Source of Truth)最终都来自于业务实体(Entity)对象(比如DDD中的聚合根),基于此,所读数据的来源形式大致分为以下几种:
- 所读数据来源于同一个进程空间的单个实体(后文简称“单进程单实体”),这里的进程空间指某个单体应用或者单个微服务;
- 所读数据来源于同一个进程空间中的多个实体(后文简称“单进程跨实体”);
- 所读数据来源于不同进程空间中的多个实体(后文简称“跨进程跨实体”)。