IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

时间:2021-08-15 12:54:42

上一篇:《IDDD 实现领域驱动设计-SOA、REST 和六边形架构

阅读目录:

  1. CQRS-命令查询职责分离

  2. EDA-事件驱动架构

    1. Domin Event-领域事件

    2. Long-Running Process(Saga)-长时处理过程

    3. Event Sourcing-事件溯源

  3. CQRS Journey-微软示例项目

  4. ENode-netfocus 实践项目

存在即是理由,每一种架构的产生都会有一种特定的场景,或者解决某一种实际应用问题,经验的累积促成了某一种架构的产生。

1. CQRS-命令查询职责分离

IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

说明:本图摘自 MSDN

CQRS(Command & Query Responsibility Segregation)命令查询职责分离,和 REST 同属于架构风格,如果单纯理解 CQRS,是比较容易的,另一种方式解释就是,一个方法要么是执行某种动作的命令,要么是返回数据的查询,命令的体现是对系统状态的修改,而查询则不会,职责的分离更加有利于领域模型的提炼,系统的灵活性和可扩展性也得到进一步加强。

为什么要进行命令和查询职责分离?

如果你有时间,可以先阅读下上面几篇博文及相关评论。

我们都知道 Repository 的职责就是管理聚合根(Aggregate)对象,一般是一一对应关系,领域层中的业务逻辑要对某种聚合根对象进行操作,必须要通过 Repository,而应用层接受用户请求获取数据对象显示,也必须要通过 Repository 进行聚合根对象转换,这个一般没有涉及到领域业务操作,仅仅只是获取聚合根对象数据。领域层中的业务逻辑要求 Repository 实现对聚合根状态的管理,所以我们一般会在领域层 IRepository 接口中定义 Add、Update、GetById 等方法,然后在基础设施层中的 Repository 进行实现,而来自应用层的要求,需要获取聚合根对象数据,所以在 Repository 中还需要添加一些 GetList 等操作,而根据 IRepository 的接口契约,返回的类型必须是聚合根,而在这种场景中,是不需要获取聚合根对象的,只需要获取数据(DTO)就可以了。。。

我大致列一下上面描述中,所出现的一系列问题:

  1. Repository 职责变得飘忽不定。
  2. IRepository 会被污染,导致的结果是领域层也会被污染。
  3. Repository 会出现本不应该出现的 DTO 概念。
  4. Repository 会被大量 GetList 操作所吞没。
  5. Repository 最后会变得“人不像人,鬼不像鬼”。

如果你带着这些问题去理解 CQRS,就会有这样的感慨:“天哪,这简直就是老天派下的一个救星啊!”。

回到一开始的那张图上,看起来感觉很简单的样子,来自用户 UI 的请求分为 Query(查询)和 Command(命令),这些请求操作都会被 Service Interfaces(服务接口,只是一个统称)接收,然后再进行分发处理,对于命令操作会更新 Update Data store,因为读与写分离,为了保持数据的一致性,我们还需要把数据更新应用到 Read Data store。对于一般的应用系统来说,查询会占很大的比重,因为读与写分离了,所以我们可以针对查询进行进一步性能优化,而且还可以保持查询的灵活性和独立性,这种方式在应对大型业务系统来说是非常重要的,从这种层面上来说,CQRS 不用于 DDD 架构好像也是可以的,因为它是一种风格,并不局限于一种架构实现,所以你可以把它有价值的东西进行提炼,应用到合适的一个架构系统中也是可以的。

如果 CQRS 中包含有 Domain(领域)的概念,会是怎样的一种情形呢?

IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

说明:本图摘自 AxonFramework

上面图中包含有很多的概念,但本质是和第一张图是一样的,只不过在其基础上进行了扩展和延伸,先列举一下所涉及的概念:

  • Command Bus(命令总线):图中没有,应该放在 Command Handler 之前,可以看作是 Command 发布者。
  • Command Handler(命令处理器):处理来自 Command Bus 分发的请求,可以看作是 Command 订阅者、处理者。
  • Event Bus(事件总线):一般在 Command Handler 完成之后,可以看作是 Event 发布者。
  • Event Handler(事件处理器):处理来自 Event Bus 分发的请求,可以看作是 Event 订阅者、处理者。
  • Event Store(事件存储):对应概念 Event Sourcing(事件溯源),可以用于事件回放处理,还原指定对象状态。

上面有些是 EDA(事件驱动架构)中的概念,这个在后面会有详细说明,我简单描述一下处理流程,首先抽离两个重要概念:Command(命令)和 Event(事件),Command 是一种命令的语气(本身就是命令的意思,呵呵),它的效果就是对某种对象状态的修改,Command Bus 收集来自 UI 的 Command 命令,并根据具体命令分发给具体的 Command Handler 进行处理,这时候就会产生一些领域操作,并对相应的领域对象进行修改,Command Handler 只是修改操作,并不会涉及到修改之后的操作(比如保存、事件发布等),Command Handler 完成之后并不表示这个 Command 命令就此结束,它需要把接下来的操作交给 Event Bus(完成之后的操作),并分发给相应的 Event Handler 订阅者进行处理,一般是数据保存、事件存储等。

我们来看 IDDD 中的一段代码(P126):

public void commitBacklogItemToSprint(
String aTenantId, String aBacklogItemId, String aSprintId) { TenantId tenantId = new TenantId(aTenantId); BacklogItem backlogItem = backlogItemRepository().backlogItemOfId(
tenantId, new BacklogItemId(aBacklogItemId)); Sprint sprint = sprintRepository().backlogItemOfId(
tenantId, new SprintId(aSprintId)); backlogItem.commitTo(sprint);
}

commitBacklogItemToSprint 就可以看作是一个 Command Handler,注意其命名(commitXXXXToXXXX),一眼看过去就是命令的意思,commitTo 之后的操作是提交给 Event Bus,然后分发给相应 Event Handler 订阅者,来完成状态修改后确定的操作,这样一个领域对象状态的变更才算完成。

关于 Event Handler 保存领域状态操作,其实说简单也简单,说复杂会很复杂,对于它的实现一般会采用异步的方式,也就是说领域状态的保存操作不会延时领域中的业务操作,数据的一致性使用 Unit of Work,具体的领域状态保存用 Repository 实现。

梳理 Command 整个流程,你会发现一个关键词:状态(Status),在上一篇博文讲 REST 概念时,也有一个相似的概念:应用状态(Application State),REST 其中的一个含义就是状态转换,从客气端的发起请求开始,到服务端响应请求结束,应用状态在其过程中会进行不断的转换,请求响应的整个过程也就是应用状态转换的过程,对于 Command 处理流程来说,领域对象的状态和应用状态其实是相类似。我举一个例子,在 REST 架构风格中,应用状态是不会保存到服务端的,客户端发起请求(包含应用状态信息),服务端做出相应处理,此时的状态会转换成资源状态呈现给客户端,这就是表现层状态转换的意思,回到 Command 处理流程上,Command Bus 接收来自 UI 的请求,分发给相应的 Command Handler 进行处理,在处理过程中,就会对领域对象进行修改操作,但它不会保存修改之后的状态信息,而是交给 Event Handler 进行保存状态信息。

和 Command 相比,Query 的处理流程就简单很多了,Query Service 接收来自 UI 的查询请求,这个查询处理可以用各种方式实现,你可以使用 ORM,也可以直接写 SQL 代码,反正是:怎么能提高性能,就怎么来!返回的结果类型一般是 DTO(数据传输对象),根据 UI 进行设计,可以减少不必要的数据传输。

2. EDA-事件驱动架构

Event-Driven Architecture(事件驱动架构),来自解道的定义:

事件代表过去发生的事件,事件既是技术架构概念,也是业务概念,以事件为驱动的编程模型称为事件驱动架构 EDA。

EDA 架构的三个特性:

  1. 异步
  2. 实时
  3. 彻底解耦

EDA 架构的核心是基于消息的发布订阅模式,通过发布订阅模式实现事件的一对多灵活分发。消息消费方对发送方而言完全透明,消息发送方只管把消息发送到消息中间件,其它事情全部不用关心,由于消息中间件中的 MQ 等技术,即使发送消息时候,消息接收方不可用,但仍然可以正常发送,这才叫彻底解耦。其次一对多的发布订阅模式也是一个核心重点,对于消息的订阅方和订阅机制,可以在消息中间件灵活的进行配置和管理,而对于消息发送方和发送逻辑基本没有任何影响。

EDA 要求我们的是通过业务流程,首先要识别出有价值的业务事件,这些事件符合异步、实时和发布订阅等基本的事件特征;其次是对事件进行详细的分析和定义,对事件对应的消息格式进行定义,对事件的发布订阅机制进行定义等,最后才是基于消息事件模式的开发和测试等工作。

在上一篇博文中有讲到 SOA,我们知道分为客户端和服务端,客户端发起请求给服务端,服务端做出相应的响应,也就是说客户端是主动的,服务端是被动的,这种情况就会造成服务的分散,也就是说,我们一般在设计服务的时候,会根据客户端的响应而*的切分业务逻辑,最后导致的情况是各个业务模块所属的服务,被分散在各个业务系统中,这种设计就会导致很多问题的发生。而对于 EDA 架构来说,订阅者向 Event Bus 订阅事件,告诉事件总线我要这个,而 Event Bus 接收订阅后,并不会立即进行处理,而是想什么时候处理就什么时候处理,主动权在 Event Bus 手中,当 Event Bus 想进行处理的时候,一般是接受来自 Command Handler 的请求,然后就分别向指定订阅者发布通知,告诉它们我已经处理了,你们可以接着做下面的事了。

从上面的描述中,我们可以看到 SOA 和 EDA 的明显区别,相对于 SOA 来说,EDA 更加有利于领域的聚合,主动权在领域手中,我们就可以从容面对很多的情形,简单画了一张图:

IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

另外,需要注意的一点,CQRS 可以结合 EDA,也可以不结合,但反过来对于 EDA 来说,则必须结合 CQRS 使用。

2.1 Domin Event-领域事件

领域事件和 Domain Service(领域服务)一样,同属于 DDD 战术模式,这部分内容在 IDDD 第八章有详细介绍,因为我还没学习到那部分,这边就简单说明一下。在 EDA 的定义中说到:事件代表过去发生的事件,换句话说它是代表已完成的事件,准备来说,还应该包含正在完成的事件,既然是属于 DDD 战术模式的一种,那在领域设计中必然有所用武之地。

我用大白话来描述下领域事件在领域中的作用:我们知道行军打仗需要做出抉择,也就是说,需要指挥部商量后下达作战命令,然后把命令交给各个负责的作战中心,有陆军、海军、空军、导弹部队等,它们是命令的实施者,而指挥部是命令的决策者,这个和领域事件是一样的,领域中处理一些业务逻辑后,就会对领域对象的状态做出一些改变,这个就相当于作战命令,然后根据作战命令分配的作战中心进行完成,也就是领域事件的订阅者去完成领域对象状态改变之后的操作,简单而言,领域事件就是领域中的“跑腿者”。

在上面 EDA 的介绍中,有这样的一段代码:backlogItem.commitTo(sprint);,用通用语言表述就是:待定项提交到冲刺,这是领域中完成的一个操作,由 Command Handler 进行委派完成,backlogItem 是一个聚合根对象,commitTo 是聚合根中的一个操作,这个操作完成后,backlogItem 聚合根对象的状态就会被修改了,那在 commitTo 中具体有怎么的操作呢?看下示例代码:

public void commitTo(Sprint aSprint)
{
this.assertArgumentNotNull(aSprint, "Sprint must not be null.");
this.assertArgumentEquals(aSprint.tenantId(), this.tenantId(), "Sprint must be of same tenant.");
this.assertArgumentEquals(aSprint.productId(), this.productId(), "Sprint must be of same product."); if (!this.isScheduledForRelease())
{
throw new IllegalStateException("Must be scheduled for release to commit to sprint.");
} if (this.isCommittedToSprint())
{
if (!aSprint.sprintId().equals(this.sprintId()))
{
this.uncommitFromSprint();
}
} this.elevateStatusWith(BacklogItemStatus.COMMITTED); this.setSprintId(aSprint.sprintId()); DomainEventPublisher
.instance()
.publish(new BacklogItemCommitted(
this.tenantId(),
this.backlogItemId(),
this.sprintId()));
}

注意 commitTo 所处在 BacklogItem 聚合根内,前面都是对聚合根对象的一些状态操作,在后面我们会看到 DomainEventPublisher(领域事件发布者),BacklogItemCommitted 继承自 DomainEvent,BacklogItemCommitted 对应的领域事件,在 BacklogItemApplicationService 中进行订阅,一般是聚合根对象在初始化的时候。

根据上面这个代码示例,然后结合 EDA 的三个特性就可以很好理解了,首先对于领域事件的处理操作一般是异步完成,这样就不会影响聚合根中的其他业务操作,当领域事件发布的时候,会实时的告知订阅者进行处理,因为它不管订阅者的具体处理情况,订阅者和发布者的规范在 DomainEvent 中,而不是像接口定义和实现那么强制,所以,当领域事件发布的时候,就说明订阅者已经被告知并进行了处理,所以他们直接的关系可以彻底的解耦。

在之前的短消息项目中,我没用到领域事件,对它也不是很深入的了解,在后面的博文中,再进行详细说明。

2.2 Long-Running Process(Saga)-长时处理过程

来自 IDDD 中的定义:

事件驱动的、分布式的并行处理模式。

关于 Saga 的相关博文,国内几乎没有(netfocus 有一篇相关说明),长时处理过程,说明它是一个需要耗时、多任务并行的处理过程,那在领域中,什么时候会有它的用武之地呢?比如一个看似简单的业务逻辑,可能会涉及到领域中很复杂的业务操作,而且对于这些处理需要耗费很长的时间。

在电子商城提交一个订单操作,用户看来可能会非常简单,但在领域进行处理的时候,会涉及到订单操作、客户操作、商品操作、库存操作、消息通知操作等等,有些会进行及时的处理,但有些则不会,比如消息通知操作等等,我们可以把这个业务操作分离一下,对于一些耗时比较长的操作精拣一下,商品的减少对应库存的减少,减少之后会进行警戒线判断,如果低于警戒下,则会给库存管理人员发送消息,商品减少了对应的商品统计也要进行更新,客户购买之后也要进行发送消息通知,我们可以把这些用一个 Saga 进行处理,因为是基于事件驱动,所以一个 Saga 中会订阅多个事件,Saga 会对这些事件进行跟踪,对于一些事件处理失败,也要进行做出相应的弥补措施,当所有的操作完成后,Saga 会返回一个状态给领域,也许这个返回操作已经在开始的几天以后了。

IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

说明:本图摘自 MSDN

上图描述的是:一个会议购买座位的业务过程,中间的 Order Process Manager 就是一个 Saga,在 CQRS 架构中的表现就是 Process Manager(过程管理),我们一般会用它处理多个聚合根交互的业务逻辑,比如 netfocus 博文中列举的 TransferProcessCommandHandlers 操作,还有上图中的购买座位业务操作,那我们应该怎么设计 Saga 呢?或者说在设计的时候,应该需要注意哪些地方呢?我大致列一下:

  • 将 Saga 尽量设计成组合任务的形式,你可以把它看作是一个任务的结合体,并对内部每个任务进行跟踪操作。
  • Saga 也可以用一组聚合的形式体现,也就是上面的图中示例。
  • 无状态处理,因为是基于事件驱动,状态信息会包裹在事件中,对于 Sage 整个处理过程来说,可以看作是无状态处理。
  • 可以适用于分布式设计。

2.3 Event Sourcing-事件溯源

字面上的理解:

事件即 Event,溯是一个动词,可以理解为追溯的意思,源代表原始、源头的意思,合起来表示一个事件追踪过程。

我们都知道在源代码管理的时候,可以使用 SVN、Git、CVS 进行对代码修改的跟踪操作,从一个代码文件的创建开始,我们可以查看各个版本对代码的修改情况,甚至可以指定版本进行还原操作,这就是对代码跟踪所带来的好处。而对于领域对象来说,我们也应该知晓其整个生命周期的演变过程,这样有利于查看并还原某一“时刻”的领域对象,在 EDA 架构中,对于领域对象状态的保存是通过领域事件进行完成,所以我们要想记录领域对象的状态记录,就需要把领域对象所经历的所有事件进行保存下来,这就是 Event Store(事件存储),这个东西就相当于 Git 代码服务器,存储所有领域对象所经历的事件,对于某一事件来说,可以看作是对应领域对象的“快照”。

总的来说,ES 事件溯源可以概括为两点:

  1. 记录
  2. 还原

最后,贴一张 CQRS、EDA、Saga、ES 结合图:

IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

说明:本图来自 netfocus

CQRS 参考资料:

EDA 参考资料:


未完成的两点:

  • 3. CQRS Journey-微软示例项目
  • 4. ENode-netfocus 实践项目

本来还想把这两个项目分析一下,至少可以看懂一个业务流程,比如 Conference 项目中的 AssignSeats、ConferencePublish 等,ENode 项目中的 BankTransferSample 示例,但分析起来,真的有些吃力,有时候概念是一方面,实践又是另一方面,后面有时间理解了,再把这两点内容进行补充下。

这篇博文内容有点虚,大家借鉴有用的地方就行,也欢迎拍砖斧正。