DDD里面的CQRS到底是什么?

我的新课 《C2C 电商系统微服务架构120天实战训练营》 在公众号 儒猿技术窝 上线了,感兴趣的同学,可以长按扫描下方二维码了解课程详情:

课程大纲请参见文末

开篇 

随着业务不断发展,软件系统的架构也越来越复杂,但无论多复杂的业务最终在系统中实现的时候,无非是读写操作。用户根据业务规则写入商业数据,再根据查询规则获取想要的结果。通常而言我们会讲这些读写的数据放到一个数据库中保存,通过一套模型对其进行读写操作。而在大型系统中往往查询操作远远多于写入操作,于是就有了读写分离的思想,将读操作和写操作的模型分开定义并且提供不同的通道供用户使用。 CQRS(Command-Query Responsibility Segregation)  就是基于这一思想提供的 一种模式 读写分离的模式,今天就围绕着它给大家讲述以下内容:

  • CQRS 的演变和架构

  • Event   Sourcing   原理与应用

  • Event   Sourcing   CQRS 的完美结合

  • CQRS 的例子

CQRS的演变和架构 

CQRS(Command-Query Responsibility Segregation) 是一种读写分离的模式,从字面意思上理解Command是命令的意思,其代表写入操作;Query是查询的意思,代表的查询操作,这种模式的主要思想是将数据的写入操作和查询操作分开。

它源于 Bertrand Mayer 设计的命令查询分离( CQS )原理。 CQS 声明一个类只能有两种方法:改变状态并返回 void 的方法和返回状态的方法。 Greg Young  是负责命名这种模式为 CQRS  并推广它的人。

首先来看看在没有 CQRS 之前是如何处理系统中的修改和查询的吧, 如图 1 所示:

图1   传统的系统请求

传统的系统请求从最左边的 Client 开始,沿着红线往右通过 Application   Service对系统进行请求。这里 Application   Service   可以理解为系统的门面,或者是 Controller 层负责接收客户端的请求,此时请求的内容比较简单基本和数据库中的信息一致,因此这里使用DTO( Data  Transfer Object)直接请求。 DTO 经过 Domain   Model   以后直接到达Database,从而沿着蓝色的线条返回给 Client 端。传统的请求方式部分读操作和写操作,都使用同样的数据模型和一套 Domain   Model以及相同的数据库。

从传统操作来看 Client 的请求在经过 Application Service ,用户意图全部被分解为 CRUD 操作,但是在 Domain   Model 中是无法体现的。为保证 DTO 的完整性和一致性,与操作无关的信息会被纳入 DTO ,查询操作和创建操作都共用一个 DTO ,而领域模型的业务流程被弱化。为了适应同时适应查询和创建操作, DTO 被设计的面面俱到,也就显得臃肿。从而在传输中存在不必要的字段传递。

而且一次操作,在 DTO 与领域对象间进行多次转换,增加了系统复杂度。还有,读写操作将围绕同一数据模型展开,对于读多写少的系统而言效率并不是最高的,特别在读操作为主的高并发系统中缺点就尤为突出。

正因为传统系统架构存在上面这些问题,因此 CQRS 根据读写职责的不同,把领域模型切分为 Command 端与 Query 端两个部分,如图 2 所示,红色线部分就是 Command 端,其对应的是 Domain   Model   对其发送 Command   操作的指令往数据写入状态信息。

Query 端作为查询操作,由蓝色的线表示,通过 Query   Model 向数据库获取信息,通过黑色向左的先返回结果给 Client Command 端与 Query 端都通过 Application   Service   进入系统,共享同一个数据库,但 Command 端只写入状态, Query 端只读取状态。

图2   CQRS   分为Command   端和 Query端

目前而言已经将读写操作分开了,由于两个操作依旧共用一个数据库,为了提高读写效率数据库的分离就成为必然的选择。如图 3 所示,于是将原来的 Database ,分离为 Writer   Database   Reader   Database 分别用于写操作和读操作。为了保证读写操作的数据一致性,需要在两个数据库之间进行数据同步。

由于数据同步是由时效性的,因此写入方是 Command 端,读取方是 Query 端,因此系统智能保证最终一致性。那么如何保证两个库之间的同步呢?下面需要引入 Event   Sourcing 的概念。

Event Sourcing 原理与应用 

Event Sourcing 也叫事件溯源,是 Martin Fowler 提出的一种架构模式。 其设计思想是系统中的业务都由事件驱动来完成。系统中记录的是一个个事件,由这些事件体现信息的状态。业务数据可以是事件产生的视图,不一定要保存到数据库中。

为了便于理解 Event   Sourcing   我们通过一个例子来进一步解释,如图 3   所示:

图3   Command   端和 Query端 读写数据库的分离

我们从左往右看。对于一个业务类“账户”,拥有“属性”包括“账户 ID ”和“账户金额”信息,同时拥有“方法”包括“创建账户”、“存现金”和“取现金”。中间绿色的事件序列,是针对“账户”进行的一些列操作,按照其中的序列号来看。

1.  创建了一个银行账户,假设此时的账户 ID 为“ 0 001”

2.  针对0 001 ”这个账户存入 3 00 元现金。

3.  然后从0 001 ”这个账户取出 1 00 元现金。

4.  最后,再存入 2 00 元。

上面 生成的这一系列事件会保存到下方的 Event   Store 的事件库中,这里并不会保存 账户 的状态 信息。当需要获取 “账户” 数据 的时候,会通过这些事件信息,还原成 “账户”的最终状态,也就是“账户 ID ”为“ 0 001 ”,“账户金额”为 4 00 。其具体实现方式是,通过账户相关的四个事件对应的处理方法,重新生成当前状态。如果每次查询状态信息都需要这样处理势必会造成资源的浪费, 因此在右侧黄色的部分,我们将最终的 “账户”信息通过视图的方式保存下来,以供查询

3   Event   Sourcing   实例图

上面这个 “账户”处理的 过程,就是 Event Sourcing 说白了就是通过事件的处理模式。它将系统中的操作都按照事件的方式记录并保存,任何实体的最终状态都是通过事件的叠加和还原确认的。

Event   Sourcing   包含的内容

上面介绍了 Event   Sourcing   的执行原理和基本概念,这里一起来看看其包含的主要内容,便于我们对它有更加全面的理解。

聚合对象:图3的例子中 账户 就是一个聚合对象,它里面包含 “账户 ID ”、“账户金额”等 的基本信息,也包含了对账户操作 的方法:“创建账户”、“存现金”、“取现金”。同时 账户 在领域驱动开发中对应的是一个领域模型。

  • Event Store: Event Sourcing 模式中,事件 所保存的数据库称为 Event Store 。在事件中需要包含聚合对象的 ID ,以及事件的顺序。这样在查询的时候可以根据聚合 ID 从数据库中找到相关的事件,并通过事件的序号还原执行顺序。也就是事件的重现,也就是某一时刻执行的事件取出来,调用他的处理函数,还原那个时间点的业务状态。

  • 为了获取最新的“账户”状态信息,需要通过 Event   Sourcing   中获取对应的事件进行回放,从而获取当前的状态,这样的操作会浪费很多资源。因此我们会将聚合对象的最新数据状态,写到一个表中,这个表就是视图。又或者将这个状态信息发送给其他的应用程序进行后续的业务操作。

  • 查询的内容是针对“账户”最终状态的,因此针对的对象应该是视图。这里的设定刚好的 CQRS 中的读写分离不谋而合,通过 Event   Store存放 Command   端的Event   信息,通过视图存放实体最终状态的信息,而Query   端从视图查询数据返回给用户。

Event Sourcing 的优缺点

上面介绍了 Event   Sourcing 的原理和内容以后再来看看它的优缺点。

Event   Sourcing   的优点:

  • 溯源事件与重现操作:特别是在业务复杂的系统 中,一个事务包含多个操作,它们有的是并行有的串行,如果需要了解操作的执行就需要对每个事件了如指掌。Event   Sourcing   恰恰提供了事件的历史信息,方便查找任何时间点发生的事情。

  • 追踪和 修复 Bug :可以通过事件分析业务的执行过程, 帮助发现Bug,例如重方 Bug 产生时的事件序列,从而定位 Bug 所处位置。 发现 B ug 并且修复以后,可以通过重新聚合业务数据, 重放执行的事件序列验证修复结果,同时将Bug造成的损失进行挽回。

  • 提高性能: Event Sourcing 模式下, 由于是记录事件执行的序列,因此都是新增操作,没有更新操作,相对于需要更新操作的系统而言记录数据的性能是提高了。如果使用视图的方式将实体的最终状态可以传递给其他的应用,而不用写入数据库以后再读取,这种做法也提高了效率。

Event   Sourcing   的缺点:

  • 转变思路:Event   Sourcing的落地需要在设计时就用 领域驱动的方式开展,需要有基于事件的响应式编程思维。这种方式需要以领域模型设计优先,而不是传统的数据库设计优先。

  • 变更事件结构:随着业务流程的变化需要不断调整事件结构,对事件添加或者修改一些数据。这种行为会影响到 历史重现 ,需要考虑兼容之前的事件结构。

  • 处理幂等事件:如果对应的事务在执行过程中被中断,需要通过事件回放的方式达到事务的最终一致性问题。此时需要对事件的幂等性提出要求,也就是同一个事件运行多次得到的结果不变。需要在事件处理时丢弃重复事件。

  • 查询事件数据库( event store :由于数据库中存放的一个个事件,如果针对实体状态的查询会相对困难。需要将这些事件重放,获取最新的实体状态的信息。这也是为什么需要通过CQRS的方式将读写进行分离, Command 端使用 Event   Sourcing   而Query端使用 Event   Sourcing   发出Event   的最终状态进行查询的原因。

CQRS与Event Sourcing的 完美结合

通过上面对Event   Sourcing   的介绍,可以发现它针对Event   进行记录存放到Event   Store中,并且把最终的状态放到视图中进行保存可以供给 Query 端进行查询。这种模式天生与 CQRS 就有默契的配合。

从CQRS模式的结构看,实体状态的变化发生在 Command 端, Command 端知道业务处理进行了哪些具体操作,将这些具体的操作进行封装就形成了 Event

Query 端,查询返回的是实体当前状态状态。根据“当前状态 + 变化 = 新的状态”,如果能从 Command 端得到“变化”,再加上 Query 端自身获取的“当前状态”就能得到变化后的“新的状态”。

此时 Command   端发出的 Event 正好符合这个“变化”,如果当变化发生也就是新 Event 产生时,由 Command 端将这个 Event 推送到 Query 端, Query 端根据 Event 刷新状态,就能保证两端实体状态一致,达到最终一致性, 如图4所示:

Event   Sourcing   和 CQRS   结合

在图3的基础上加入 Event   Handler   也就是图中蓝色部分,这部分接收从Domain   Model中发过来的 Event 信息,也就是最新的实体修改信息。再将这个信息存放到 Reader   Database(也可以理解为视图)中,这样新的 E v ent   信息加上当前的实体信息就时最新的实体信息了。而采用这种方式以后Query   端依旧可以通过Reader   Database获取数据对其原来的操作并没有产生影响。

再回到Command端,其对应的多次操作的 Event   会存放到Event   Store中,作为业务跟踪的记录被保存下来。

上面提到的只是一种系统架构的模式,在实际运用中可以根据具体情况进行改进和优化。如图 5 所示,可以在 Command   端和 Query   端进行 Event   交换的时候加入队列,满足两套应用程序部署在不同进程的场景需求。

图5   Command   端和Query   端加入队列

一个CQRS的例子 

上面聊到了CQRS与 Event   Sourcing的完美结合,这里通过一个例子给大家进一步介绍其运作的过程。这个例子的背景是,对于用户( User   而言保存了对应的联系方式(Contact)和住址( Address )。

Command   用来建立(Create)用户( User   和更新(Update)用户( User ); Query   用来查询用户(User)对应的住址( Address )和联系方式( Contact )。

如图 4 所示, Client   请求应用分为上线两条线,分别用四种颜色代表。我们根据不同颜色来讲解 Command   端和 Query   端执行的过程。

Event   Sourcing   和 CQRS   结合

红色向左的线:这里主要是针对User   的create   和update   操作,分别填充CreateUserCommand类和 UpdateUserCommand 类,作为 UserAggregate 聚合类的输入参数。在 UserAggregate 中分别由, handleCreateUserCommandhandleUpdateUserCommand 两个方法处理,最后通过 UserWriteRepository 来保存到 Write   database中。

  • 绿色向下的线:其连接了紫色的区域是UserProjection,它的作用是将 Write   database的数据同步到 Read   database中。

  • 蓝色向右的线:Client   发起Query请求通过 AddressByRegionQuery 类和 ContactByTypeQuery 类构建请求,将其传送到 UserProjection 类进行处理,其中 handle 方法分别对两类参数的请求进行处理。最后通过 UserReadRepository 获取 Read   database中的信息。

  • 紫色向左的线:当从Read  database 中获取信息以后,返回给Client。

6   CQRS   例子图解

在了解了整体架构以后再来看看具体实现的类结构。

如图 7   所示, User 实体类包括如下几个字段,也就是我们要操作的业务实体。包括用户的基本信息,其中 contact   address   类的具体信息在这里不展开描述。

图7 User 实体类

Command   的类信息如图 8 所示,其内容相对简单。针对 CreateUserCommand 主要用于创建用户,包括 UserID FirstName 以及 LastName

图8 CreateUserCommand 类

如图9所示, UpdateUserCommand 中加入了地址和联系方式的更新内容。

图9 UpdateUserCommand 类

有了 Command   再来看看聚合类 UserAggregate ,由于其中包括 Create Update 的处理方法,这里介绍其中的 handleCreateUserCommand 方法,也就是处理新建用户命令。

这里会创建一个 UserCreatedEvent 对象,并将其通过 WriteRepository 保存到 Write   database 中。也就是在 ES 中的 Event   store ,同时会将 event   list 返回。

图10 handleCreateUserCommand 类

在处理完 Command   以后会返回 Event ,这个 Event 在保存到数据库中的同时,也会发送和 Query 端作为最新的实体状态进行更新,这里会用到 UserProjector 类完成映射。 如图 1 1 ,所示,其中的project方法会针对 UserIDevents 进行逐一处理。

图11 UserProjector 类

看完了 Command   端和 同步的 Projector ,再来看看 Query 端的类。如图 1 2 所示, AddressByRegionQuery 类定义了 UserID State 信息。

图12 AddressByRegionQuery 类

如图 1 3 所示, ContactByTypeQuery 定义了 UserID ContactType 的信息。

图13 ContactByTypeQuery 类

如图 1 4 所示,上面提到的 AddressByRegionQuery ContactByTypeQuery 作为参数传入到 UserProjection 类的 handle 方法中,并且返回对应的 Contact Address 信息。使用了 UserReadRepositiory Read   database 中获取数据。

图14 UserProjection

最后,再来看看测试代码这里将其分为 7 个步骤,如图 1 5 所示。

随机 生成用户 I D。

  1. 通过CreateUserCommand, 创建新建用户的 Command 并且通过UserAggregate生 成对应的事件。

  2. 通过UserProjector将 事件映射到 Query 端的数据库中

  3. 通过UpdateUserCommand, 创建更新地址信息的 Command ,生成对应的事件

  4. 通过UserProjector 将事件映射到 Query 端的数据库中

  5. 通过AddressByRegionQuery, 创建查询地址信息的 Query

  6. 执行查询从 Read database 中获取数据与假设值进行比较

图15 Command 和Query的执行过程

最后来看看这些文件的目录结构,如图 1 6 所示。

1 6 文件结构

总结

本文从CQRS的演变切入,介绍了如何从“读写一体”过渡到“读写分离”的 CQRS 的架构方式,以及 CQRS 方式的几种表现形式。通过读数据库与写数据库之间同步的问题,引出 Event   Sourcing   的原理和应用,包括Event   Sourcing的内容和优缺点。从而得出 CQRSEvent   Sourcing   结合完成读写分离的结论。最后,通过一个CQRS的例子带大家从代码的角度走了一遍 CQRS 的流程。

征 稿

有酬投稿

愿意技术分享的朋友,欢迎投稿,每篇文章提供 800 ~ 1000 元的稿酬,投稿请扫描下方二维码, 添加微信 :jeversoncui