Java EE 领域驱动设计实践指南(四)
六、货物跟踪器:Axon 框架
我们现在已经实施了三种货物跟踪系统:
使用 Jakarta EE 基于单片架构的 DDD 实施
使用 Eclipse MicroProfile 基于微服务架构的 DDD 实现
基于使用 Spring Boot 的微服务架构的 DDD 实施
我们最终的 DDD 实施将基于事件驱动的微服务架构模式,使用以下内容:
一个纯粹的 ES (Event Sourcing)框架
【CQRS】(命令/查询责任分离) 方法
*我们将用 Axon 框架来实现这一点。Axon 是企业 Java 领域中为数不多的提供开箱即用、稳定、完整且功能丰富的解决方案来实现基于 CQRS/ES 的架构的框架之一。
使用像 Axon 这样的纯 CQRS/ES 框架需要我们在构建应用的思维过程中进行根本性的改变。应用状态的每个方面,无论是状态构造、状态更改还是状态查询,都围绕着事件,这与传统的应用有着根本的不同。代表应用各种有界上下文状态的主要实体是它的聚合,因此我们的讨论将主要围绕聚合状态。
在我们开始实现之前,让我们多谈一点事件源/CQRS 模式和构建应用的方法。我们还将检查这些模式与我们以前实现的不同之处。
活动采购
事件源模式在存储应用状态、检索应用状态以及在应用的各种有界上下文中发布应用状态更改时采用了不同的方法。
在我们进入事件源的细节之前,让我们看一下传统的状态维护方法。
传统应用使用 " 域源或状态源 " 来存储/检索聚集状态。域来源的概念是,我们使用传统的数据存储机制(例如,关系数据库、NoSQL 数据库)来构造、修改或查询聚集状态。只有当聚合状态被持久化后,我们才会将事件发布到消息代理上。我们之前的实现都是基于“ 域采购 ”。
如图 6-1 所示。
图 6-1
在传统数据存储中存储应用状态的源自域的应用
源自域的应用在使用上相当简单,因为它们使用传统的存储和检索状态的机制。每当在集合上有操作时,在各种有界上下文中的集合的状态是按原样存储的**,例如,当我们 预订新货物 时,创建新货物,并且新货物的细节存储在数据库中的相应货物表中(在我们的情况下是预订有界上下文中的数据库模式)。我们提出一个 New Cargo Booked 事件 ,它被推送到一个传统的消息代理上,该消息代理可以被任何其他有界上下文订阅。我们使用一个 专用的消息代理 来发布这些事件。**
**另一方面, 事件源 专门处理聚集上发生的事件。聚合的每个状态变化都被捕获为一个事件,并且只有事件被持久化,而不是整个聚合实例,负载作为聚合实例。
- 再次强调,我们只存储事件,而不是整体的集合。
让我们通过一个例子来解释这一点,如图 6-2 所示。
图 6-2
使用事件采购的货物预订用例
如前所述,在“Book New Cargo”操作结束时,我们只保存“Cargo Booked Event ”,而不保存 Cargo Aggregate 实例。事件保存在一个 专门构建的事件存储库 中。事件存储除了充当事件的持久存储之外,还需要将 兼作事件路由器 **,**也就是说,它应该使持久事件对感兴趣的订户可用。
类似地,当需要更新聚集的状态时,我们使用一种非常不同的方法。这些步骤概述如下:
-
我们需要从事件存储中加载特定聚合实例上发生的事件集。
-
我们在聚合实例上重放它以获得当前状态。
-
我们基于操作更新(而不是持久化)聚合状态。
-
我们只保存更新的事件。
让我们再看一个例子来解释这一点,如图 6-3 所示。
图 6-3
使用事件采购的货物路线用例
如图所示,当我们想要安排货物的路线时,我们首先基于特定货物的标识符(预订 Id)检索该货物已经发生的事件集,在该特定货物聚合实例上重放到目前为止已经发生的事件,用货物聚合应该采取的路线更新货物聚合,最后仅发布货物路由事件。同样,这与传统应用处理状态修改的方式完全不同。
图 6-4 描述了在货物集合的两个操作结束时事件存储的记录。
图 6-4
对货物集合进行两次操作后的事件存储数据
Event Sourcing 模式提倡一种激进的方法,使用一种纯粹基于事件的方法来管理有界上下文中的聚合状态。
那么,我们如何产生这些事件呢?如果我们只持久化事件,我们如何获得聚集的状态?这就是***【CQRS(命令/查询责任分离)*** 原则发挥作用的地方。
事件源主要与 CQRS 结合使用, 命令端 用于生成聚合事件, 查询端 用于查询聚合状态。
CQRS(消歧义)
命令/查询责任分离原则本质上是一种应用开发模式,它鼓励更新状态的操作和查询状态的操作之间的分离。
本质上,CQRS 提倡使用
-
命令 更新各种应用对象的状态(聚合在一个有界的上下文内)
-
查询 查询各个应用对象的状态(聚合在一个有界的上下文内)
在前面的章节中,我们已经利用 Jakarta EE、Spring Boot 和 Eclipse MicroProfile 实现了 CQRS 模式的变体。
图 6-5 描述了我们之前的 CQRS 实现。
图 6-5
我们以前的 CQRS 实现
实现基于共享方法,即命令和查询具有共享模型(例如,货物集合本身处理命令和服务查询)。我们利用了域来源,也就是说,状态在传统数据库中被持久化和检索。
当我们需要将 CQRS 与事件源一起使用时,事情就有点不同了。在这种方法中,命令和查询将具有
-
独立型号
-
分离代码流
-
独立界面
-
独立的逻辑流程
-
独立持久存储
这在图 6-6 中进行了描述。
图 6-6
CQRS 与 ES 隔离模式
如图所示,在有限的上下文中,命令/查询有自己的一组接口、模型、流程和存储。命令端 处理命令 修改聚合状态。这个 导致事件 被查询方持久化和订阅以更新一个 读取模型 。读取模型是应用状态的投影,针对特定的受众,具有特定的信息需求。这些事件可以被其他有界上下文订阅。
图 6-7 将 CQRS 和 ES 带到了一起。
图 6-7
活动采购的 CQRS
总而言之,使用事件源和 CQRS 构建的应用
-
拥有 事件 为一等公民
-
使用一个 命令模型 ,它更新聚集的状态并生成事件
-
将事件而不是直接应用状态存储在专门构建的事件存储中。
**事件存储还兼作 事件路由器 ,使感兴趣的订阅者可以使用保存的事件
- 通过 提供聚合状态的 读取模型/投影 查询模型 ,通过订阅状态变化事件进行更新。
利用这种模式的应用是为构建事件驱动的微服务应用而定制的。
在我们开始实现之前,先介绍一下 Axon 框架。
轴突框架
该框架于 2010 年首次发布,是一个纯开源的 CQRS/ES 框架。
该框架在过去几年中有了显著的发展,除了核心框架之外,还提供了一个服务器选项,其中包括一个事件存储和一个事件路由器。Axon 的核心框架与服务器相结合,抽象了实现 CQRS/ES 模式所需的复杂基础设施问题,并帮助企业开发人员只关注业务逻辑。
实现基于事件源的架构是极其复杂和难以实现的。利用流媒体平台(例如 Kafka)实现事件商店似乎是一种日益增长的趋势。这样做的缺点是,在实现这些流媒体平台不提供的事件源特性时需要大量的定制工作(它们本来是流媒体平台,而不是事件源平台!).Axon 在这方面大放异彩,它的特性集帮助应用轻松采用 CQRS/ES 模式。
锦上添花的是,它采用 DDD 作为构建应用的基本构件。随着企业最近推动采用微服务架构风格,Axon 通过结合 DDD 和 CQRS/ES 模式的方法,为客户构建事件驱动的微服务提供了一个强大的功能完整的解决方案。
轴突成分
从高层次来看,Axon 提供了以下组件:
-
Axon 框架,领域模型——一个帮助你建立以 DDD、事件源和 CQRS 模式为中心的领域模型的核心框架
-
Axon 框架,调度模型——支持前面提到的领域模型的逻辑基础设施,即处理领域模型状态的命令和查询的路由和协调
-
Axon Server**–**支持前面提到的域/调度模型的物理基础设施。
如图 6-8 所示。
图 6-8
轴突框架组件
如上所述,我们总是可以选择外部基础设施来代替 Axon server,但这意味着要实现 Axon server 现成可用的一组功能。
接下来,我们将对 Axon 框架组件进行一次快速浏览。作为 Cargo Tracker 领域模型实现的一部分,我们将再次深入研究它们,所以现在只需通读该部分,对这些组件有一个大致的了解。
Axon 框架域模型组件
总计
任何有界上下文的领域模型的核心,Axon 为定义和开发 DDD 集合提供了一流的支持。在 Axon 中,聚合被实现为包含状态和改变该状态的方法的常规 POJOs。POJOs 用原型注释(@Aggregate)进行标记,将它们指定为聚合。
此外,Axon 还支持聚合内的聚合识别/命令处理(状态改变)以及从事件存储中加载这些聚合(事件源)。这种支持是利用特定的原型注释提供的(@AggregateIdentifier、@CommandHandler、@EventSourcingHandler)。
命令/命令处理程序
命令的意图是在各种有界的上下文中改变集合的状态。
Axon 为通过命令处理程序处理命令提供了一流的支持。命令处理程序是放置在集合中的例程;并且它们将特定的命令,即状态改变意图作为主要输入。虽然实际的命令类是作为常规的 POJOs 实现的,但是命令处理程序支持是通过原型注释(@CommandHandler)提供的,这些注释放置在聚合上。Axon 还支持在需要时将这些命令放在外部类(外部命令处理程序)中。命令处理程序还负责引发域事件,并将这些事件委托给 Axon 的事件存储/路由器基础设施。
事件/事件处理程序
在聚合上处理命令总是会导致事件的生成。事件向感兴趣的订阅者通知有界上下文中聚合的状态变化。事件类本身被实现为常规的 POJOs,不需要特定的注释。Aggregates 使用 Axon 提供的生命周期方法,将事件推送到事件存储,并在处理完命令后推送到事件路由器。
事件的消费是通过订阅他们感兴趣的事件的“事件处理器”来处理的。Axon 提供了一个原型注释“@EventHandler ”,它被放在常规 POJOs 中的例程上,支持事件的消费和后续处理。
查询处理程序
查询的目的是在我们的有界上下文中检索聚集的状态。Axon 中的查询由查询处理程序通过放置在常规 POJOs 上的@QueryHandler注释来处理。查询处理程序依靠读取模型/投影数据存储来检索聚集状态。他们使用传统的框架(例如 Spring Data、JPA)来执行查询请求。
萨迦
Axon 框架为基于编排的传奇 和基于编排的传奇 提供了一流的支持。简单回顾一下,基于编排的传奇依赖于由参与传奇的各种有界上下文引发和订阅的事件。另一方面,基于编排的传奇依赖于一个中心组件,该组件负责参与传奇的各种有界上下文之间的事件协调。基于编排的传奇是通过 Axon 框架提供的常规事件处理程序实现的。Axon 框架提供了一个全面的实现来支持基于编排的传奇。
这包括以下内容:
-
生命周期管理(通过相应的注释、传奇管理器开始/结束传奇)
-
事件处理(通过@SagaEventHandler 注释)
-
支持多种实现的 Saga 状态存储(JDBC、Mongo 等)。)
-
跨多个服务的关联管理
-
截止日期处理
这就完成了 Axon 框架中可用的领域模型组件。让我们谈一谈 Axon 中可用的调度模型组件。
轴突分派模型组件
在构建基于 Axon 的应用时,理解 Axon 的调度模型非常重要。概括地说,任何有界上下文都参与四种类型的操作:
-
处理命令以改变状态
-
处理查询以检索状态
-
发布事件/消费事件
-
传奇故事
Axon 的调度模型提供了必要的基础设施,使有界上下文能够参与这些操作,例如,当命令被发送到有界上下文时,正是调度模型确保命令被正确地路由到该有界上下文内相应的命令处理程序。
让我们更详细地了解一下调度模型。
命令总线
发送到有界上下文的命令需要由命令处理程序来处理。命令总线/命令网关有助于将命令分派给相应的命令处理程序进行处理。
扩张
-
CommandBus–Axon 基础架构组件,将命令路由到相应的CommandHandler。 -
CommandGateway–Axon 基础设施实用程序组件,是CommandBus的包装器。利用CommandBus要求我们为每个命令调度创建可重复的代码(例如,CommandMessage创建,CommandCallback例程)。使用 CommandGateway 有助于消除大量样板代码。我们将在本书的后续章节中回过头来讨论实现。
Axon 提供了CommandBus :的多种实现
-
简单命令
-
AxonServerCommandBus
-
异步命令总线
-
干扰命令总线
-
分布式命令总线
-
记录命令总线
对于我们的实现,我们将使用AxonServerCommandBus**,这是一个利用 Axon 服务器作为各种命令的调度机制的实现。
实施总结如图 6-9 所示。
图 6-9
Axon 服务器命令总线实现
查询总线
与命令类似,发送到有界上下文的查询需要由查询处理程序来处理。查询总线/查询网关有助于将查询分派给相应的查询处理程序进行处理:
-
QueryBus–Axon 基础架构组件,将查询路由到相应的QueryHandler。 -
QueryGateway–Axon 基础设施实用程序组件,是QueryBus的包装器。利用QueryGateway消除了样板代码。
Axon 提供了Query Bus :的多种实现
-
简单查询总线
-
AxonServerQueryBus
出于我们的实现目的,我们将使用AxonServerQueryBus, an implementation,它利用 Axon 服务器作为各种查询的调度机制。
实施总结如图 6-10 所示。
图 6-10
Axon 服务器查询总线实现
事件总线
事件总线是一种机制,它从命令处理程序接收事件,并将它们分派给相应的事件处理程序,这些事件处理程序可以是对该事件感兴趣的任何其他有界上下文。Axon 提供了事件总线的三种实现:
-
AxonServerEventStore
-
嵌入式事件存储
-
简单事件总线
对于我们的实现,我们将利用 AxonServerEventStore。图 6-11 描绘了 Axon 内部的事件总线机制。
图 6-11
Axon 服务器事件总线实现
萨迦
如前所述,Axon 框架提供了对基于**编排 的支持。在某种意义上,基于编排的传奇的实现是简单的,参与特定传奇的有界上下文将直接引发和订阅事件,类似于常规事件处理。
***另一方面,在基于编排的传奇中,生命周期协调通过一个中心组件进行。这个核心组件负责传奇的创建、跨参与传奇的各种有界上下文的流程协调,以及最终传奇本身的终止。Axon 框架为此提供了一个组件 SagaManager 。类似地,基于编排的 Saga 需要状态存储来存储和检索 Saga 实例。Axon 框架支持各种存储实现(JPA、内存、JDBC 和 Mongo、Axon Server)。对于我们的实现,我们将使用 Axon Server 本身提供的 Saga 存储。Axon 在这里也应用了合理的默认设置,当我们创建一个 Saga 时,它会自动配置一个 Saga 管理器和 Axon 服务器作为状态存储机制。
图 6-12 描述了我们实现中基于编排的 Saga 机制。
图 6-12
基于 Axon 编排的 saga 方法
Axon 基础架构组件:Axon 服务器
Axon Server 提供了支持调度模型所需的物理基础设施。概括地说,Axon Server 有两个主要组件:
-
基于 H2 的事件存储(用于存储配置)和文件系统(用于存储事件数据)
-
事件流经系统的消息路由器
以下是其功能的简要总结:
-
内置消息路由器,支持高级消息模式(粘性命令路由、消息节流、QoS)
-
具有内置数据库的专用事件存储(H2)
-
高可用性/可伸缩性能力(集群)
-
安全控制
-
UI 控制台
-
监控/度量功能
-
数据管理功能(备份、调整、版本控制)
Axon Server 使用 Spring Boot 构建,并作为常规 JAR 文件分发(当前版本是 axonserver-4.1.2)。它利用自己的基于文件系统的存储引擎作为事件存储数据库,可从 www.axoniq.io 下载。
启动服务器就像将它作为传统的 JAR 文件运行一样简单。清单 6-1 演示了这一点:
java -jar axonserver-4.1.2.jar
Listing 6-1Command to bring up the Axon server
这就调出了 Axon Server,可以在http://localhost:8024访问它的控制台。图 6-13 描述了作为 Axon Server 提供的 UI 控制台的一部分的仪表板。
图 6-13
Axon 服务器控制台
控制台提供监控和管理 Axon 服务器的功能。让我们快速浏览一下这些内容。随着我们在实现过程中的进展,我们将开始看到更多的细节。
设置–这是服务器仪表盘的登陆页面。它包含配置的所有细节、各种操作的状态以及许可证/安全细节。这里有一个简短的说明:Axon 支持 HTTP 和 gRPC 作为入站协议。
概述–该页面提供了 Axon Server 的 可视化图形 以及与之连接的应用实例。到目前为止,由于我们还没有构建任何应用,所以它只描绘了主服务器,如图 6-14 所示。
图 6-14
Axon 服务器视觉显示
搜索–该页面提供了底层事件存储的可视化表示。Axon 还提供了一种查询语言来帮助查询事件存储。图 6-15 对此进行了描述。
图 6-15
Axon 查询控制台以及查询 DSL
搜索结果如图 6-16 所示。
图 6-16
Axon 查询控制台搜索结果
用户–该页面提供添加/删除用户及其相应角色(管理员/用户)以访问 Axon 服务器的功能。这如图 6-17 所示。
图 6-17
Axon 查询用户管理
总结
Axon 为希望利用 CQRS/事件源、事件驱动的微服务和 DDD 作为基础架构模式的应用提供了一个纯粹的实施方案。
Axon 提供了一个 域模型/调度模型 (Axon 框架)和一个支持的事件存储/消息路由器基础设施(Axon 服务器)来帮助构建基于 CQRS/ES 的应用。
Axon 将事件和事件源作为利用 CQRS/ES 构建事件驱动的微服务应用的基础模块。
Axon 提供了一个管理控制台来查询、保护和管理事件数据。
在介绍了 Axon 的功能之后,让我们进入 Cargo Tracker 的实现细节。
带 Axon 的货物跟踪器
我们的货物跟踪器应用的实施将基于事件驱动的微服务架构,利用 Axon 的核心框架(Axon 框架)和 Axon 的基础设施(Axon 服务器)。
Axon 框架为 Spring Boot 的提供了一流的支持,作为构建和运行各种 Axon 构件的底层技术。虽然没有必要使用 Spring Boot,但在 Axon 提供的支持下,使用 Spring Boot 的自动配置功能配置组件变得极其容易。
*### 有轴突的有界背景
对于我们的微服务实现,我们采用的方法是将货物跟踪器应用分成四个有界上下文,每个有界上下文包含一组微服务。我们也采用同样的方法在 Axon 实现中分割应用。
图 6-18 描绘了我们的四个有界上下文。
图 6-18
货物跟踪应用中的有界上下文
虽然拆分的方法与我们之前的微服务实施相同,但我们将实施许多与我们之前所做的非常不同的方面。我们的每个有界上下文都将被分成命令端和查询端。有界上下文的命令端处理对有界上下文集合的任何状态改变请求。命令端还生成聚合状态变化并将其作为事件发布。每个有界上下文的查询端利用单独的持久存储提供集合的当前状态的读取模型/投影。该读取模型/投影由查询方通过订阅状态改变事件来更新。
总体总结如图 6-19 所示。
图 6-19
有界上下文–命令端和查询端
有界上下文:工件创建
每个有界上下文都有自己的可部署工件。这些受限的上下文中的每一个都包含一组可以独立开发、部署和扩展的微服务。每个工件都被构建成一个 Axon + Spring Boot fat JAR 文件,它拥有独立运行所需的所有依赖项和运行时。
工件总结如图 6-20 所示。
图 6-20
有界上下文——映射到它们的微服务工件
要开始使用 Axon,第一步是用以下依赖项创建一个常规的 Spring Boot 应用:spring-web 和 spring-data-jpa。
图 6-21 描绘了利用来自 Spring Boot 的 Initializr 项目( start.spring.io )创建预订微服务。
图 6-21
预订微服务 Spring Boot 项目及其附属项目
我们用以下内容创建了项目:
-
group–com . practical DD . cargo tracker
-
人工制品-预订
-
依赖性——Spring Web Starter、Spring Data JPA
Axon 利用 Spring Boot 的自动配置功能来配置其组件。为了实现这种集成,我们只需将“axon-spring-Boot-starter”的依赖项添加到启动项目的 pom 文件中。一旦这个依赖可用, axon-spring-boot-starter 将 自动配置调度模型 (命令总线、查询总线、事件总线)和 事件存储 。
清单 6-2 中说明了这种依赖性:
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.1.1</version>
</dependency>
Listing 6-2Dependencies for Axon spring boot starter
Axon 使用合理的默认值将 Axon 服务器配置为调度模型基础设施和事件存储。这不需要明确包含在我们的任何 Spring Boot 配置或源文件中。只需添加清单 6-2 中提到的依赖项,即可自动将 Axon Server 配置为调度模型基础架构的实现,此外它还是事件存储。
图 6-22 总结了 Axon Spring Boot 应用的解剖结构。
图 6-22
剖析 Spring Boot 应用
有界上下文:包结构
实现有界上下文的第一步是将各种 Axon 工件逻辑分组到一个可部署的工件中。逻辑分组包括识别一个包结构,我们将各种 Axon 工件放置在这个包结构中,以实现我们对有界上下文的整体解决方案。
图 6-23 描述了我们任何有界上下文(命令端、查询端)的高级包结构。正如所见,我们之前的实现没有任何变化,因为 CQRS/ES 模式非常适合我们在第二章(图 2-9 )中布局的六边形架构。
图 6-23
有界上下文–包结构
让我们以我们的预订有界上下文(预订命令端有界上下文、预订查询端有界上下文)为例,稍微扩展一下包结构。
接口
这个包将所有入站接口封装到由通信协议分类的有界上下文中。接口的主要目的是代表域模型协商协议(例如,REST API、WebSocket、FTP、自定义协议)。
作为一个例子,Booking Command Bounded Context 提供了用于向其发送命令的 REST APIs(例如,Book Cargo Command、Update Cargo Command)。类似地,订舱查询有界上下文提供了向其发送查询的 REST APIs(例如,检索货物订舱细节,列出所有货物)。这被分组为“”包。它还有事件处理程序,订阅 Axon 生成的各种事件。所有事件处理程序都被分组到“ 【事件处理程序】 包中。除了这两个包,接口包还包含了""包。这用于将传入的 API 资源/事件数据转换为相应的命令/查询模型。****
****接口包结构如图 6-24 所示。无论是命令项目还是查询项目,都是一样的。
图 6-24
接口封装结构
应用
快速概括一下,应用服务充当有界上下文的域模型的外观。除了充当门面之外,在 CQRS/ES 模式中,应用服务还负责委托给 Axon 的调度模型(命令网关、查询网关)来调用调度模型。
总而言之,应用服务
-
参与命令调度、查询调度和 Saga
-
为基础领域模型提供集中的关注点(例如,日志、安全性、度量)
-
对其他有界上下文进行标注
应用包结构如图 6-25 所示。
图 6-25
应用包结构
领域
这个包包含有界上下文的域模型。
领域模型包括以下内容:
-
聚合
-
聚合预测(读取模型)
-
命令
-
问题
-
事件
-
查询处理程序
域包结构如图 6-26 所示。
图 6-26
领域模型包结构
基础设施
基础设施包有两个主要目的:
-
有界上下文的域模型与任何外部存储库通信所需的基础设施组件,例如,查询端有界上下文与底层读取模型存储库(如 MySQL 数据库或 MongoDB 文档存储库)通信。
-
任何 Axon 特定的配置,例如,对于快速测试,我们可能希望使用嵌入式事件存储,而不是 Axon 服务器的事件存储。该配置将放在基础设施包类中。
基础设施包结构如图 6-27 所示。
图 6-27
基础设施包结构
以预订有界上下文为例,图 6-28 描述了预订有界上下文的包结构布局。
图 6-28
预订有界上下文包结构
构建预订绑定上下文应用会产生一个 Spring Boot JAR 文件(bookingms-1.0.jar)。为了启动预订有界上下文应用,我们首先启动 Axon 服务器。然后,我们将预订有界上下文作为常规的 Spring Boot JAR 文件运行。清单 6-3 对此进行了说明:
java -jar bookingqueryms-1.0.jar
Listing 6-3Command to bring up the Booking Bounded Context as a spring boot application
内的 axon-spring-boot 依赖项会自动寻找正在运行的 axon 服务器,并自动连接到它。图 6-29 描绘了 Axon 仪表盘,显示了连接到正在运行的 Axon 服务器的预订微服务。
图 6-29
预订连接到 Axon 服务器并准备好处理命令/查询的微服务
这完成了基于微服务和基于利用 Axon 框架的 CQRS/ES 模式的我们的货物跟踪器应用的有界上下文的实现。我们的每个有界上下文都被实现为一个 Axon Spring Boot 应用。有界的上下文被模块整齐地分组在一个包结构中,具有清晰分离的关注点。
本章接下来的两个部分将处理货物跟踪应用***-域模型*** 和***-域模型服务*** 的基于 Axon 框架的 DDD 工件的实现。DDD 工件的总体布局如图 6-30 所示。
图 6-30
基于 Axon 框架的 DDD 工件
用 Axon 实现领域模型
领域模型是我们代表核心业务功能的每个有界上下文的核心。考虑到 Axon 非常严格地遵循 DDD/CQRS/ES 原则,我们用 Axon 实现的领域模型将与我们之前的实现完全不同。
我们将为我们的领域模型实现以下工件集:
-
聚合/命令
-
聚合预测(读取模型)/查询
-
事件
-
萨迦
总计
聚合是有界环境中我们的领域模型的核心。在我们的实现中,因为我们采用了 CQRS 模式,所以每个子域有两个有界上下文,一个用于命令端,一个用于查询端。我们将主要让 聚合 用于命令端有界上下文,同时我们将维护 聚合投影 用于查询端有界上下文。
图 6-31 描述了每个命令端有界上下文的集合。
图 6-31
每个命令端有界上下文的集合
聚合类的实现包括以下几个方面:
-
聚合类实现
-
状态
-
命令处理
-
事件发布
-
状态维护
Axon 为使用 原型注释 构建聚合类提供了一流的支持。实现聚合的第一步是获取一个常规的 POJO,并用 Axon 提供的 @Aggregate 注释对其进行标记。该注释向框架表明它是有界上下文中的聚合类。
和以前一样,我们将浏览 Cargo Aggregate 的实现,它是 Booking 命令端有界上下文示例的聚合。
清单 6-4 显示了实现货物集合的第一步:
package com.practicalddd.cargotracker.bookingms.domain.model;
import org.axonframework.spring.stereotype.Aggregate;
@Aggregate //Axon provided annotation for marking Cargo as an Aggregate
public class Cargo {
}
Listing 6-4Cargo Aggregate using Axon annotations
下一步是为聚合提供惟一性,即标识聚合实例的键。拥有一个聚合标识符是强制性的,因为当需要处理一个特定的命令时,框架利用它来识别哪个聚合实例需要作为目标。Axon 提供了一个原型注释(@ Aggregate Identifier)来标识聚合的特定字段作为聚合标识符。
继续我们的货物集合示例,预订标识符(或 BookingId)是我们的集合标识符,如清单 6-5 所示:
package com.practicalddd.cargotracker.bookingms.domain.model;
import org.axonframework.spring.stereotype.Aggregate;
import org.axonframework.modelling.command.AggregateIdentifier;
@Aggregate //Axon provided annotation for marking Cargo as an Aggregate
public class Cargo {
@AggregateIdentifier //Axon provided annotation for marking the Booking ID as the Aggregate Identifier
private String bookingId;
}
Listing 6-5Aggregate Identifier implementation using Axon annotations
实现的最后一步是提供一个无参数的构造函数。这是框架主要在更新聚合的操作期间所需要的。Axon 将使用无参数构造函数创建一个空的聚合实例,然后播放该聚合实例上发生的所有过去的事件,以达到当前和最新的状态。我们将在后面的状态维护一节中详细讨论这个主题。现在,让我们把它放在聚合实现中。
清单 6-6 展示了无参数构造函数在聚合实现中的添加:
package com.practicalddd.cargotracker.bookingms.domain.model;
import org.axonframework.spring.stereotype.Aggregate;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.invoke.MethodHandles;
@Aggregate //Axon provided annotation for marking Cargo as an Aggregate
public class Cargo {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@AggregateIdentifier //Axon provided annotation for marking the Booking ID as the Aggregate Identifier
private String bookingId;
protected Cargo() { //Empty no-args constructor
logger.info("Empty Cargo created.");
}
Listing 6-6Cargo Aggregate constructor
聚合类的实现如图 6-32 所示。接下来就是给 加上状态 。
图 6-32
聚合类实现
状态
在第三章中,我们讨论了 Jakarta EE 实现中的富域聚合和贫域聚合。DDD 推荐使用领域丰富的集合,这些集合使用 业务概念 来传达有界上下文的状态。
让我们在 Booking 命令有界上下文中浏览一下我们的 Cargo 根聚合类的情况。DDD 的本质是用业务术语而不是技术术语将聚集的状态捕捉为属性。
将状态转换为业务概念,货物集合具有以下属性:
-
货物的始发地
-
预订金额
-
路线说明 (始发地、目的地、目的地到达截止日期)
-
根据路线规格将货物分配到的 路线 。路线由多段 路程 组成,货物可能会通过这些路程到达目的地。
图 6-33 描述了货物集合及其相应关联的 UML 类图。
图 6-33
货物集合的类图
让我们将这些属性包括在货物总量中。这些属性被实现为与聚合有很强关联关系的常规 POJOs。
清单 6-7 显示了主清单中的 货物聚集对象 :
package com.practicalddd.cargotracker.bookingms.domain.model;
import java.lang.invoke.MethodHandles;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.spring.stereotype.Aggregate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Aggregate
public class Cargo {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@AggregateIdentifier
private String bookingId; // Aggregate Identifier
private BookingAmount bookingAmount; //Booking Amount of the Cargo
private Location origin; //Origin Location of the Cargo
private RouteSpecification routeSpecification; //Route Specification of the Cargo
private Itinerary itinerary; //Itinerary Assigned to the Cargo
protected Cargo() { logger.info("Empty Cargo created);}
}
Listing 6-7Cargo Aggregate implementation
清单 6-8 显示了 预订金额业务对象 :
package com.practicalddd.cargotracker.bookingms.domain.model;
/**
* Booking Amount Implementation of the Cargo
*/
public class BookingAmount {
private int bookingAmount;
public BookingAmount() {}
public BookingAmount(int bookingAmount) {
this.bookingAmount = bookingAmount;
}
}
Listing 6-8Booking Amount Business Object
清单 6-9 显示了 位置的业务对象 :
package com.practicalddd.cargotracker.bookingms.domain.model;
/**
* Location class represented by a unique 5-digit UN Location code.
*/
public class Location {
private String unLocCode; //UN location code
public Location(String unLocCode){this.unLocCode = unLocCode;}
public void setUnLocCode(String unLocCode){this.unLocCode = unLocCode;}
public String getUnLocCode(){return this.unLocCode;}
}
Listing 6-9Location Business Object
清单 6-10 显示了 路线规范业务对象 :
package com.practicalddd.cargotracker.bookingms.domain.model;
import java.util.Date;
/**
* Route specification of the Cargo - Origin/Destination and the Arrival Deadline
*/
public class RouteSpecification {
private Location origin;
private Location destination;
private Date arrivalDeadline;
public RouteSpecification(Location origin, Location destination, Date arrivalDeadline) {
this.setOrigin(origin);
this.setDestination(destination);
this.setArrivalDeadline((Date) arrivalDeadline.clone());
}
public Location getOrigin() { return origin; }
public void setOrigin(Location origin) { this.origin = origin; }
public Location getDestination() { return destination; }
public void setDestination(Location destination) { this.destination = destination; }
public Date getArrivalDeadline() { return arrivalDeadline; }
public void setArrivalDeadline(Date arrivalDeadline) { this.arrivalDeadline = arrivalDeadline; }
}
Listing 6-10Route Specification Business Object
清单 6-11 显示了 行程单业务对象的货物 :
package com.practicalddd.cargotracker.bookingms.domain.model;
import java.util.Collections;
import java.util.List;
/**
* Itinerary assigned to the Cargo. Consists of a set of Legs that the Cargo will go through as part of its journey
*/
public class Itinerary {
private List<Leg> legs = Collections.emptyList();
public Itinerary() {}
public Itinerary(List<Leg> legs) {
this.legs = legs;
}
public List<Leg> getLegs() {
return Collections.unmodifiableList(legs);
}
}
Listing 6-11Itinerary Business Object
清单 6-12 显示货物:的航段业务对象
package com.practicalddd.cargotracker.bookingms.domain.model;
/**
* Leg of the Itinerary that the Cargo is currently on
*/
public class Leg {
private String voyageNumber;
private String fromUnLocode;
private String toUnLocode;
private String loadTime;
private String unloadTime;
public Leg(
String voyageNumber,
String fromUnLocode,
String toUnLocode,
String loadTime,
String unloadTime) {
this.voyageNumber = voyageNumber;
this.fromUnLocode = fromUnLocode;
this.toUnLocode = toUnLocode;
this.loadTime = loadTime;
this.unloadTime = unloadTime;
}
public String getVoyageNumber() {
return voyageNumber;
}
public String getFromUnLocode() {
return fromUnLocode;
}
public String getToUnLocode() {
return toUnLocode;
}
public String getLoadTime() { return loadTime; }
public String getUnloadTime() {
return unloadTime;
}
public void setVoyageNumber(String voyageNumber) { this.voyageNumber = voyageNumber; }
public void setFromUnLocode(String fromUnLocode) { this.fromUnLocode = fromUnLocode; }
public void setToUnLocode(String toUnLocode) { this.toUnLocode = toUnLocode; }
public void setLoadTime(String loadTime) { this.loadTime = loadTime; }
public void setUnloadTime(String unloadTime) { this.unloadTime = unloadTime; }
@Override
public String toString() {
return "Leg{" + "voyageNumber=" + voyageNumber + ", from=" + fromUnLocode + ", to=" + toUnLocode + ", loadTime=" + loadTime + ", unloadTime=" + unloadTime + '}';
}
}
Listing 6-12Leg Business Object
聚合状态实现如图 6-34 所示。接下来是 处理命令 。
图 6-34
聚合状态实现
命令处理
命令指示有界上下文改变它的状态,特别是在有界上下文内的集合(或任何其他识别的实体)。实现命令处理包括以下内容:
-
命令的识别/执行
-
识别/实现处理命令的命令处理程序
命令的识别
命令的识别围绕着影响集合状态的任何操作。例如,预订命令有界上下文具有以下操作或命令:
-
预订货物
-
运送货物
-
改变货物的目的地
所有三个命令都导致有界环境内货物集合体的状态改变。
命令的实现
使用常规的 POJOs 来实现 Axon 中确定的命令。对 Axon 命令对象的唯一要求是,在处理命令时,Axon 框架需要知道这个特定命令需要在集合的哪个实例上处理。这是通过利用轴突注释@TargetAggregateIdentifier来完成的。顾名思义,在处理命令时,Axon 框架知道需要处理命令的目标聚合实例。
我们来看一个例子。清单 6-13 显示了 BookCargoCommand 类,它是 Book Cargo 命令的实现:
package com.practicalddd.cargotracker.bookingms.domain.commands;
import org.axonframework.modelling.command.TargetAggregateIdentifier;
import java.util.Date;
/**
* Implementation Class for the Book Cargo Command
*/
public class BookCargoCommand {
@TargetAggregateIdentifier //Identifier to indicate on which Aggregate does the Command needs to be processed on
private String bookingId; //Booking Id which is the unique key of the Aggregate
private int bookingAmount;
private String originLocation;
private String destLocation;
private Date destArrivalDeadline;
public BookCargoCommand(String bookingId, int bookingAmount,
String originLocation, String destLocation, Date destArrivalDeadline){
this.bookingId = bookingId;
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingId(String bookingId){this.bookingId = bookingId; }
public void setBookingAmount(int bookingAmount){this.bookingAmount = bookingAmount;}
public String getBookingId(){return this.bookingId;}
public int getBookingAmount(){return this.bookingAmount;}
public String getOriginLocation() {return originLocation; }
public void setOriginLocation(String originLocation) {this.originLocation = originLocation; }
public String getDestLocation() { return destLocation; }
public void setDestLocation(String destLocation) { this.destLocation = destLocation; }
public Date getDestArrivalDeadline() { return destArrivalDeadline; }
public void setDestArrivalDeadline(Date destArrivalDeadline) { this.destArrivalDeadline = destArrivalDeadline; }
}
Listing 6-13BookCargoCommand implementation
BookCargoCommand 类是一个常规的 POJO,它具有处理货物预订所需的所有必要属性(预订 ID、预订金额、始发地和目的地位置,最后是到达截止日期)。
预订 Id 代表货物集合的唯一性,即集合标识符。我们用目标聚合标识符注释来注释预订 Id 字段。因此,每次命令被发送到 Booking Command Bounded 上下文时,它都会在由 Booking ID 标识的聚合实例上处理命令。
在 Axon 内执行任何命令之前,必须设置聚合标识符,否则 Axon 框架将不知道它需要处理哪个聚合实例。
命令处理程序的标识
每个命令都会有一个对应的命令处理程序,需要 处理命令 。BookCargoCommand 将有一个相应的处理程序,它将接受 BookCargoCommand 作为输入参数并处理它。处理程序通常放在聚合中的例程上;然而,Axon 也允许将命令处理程序放在应用服务层的集合之外。
命令处理程序的实现
如前所述,命令处理程序的实现包括识别集合中可以处理命令的例程。Axon 提供了一个恰当命名的注释“ ”、@CommandHandler ”,它被放置在被标识为命令处理程序的聚合例程上。
让我们看一个 CargoBookingCommand 的预订命令处理程序的例子。清单 6-14 显示了位于聚合构造函数/常规例程上的@CommandHandler 注释:
package com.practicalddd.cargotracker.bookingms.domain.model;
import java.lang.invoke.MethodHandles;
import com.practicalddd.cargotracker.bookingms.domain.commands.AssignRouteToCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.commands.ChangeDestinationCommand;
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.spring.stereotype.Aggregate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Aggregate
public class Cargo {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@AggregateIdentifier
private String bookingId; // Aggregate Identifier
private BookingAmount bookingAmount; //Booking Amount
private Location origin; //Origin Location of the Cargo
private RouteSpecification routeSpecification; //Route Specification of the Cargo
private Itinerary itinerary; //Itinerary Assigned to the Cargo
protected Cargo() {
logger.info("Empty Cargo created.");
}
@CommandHandler //Command Handler for the BookCargoCommand. The first Command sent to an Aggregate is placed on the Aggregate Constructor
public Cargo(BookCargoCommand bookCargoCommand) {
//Process the Command
}
@CommandHandler //Command Handler for the Route Cargo Command
public void handle(AssignRouteToCargoCommand assignRouteToCargoCommand){
//Process the Command
}
@CommandHandler //CommandHandler for the Change Destination Command
public void handle(ChangeDestinationCommand changeDestinationCommand){
//Process the Command
}
}
Listing 6-14Command handlers within the Cargo aggregate
通常,发送到聚合的第一个命令用于创建聚合,并放置在聚合构造函数(或称为命令处理构造函数)上。
后续命令被放置在聚合中的常规例程上。RouteCargoCommand 和 ChangeCargoDestinationCommand 位于货物集合中的常规例程上。
命令处理程序具有业务逻辑/决策制定(例如,正在处理的命令数据的验证)以允许事件的后续处理和生成。这是命令处理程序的唯一职责: 它不应该修改聚合的状态。
清单 6-15 显示了 BookCargoCommand 中的一个示例,我们在其中验证预订金额:
@CommandHandler
public Cargo(BookCargoCommand bookCargoCommand) {
//Validation of the Booking Amount. Throws an exception if it is negative
if(bookCargoCommand.getBookingAmount() < 0){
throw new IllegalArgumentException("Booking Amount cannot be negative");
}
}
Listing 6-15Business Logic/Decision making within the Aggregate commands
图 6-35 描述了带有相应命令/命令处理程序的货物集合的类图。
图 6-35
货物总分类图
命令处理器的实现如图 6-36 所示。
图 6-36
命令处理程序实现
接下来是 发布事件 。
事件发布
一旦命令被处理,我们完成了所有的业务/决策逻辑,我们需要发布命令已经被处理的事件,例如,BookCargoCommand 导致了我们需要发布的 CargoBookedEvent。事件总是以过去时态提及,因为它表明在限定的上下文中已经发生了一些事情。
事件发布包括以下步骤:
-
事件的识别/实施
-
事件发布的实现
事件的识别/实施
正在处理的每个命令总是会导致一个事件。BookCargoCommand 将生成 CargoBookedEvent 类似地,AssignRouteToCargoCommand 将生成 CargoRoutedEvent。
事件传达了特定命令处理后的聚合状态,因此确保它们包含所有必需的数据变得非常重要。事件类的实现是作为 POJOs 完成的,不需要构造型注释。
清单 6-16 显示了 CargoBookedEvent 实现的示例:
package com.practicalddd.cargotracker.bookingms.domain.events;
import com.practicalddd.cargotracker.bookingms.domain.model.BookingAmount;
import com.practicalddd.cargotracker.bookingms.domain.model.Location;
import com.practicalddd.cargotracker.bookingms.domain.model.RouteSpecification;
/**
* Event resulting from the Cargo Booking Command
*/
public class CargoBookedEvent {
private String bookingId;
private BookingAmount bookingamount;
private Location originLocation;
private RouteSpecification routeSpecification;
public CargoBookedEvent(String bookingId,
BookingAmount bookingAmount,
Location originLocation,
RouteSpecification routeSpecification){
this.bookingId = bookingId;
this.bookingamount = bookingAmount;
this.originLocation = originLocation;
this.routeSpecification = routeSpecification;
}
public String getBookingId(){ return this.bookingId; }
public BookingAmount getBookingAmount(){ return this.bookingamount; }
public Location getOriginLocation(){return this.originLocation;}
public RouteSpecification getRouteSpecification(){return this.routeSpecification;}
}
Listing 6-16CargoBookedEvent implementation
事件发布的实现
那么我们在哪里发布这些事件呢?该处生成****即** 中的命令处理程序 **。回到我们在前面章节中的实现,命令处理器处理命令;一旦处理完成,它们负责发布正在处理的命令的事件。
命令的处理是聚合实例生命周期的一部分。Axon 框架提供了Aggregate life cycle类,帮助在聚合的生命周期中执行操作。这个类提供了一个静态函数“【apply()**”,帮助发布生成的事件。
清单 6-17 显示了 BookCargoCommand 的命令处理程序中的代码片段。命令处理完成后,调用 apply()方法发布 CargoBookedEvent 类的 Cargo Booked 事件,事件负载:
@CommandHandler
public Cargo(BookCargoCommand bookCargoCommand) {
logger.info("Handling {}", bookCargoCommand);
if(bookCargoCommand.getBookingAmount() < 0){
throw new IllegalArgumentException("Booking Amount cannot be negative");
}
//Publish the Generated Event using the apply method
apply(new CargoBookedEvent(bookCargoCommand.getBookingId(),
new BookingAmount(bookCargoCommand.getBookingAmount()),
new Location(bookCargoCommand.getOriginLocation()),
new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline())));
}
Listing 6-17Publishing of the Cargo Booked Event
事件发布如图 6-37 所示。接下来就是 维持状态 。
图 6-37
事件发布实现
状态维护
事件源过程中最重要和最关键的部分是理解状态是如何维护和利用的。本节包含一些与状态一致性相关的关键概念,因此我们将通过例子而不仅仅是简单的文献来解释它。
我们将再次依赖货物预订有限环境中的货物预订示例。简单回顾一下,到目前为止,我们已经标识了我们的聚合(Cargo ),给它一个身份,处理了命令,并发布了事件。
为了解释状态维护的概念,我们将为货物总量添加一个属性。
【路线状态】–决定已登记货物的路线状态:
-
新预订的货物还没有分配到路线,因为货运公司决定了最佳路线(路线状态 NOT _ ROUTED)。
-
货运公司决定路线,并将货物分配到该路线(路线状态-路线)。
清单 6-18 将 RoutingStatus 的实现描述为一个 Enum:
package com.practicalddd.cargotracker.bookingms.domain.model;
/**
* Enum class for the Routing Status of the Cargo
*/
public enum RoutingStatus {
NOT_ROUTED, ROUTED, MISROUTED;
public boolean sameValueAs(RoutingStatus other) {
return this.equals(other);
}
}
Listing 6-18Routing Status enum implementation
聚合内的事件处理
当一个事件从一个集合发布时,Axon 框架使那个 事件首先对集合本身 可用。由于聚合是事件源,因此它依赖事件来帮助维护其状态。这个概念一开始有点难以理解,因为我们习惯于检索和维护聚合状态的传统方式。简而言之,聚集依赖于事件源而不是传统源(例如,数据库)来维护其状态。
为了处理提供给它的事件,聚合使用 Axon 框架提供的注释“@ EventSourcingHandler”。该注释表明一个聚合是一个事件源聚合,它依赖于所提供的事件来维护其状态。
对于聚合接收的第一个命令和它接收的后续命令,检索和维护聚合状态的机制是不同的。在接下来的例子中,我们将详细介绍这两种方法。
状态维护:第一个命令
当一个集合接收到它的第一个命令时,Axon 框架识别出相同的命令,并且不重新创建状态,因为该特定集合的状态不存在。放置在构造函数上的命令(命令构造函数)指示这是聚合接收的第一个命令。
让我们看看在这种情况下状态是如何保持的。
清单 6-19 显示了代表货物集合状态的所有属性:
@AggregateIdentifier
private String bookingId; // Aggregate Identifier
private BookingAmount bookingAmount; //Booking Amount
private Location origin; //Origin Location of the Cargo
private RouteSpecification routeSpecification; //Route Specification of the Cargo
private Itinerary itinerary; //Itinerary Assigned to the Cargo
private RoutingStatus routingStatus; //Routing Status of the Cargo
Listing 6-19Aggregate Identifier implementation using Axon annotations
CargoBookedEvent 在 BookCargoCommandHandler 中发布后,需要设置 state 属性。Axon 框架首先向货物集合提供 CargoBookedEvent。货物集合处理事件以设置和维护状态属性。
清单 6-20 描述了货物聚合使用*"**@ EventSourcingHandler*"注释处理提供给它的 CargoBookedEvent,并设置相应的状态属性。在聚合处理的第一个事件中设置聚合标识符值(在本例中为预订 Id)是一个硬性要求:
@EventSourcingHandler
//Annotation indicating that the Aggregate is Event Sourced and is interested in the Cargo Booked Event raised by the Book Cargo Command
public void on(CargoBookedEvent cargoBookedEvent) {
logger.info("Applying {}", cargoBookedEvent);
//State Maintenance
bookingId = cargoBookedEvent.getBookingId(); //Hard Requirement to be set
bookingAmount = cargoBookedEvent.getBookingAmount();
origin = cargoBookedEvent.getOriginLocation();
routeSpecification = cargoBookedEvent.getRouteSpecification();
routingStatus = RoutingStatus.NOT_ROUTED;
}
Listing 6-20EventSourcing Handler implementation
完整的实现如清单 6-21 所示:
package com.practicalddd.cargotracker.bookingms.domain.model;
import java.lang.invoke.MethodHandles;
import com.practicalddd.cargotracker.bookingms.domain.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.events.CargoBookedEvent;
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.eventsourcing.EventSourcingHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.spring.stereotype.Aggregate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.axonframework.modelling.command.AggregateLifecycle.apply;;
@Aggregate
public class Cargo {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@AggregateIdentifier
private String bookingId; // Aggregate Identifier
private BookingAmount bookingAmount; //Booking Amount
private Location origin; //Origin Location of the Cargo
private RouteSpecification routeSpecification; //Route Specification of the Cargo
private Itinerary itinerary; //Itinerary Assigned to the Cargo
private RoutingStatus routingStatus; //Routing Status of the Cargo
protected Cargo() { logger.info("Empty Cargo created."); }
@CommandHandler //First Command to the Aggregate
public Cargo(BookCargoCommand bookCargoCommand) {
logger.info("Handling {}", bookCargoCommand);
if(bookCargoCommand.getBookingAmount() < 0){
throw new IllegalArgumentException("Booking Amount cannot be negative");
}
apply(new CargoBookedEvent(bookCargoCommand.getBookingId(),
new BookingAmount(bookCargoCommand.getBookingAmount()),
new Location(bookCargoCommand.getOriginLocation()),
new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline())));
}
@EventSourcingHandler //Event handler for the BookCargoCommand. Also sets the various state attributes
public void on(CargoBookedEvent cargoBookedEvent) {
logger.info("Applying {}", cargoBookedEvent);
// State being maintained
bookingId = cargoBookedEvent.getBookingId();
bookingAmount = cargoBookedEvent.getBookingAmount();
origin = cargoBookedEvent.getOriginLocation();
routeSpecification = cargoBookedEvent.getRouteSpecification();
routingStatus = RoutingStatus.NOT_ROUTED;
}
}
Listing 6-21CommandHandler/EventSourcingHandler within the Cargo Aggregate
图 6-38 显示了流程的图示。
图 6-38
状态维护–第一个命令
-
1–命令被路由到其货物命令处理器。
-
2/3–Axon 框架检查它是否是聚合上的第一个命令。如果是,它将控制权交还给命令处理程序进行业务检查。
-
4–命令处理程序生成事件。
-
5–Axon 事件路由器通过事件源处理器使事件首先可用于聚合本身。
-
6–事件源处理器更新聚合状态。
-
7–事件在 Axon 事件库中持久化。
-
8–Axon Event Router 让其他感兴趣的订阅者也能参加活动。
我们现在已经处理了第一个命令(BookCargo),发布了事件(CargoBooked),并设置了聚合(Cargo)状态。
现在让我们看看如何在后续命令中检索、利用和维护这种状态。
状态维护:后续命令
当一个聚合接收到另一个命令时,它需要利用当前的聚合状态来处理该命令。这实质上意味着,在开始处理命令时,Axon 框架将确保命令处理程序可以使用当前的聚合状态来进行任何业务逻辑检查。Axon 框架通过加载一个空的聚合实例,从事件存储中获取所有事件,并重放到目前为止在该特定聚合实例上发生的所有事件来实现这一点。
除了货物预订命令之外,让我们通过查看货物集合需要处理的两个附加命令来浏览这个解释,即,发送预订的货物和改变货物的目的地。
清单 6-22 描述了两个命令的命令处理程序:
/**
* Command Handler for Assigning the Route to a Cargo
* @param assignRouteToCargoCommand
*/
@CommandHandler
public Cargo(AssignRouteToCargoCommand assignRouteToCargoCommand) {
if(routingStatus.equals(RoutingStatus.ROUTED)){
throw new IllegalArgumentException("Cargo already routed");
}
apply( new CargoRoutedEvent(assignRouteToCargoCommand.getBookingId(),
new Itinerary(assignRouteToCargoCommand.getLegs())));
}
/**
* Cargo Handler for changing the Destination of a Cargo
* @param changeDestinationCommand
*/
@CommandHandler
public Cargo(ChangeDestinationCommand changeDestinationCommand){
if(routingStatus.equals(RoutingStatus.ROUTED)){
throw new IllegalArgumentException("Cannot change destination of a Routed Cargo");
}
apply(new CargoDestinationChangedEvent(changeDestinationCommand.getBookingId(),
new RouteSpecification(origin,
new Location(changeDestinationCommand.getNewDestinationLocation()), routeSpecification.getArrivalDeadline())));
}
Listing 6-22CommandHandler implementation within the Cargo aggregate
我们先来看一下“assignroutetomandhandler”。这个命令处理器负责处理分配货物路线的命令。处理人员进行检查以查看货物是否已经被发送。它通过检查由货物集合的“ 路由状态 ”属性表示的货物的当前路由状态来进行检查。Axon 框架负责向“**【assignroutetocommandler】提供最新的“routing status值,即负责向命令处理程序提供最新的货物集合状态。
****让我们逐步了解 Axon 框架是如何做到这一点的:
- Axon 认识到这不是聚合上的第一个命令。因此,它通过调用聚合上存在的受保护构造函数来加载聚合的空实例。
清单 6-23 显示了货物集合中受保护的构造函数:
- 然后,Axon 根据我们在命令中传递的目标聚合标识符 Id,查询事件源,查找该聚合实例上发生的所有事件。
protected Cargo() {
logger.info("Empty Cargo created.");
}
Listing 6-23Cargo aggregate protected constructor as required by Axon
图 6-39 描述了 Axon 框架到达当前聚集状态的一般过程。
图 6-39
轴突状态检索/维护过程
图 6-40 描述了 Axon 框架在处理 Route Cargo 命令处理程序后到达当前货物聚集状态的过程。
图 6-40
Axon 状态检索/维护流程——向货物指挥部分配路线
如所描述的,当接收到 Assign Route 命令时,Axon 框架利用集合标识符(预订 Id)来加载到目前为止在该特定集合实例上发生的所有事件,在本例中是 CARGO_BOOKED。Axon 用这个标识符实例化一个新的聚合实例,并重放这个聚合实例上的所有事件。
重放一个事件本质上意味着调用集合中设置单个状态属性的单个事件源处理程序方法。
让我们回到货物预订事件的事件采购处理程序。参见清单 6-24 。
@EventSourcingHandler //Event Sourcing Handler for the Cargo Booked Event
public void on(CargoBookedEvent cargoBookedEvent) {
logger.info("Applying {}", cargoBookedEvent);
// State being maintained
bookingId = cargoBookedEvent.getBookingId();
bookingAmount = cargoBookedEvent.getBookingAmount();
origin = cargoBookedEvent.getOriginLocation();
routeSpecification = cargoBookedEvent.getRouteSpecification();
routingStatus = RoutingStatus.NOT_ROUTED;
}
Listing 6-24Event replays within the EventSourcing handler
这里列出了集合状态,包括属性“ 【路由状态】 ”。当在聚合状态的构建过程中重播该事件时,该属性被设置为 NOT_ROUTED 的值。此属性可作为 AssignRouteToCargoCommand 的命令处理程序的整个聚合状态的一部分。因为它的最新值不是 _ROUTED,所以所有的命令处理程序检查都通过了,并且处理继续货物集合的路线和“ routingStatus ”属性的新值为 ROUTED。
现在让我们看下一个发送的命令,ChangeDestinationCommand。清单 6-25 显示了更改目标命令的命令处理程序。该命令旨在改变货物的最终目的地。我们在它的命令处理程序中实现了一个业务检查,如果货物已经被发送,我们不允许改变目的地。同样,针对“routingStatus”集合属性进行检查,该集合属性需要对更改目的地命令的命令处理程序可用。
图 6-41 描述了改变目标命令的流程。
图 6-41
Axon 状态检索/维护流程-在发送货物命令后更改目的地命令
在这种情况下,Axon 框架从事件存储中检索两个事件[CARGO_BOOKED 和 CARGO_ROUTED],并重放这两个事件。同样,事件重放本质上意味着在聚合中调用特定事件的事件源处理程序。
让我们回到货物预订事件和货物运输事件的事件来源处理程序:
@EventSourcingHandler //Event Sourcing Handler for the Cargo Booked Event
public void on(CargoBookedEvent cargoBookedEvent) {
logger.info("Applying {}", cargoBookedEvent);
// State being maintained
bookingId = cargoBookedEvent.getBookingId();
bookingAmount = cargoBookedEvent.getBookingAmount();
origin = cargoBookedEvent.getOriginLocation();
routeSpecification = cargoBookedEvent.getRouteSpecification();
routingStatus = RoutingStatus.NOT_ROUTED;
transportStatus =
}
@EventSourcingHandler //Event Sourcing Handler for the Cargo Routed Event
public void on(CargoRoutedEvent cargoRoutedEvent) {
itinerary = cargoRoutedEvent.getItinerary();
routingStatus = RoutingStatus.ROUTED;
}
Listing 6-25EventSourcing Handlers within the Cargo Aggregate
只关注聚合属性“”。在第一次事件重放(CARGO_BOOKED)结束时,该值被设置为 NOT_ROUTED。在第二个事件重放(CARGO_ROUTED)结束时,该值被设置为 ROUTED。这是该属性的最新和当前值,并作为整体聚合状态的一部分提供给更改目标命令处理程序。因为命令处理程序检查货物不应该被发送,所以它不允许处理继续进行并引发一个异常。
**另一方面,如果我们在调用 Assign Route to Cargo 命令之前调用了 Change Destination 命令,那么命令处理程序将允许处理继续进行,因为我们只有一个针对 Cargo Aggregate 实例重放的事件(CARGO_BOOKED 事件)。图 6-42 描述了在预订货物命令之后和分配路线给货物命令之前调用更改目的地命令的情况。
图 6-42
Axon 状态检索/维护流程-在发送货物命令之前更改目的地命令
图 6-43 中描述了完整的流程图示。
图 6-43
轴突状态检索过程——第一个/后续命令
图 6-44 描述了带有相应事件/事件处理程序的货物集合的类图。
图 6-44
带有事件/事件处理程序的货物集合类图
这就完成了预订有界上下文的 聚合 的实现。
总体预测
我们已经在有界上下文的命令端看到了聚合的完整实现。正如我们已经演示和看到的,我们并不将聚合状态直接存储在数据库中,而是将聚合上发生的事件存储在专门构建的事件存储中。
命令不是有界上下文中唯一的操作。我们还将有查询操作,这些操作将到达。我们必然会有需要查询汇总状态的需求,例如,为操作员显示货物汇总的 web 屏幕。查询事件存储并尝试重放事件以获得当前状态不是最佳选择,也绝对不建议这样做。想象一个在生命周期中经历了多个事件的集合。为了达到当前状态而在这个集合上重放每一个事件将是非常昂贵的。我们需要另一种机制来优化和直接获得聚合状态。
我们使用“”来帮助我们实现这一点。简单来说,一个聚合投影就是聚合状态的各种形式的表示或视图,即聚合状态的读作模型 。根据计划需要完成的用例的类型,我们可以对一个聚合有多个聚合计划。聚合预测始终由包含预测数据的数据存储支持。该数据存储可以是传统的关系数据库(例如,MySql)、NoSQL 数据库(例如,MongoDB),或者甚至是内存存储(例如,Elastic)。数据存储还依赖于项目需要完成的用例的类型。****
****通过订阅命令端生成的事件并相应地更新自身,投影的数据存储始终保持最新(参见下面的事件处理程序接口)。投影提供了一个查询层,外部使用者可以使用它来获取投影数据。
图 6-45 中描述了投影流程的总结。
图 6-45
轴突投射
总体规划的实施包括以下几个方面:
-
聚合投影类实现
-
查询处理程序
-
投影状态维护
聚合预测类的实现取决于我们决定实现来存储预测状态的数据存储的类型。在我们的货物跟踪应用中,我们决定将预测状态存储在传统的 SQL 数据库中,即 MySQL。
我们的每个有界上下文将有一个基于 MySQL 的 投影数据存储 。根据我们需要满足 的用例,每个数据库可以有多个包含各种类型 投影数据 的表格。 我们在这个投影数据之上构建聚合投影类。
这如图 6-46 所示。
图 6-46
有界上下文——MySQL 数据库之上的投影数据库
因为我们的数据存储将是一个 SQL 数据库,所以我们的聚合投影类的实现将基于 JPA (Java 持久性 API)。
让我们来看一个聚合投影类的实现,即“ 货物汇总 ”投影。这个投影使用“cargo _ summary _ projection”表,该表保存在名为“bookingprojectiondb”的 MySql 数据库中。
预测需要提供预订货物的以下详细信息:
-
预订 ID
-
路线状态(货物是否已经过路线)
-
运输状态(货物是在港口还是在船上)
-
原点位置
-
目的地位置
-
到达截止日期
同样,根据用例的不同,预测需求也会有所不同。外部消费者使用货物汇总预测来快速了解货物的情况。
清单 6-26 描述了作为常规 JPA 实体实施的货物汇总预测。我们把它保存在域模型内的 投影 包内:
package com.practicalddd.cargotracker.bookingms.domain.projections;
import javax.persistence.*;
import java.util.Date;
/**
* Projection class for the Cargo Aggregate implemented as a regular JPA Entity. Contains a summary of the Cargo Aggregate
*/
@Entity //Annotation to mark as a JPA Entity
@Table(name="cargo_summary_projection") //Table Name Mapping
@NamedQueries({ //Named Queries
@NamedQuery(name = "CargoSummary.findAll",
query = "Select c from CargoSummary c"),
@NamedQuery(name = "CargoSummary.findByBookingId",
query = "Select c from CargoSummary c where c.booking_id = :bookingId"),
@NamedQuery(name = "Cargo.getAllBookingIds",
query = "Select c.booking_id from CargoSummary c") })
public class CargoSummary {
@Id
private String booking_id;
@Column
private String transport_status;
@Column
private String routing_status;
@Column
private String spec_origin_id;
@Column
private String spec_destination_id;
@Temporal(TemporalType.DATE)
private Date deadline;
protected CargoSummary(){
this.setBooking_id(null);
}
public CargoSummary(String booking_id,
String transport_status,
String routing_status,
String spec_origin_id,
String spec_destination_id,
Date deadline){
this.setBooking_id(booking_id);
this.setTransport_status(transport_status);
this.setRouting_status(routing_status);
this.setSpec_origin_id(spec_origin_id);
this.setSpec_destination_id(spec_destination_id);
this.setDeadline(new Date());
}
public String getBooking_id() { return booking_id;}
public void setBooking_id(String booking_id) {this.booking_id = booking_id;}
public String getTransport_status() {return transport_status; }
public void setTransport_status(String transport_status) { this.transport_status = transport_status;}
public String getRouting_status() {return routing_status;}
public void setRouting_status(String routing_status) {this.routing_status = routing_status; }
public String getSpec_origin_id() { return spec_origin_id; }
public void setSpec_origin_id(String spec_origin_id) {this.spec_origin_id = spec_origin_id; }
public String getSpec_destination_id() {return spec_destination_id;}
public void setSpec_destination_id(String spec_destination_id) {this.spec_destination_id = spec_destination_id; }
public Date getDeadline() { return deadline;}
public void setDeadline(Date deadline) {this.deadline = deadline;}
}
Listing 6-26Cargo Aggregate JPA Entity
图 6-47 描述了货物汇总聚合投影类的 UML 图。
图 6-47
货物汇总合计预测类的类图
投影类映射到一个 JPA 实体和一个相应的表后,让我们转到查询层。
查询处理程序
查询被发送到有界上下文,以通过聚集投影检索有界上下文的聚集状态。实现查询处理包括以下内容:
查询的识别/实现
识别/实现处理命令的查询处理程序
查询的标识
概括地说,聚集预测代表聚集的状态。聚合投影需要一个查询层,以使外部各方能够使用投影数据。查询的识别围绕着对聚集投影数据感兴趣的任何操作。
例如,通过具有满足这些要求所需的数据的货物汇总预测,预订有界上下文具有来自外部消费者的以下要求:
-
单个货物的摘要
-
所有货物的汇总清单
-
所有货物的订舱标识符列表
查询的实现
使用常规的 POJOs 来实现 Axon 中确定的查询。对于每个已识别的查询,我们需要实现一个 查询类 和一个 查询结果类 。查询类是需要与执行标准一起执行的实际查询,而查询结果类是查询执行的结果。
让我们看一些例子来进一步解释这一点。考虑我们已经确定的获取单个货物摘要的查询。我们将它命名为“CargoSummaryQuery”,查询执行类的结果命名为“CargoSummaryResult”。
清单 6-27 描述了 CargoSummaryQuery 类。类名传达了意图,它有一个接受预订 Id 的构造函数,即执行查询的标准:
package com.practicalddd.cargotracker.bookingms.domain.queries;
/**
* Implementation of Cargo Summary Query class. It takes in a Booking Id which is the criteria for the query
*/
public class CargoSummaryQuery {
private String bookingId; //Criteria of the Query
public CargoSummaryQuery(String bookingId){
this.bookingId = bookingId;
}
@Override
public String toString() { return "Cargo Summary for Booking Id" + bookingId; }
}
Listing 6-27CargoSummaryQuery implementation
这里没有复杂之处——简单的 POJO 携带查询的意图和标准。
然后,我们实现包含执行结果的“CargoSummaryResult”类,在本例中是 CargoSummaryProjection。
清单 6-28 描述了同样的情况:
package com.practicalddd.cargotracker.bookingms.domain.queries;
import com.practicalddd.cargotracker.bookingms.domain.projections.CargoSummary;
/**
* Implementation of the Cargo Summary Result class which contains the
* results of the execution of the CargoSummaryQuery. The result contains
* data from the CargoSummary Projection
*/
public class CargoSummaryResult {
private final CargoSummary cargoSummary;
public CargoSummaryResult(CargoSummary cargoSummary) { this.cargoSummary = cargoSummary; }
public CargoSummary getCargoSummary() { return cargoSummary;}
}
Listing 6-28CargoSummaryResult implementation
我们现在已经实现了 查询类 (货物汇总),它具有 查询意图 (获取货物汇总)以及 查询条件 (货物的预订 Id)。
让我们看看如何实现查询的处理。
查询处理程序的实现
正如我们有命令处理程序来处理命令一样,我们也有查询处理程序来处理查询指令。查询处理程序的实现包括识别可以处理查询的组件。与放置在集合本身上的命令不同,查询处理程序放置在常规 Spring Boot 组件内的例程上。Axon 提供了一个恰当命名的注释“ ”、@QueryHandler ”,以帮助注释标记为查询处理程序的组件中的例程。
清单 6-29 描述了"CargoAggregateQueryHandler ",它处理针对货物总量的货物汇总预测的所有查询。我们在这个组件中有两个查询处理程序,一个用于处理 CargoSummaryQuery ,另一个用于 ListCargoSummariesQuery。
查询处理程序
-
将查询(货物汇总查询,列表货物汇总查询)作为输入
-
对 CargoSummaryProjection JPA 实体执行命名 JPA 查询
-
返回结果( CargoSummaryResult , ListCargoSummaryResult)
清单 6-29 展示了货物集合查询处理程序的实现:
package com.practicalddd.cargotracker.bookingms.domain.queryhandlers;
import com.practicalddd.cargotracker.bookingms.domain.projections.CargoSummary;
import com.practicalddd.cargotracker.bookingms.domain.queries.CargoSummaryQuery;
import com.practicalddd.cargotracker.bookingms.domain.queries.CargoSummaryResult;
import com.practicalddd.cargotracker.bookingms.domain.queries.ListCargoSummariesQuery;
import com.practicalddd.cargotracker.bookingms.domain.queries.ListCargoSummaryResult;
import org.axonframework.queryhandling.QueryHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.persistence.EntityManager;
import javax.persistence.Query;
import java.lang.invoke.MethodHandles;
/**
* Class which acts as the Query Handler for all queries related to the Cargo Summary Projection
*/
@Component
public class CargoAggregateQueryHandler {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final EntityManager entityManager;
public CargoAggregateQueryHandler(EntityManager entityManager){
this.entityManager = entityManager;
}
/**
* Query Handler Query which returns the Cargo Summary for a Specific Query
* @param cargoSummaryQuery
* @return CargoSummaryResult
*/
@QueryHandler
public CargoSummaryResult handle(CargoSummaryQuery cargoSummaryQuery) {
logger.info("Handling {}", cargoSummaryQuery);
Query jpaQuery = entityManager.createNamedQuery("CargoSummary.findByBookingId", CargoSummary.class).setParameter("bookingId",cargoSummaryQuery.getBookingId());
CargoSummaryResult result = new CargoSummaryResult((CargoSummary)jpaQuery.getSingleResult());
logger.info("Returning {}", result);
return result;
}
/**
* Query Handler for the Query which returns all Cargo summaries
* @param listCargoSummariesQuery
* @return CargoSummaryResult
*/
@QueryHandler
public ListCargoSummaryResult handle(ListCargoSummariesQuery listCargoSummariesQuery) {
logger.info("Handling {}", listCargoSummariesQuery);
Query jpaQuery = entityManager.createNamedQuery("CardSummary.findAll", CargoSummary.class);
jpaQuery.setFirstResult(listCargoSummariesQuery.getOffset());
jpaQuery.setMaxResults(listCargoSummariesQuery.getLimit());
ListCargoSummaryResult result = new ListCargoSummaryResult(jpaQuery.getResultList());
return result;
}
}
Listing 6-29CargoAggregateQueryHandler implementation
图 6-48 描述了货物集合查询处理器类的 UML 图。
图 6-48
货物汇总查询处理程序类的类图
在我们总结聚合预测的实现之前,这里有一个关于查询的简短说明。
Axon 提供三种类型的查询实现:
-
点对点–我们上面的例子是基于点对点查询,其中每个查询都有一个相应的查询处理程序。结果类实际上被 Axon 包装成一个 CompletableFuture < T >,但是它是从开发者那里抽象出来的。
-
——在这种类型中,查询被发送给订阅该查询的所有处理程序,然后返回一个结果流,该结果流被合成并发送给客户机。
*** 订阅查询——这是 Axon 框架提供的一个高级查询处理选项。它使客户端能够获得它想要查询的聚合投影的初始状态,并随着投影数据在一段时间内经历的变化而保持最新。**
**这就完成了总体预测的实施。
萨迦
实现领域模型的最后一个方面是 Sagas 的实现。如前所述,传奇可以通过两种方式实现——通过事件的编排或通过事件的编排。
在我们进入实现细节之前,让我们回顾一下 Cargo Tracker 应用中各种业务流的简单视图,以及它们所属的传奇故事。
图 6-49 描绘了业务流程和他们参与的传奇故事。
图 6-49
商业流程和他们参与的传奇故事
订舱传奇包括货物订舱、货物运输路线和货物跟踪中的业务操作。它从被预订的货物及其随后的路线开始,最后以分配给被预订货物的跟踪标识符结束。客户使用该跟踪标识符来跟踪货物的进度。
装卸传奇包括货物装卸、检验、索赔和最终结算中的业务操作。它始于货物在港口的处理,货物在港口经过一次航行,并在最终目的地被客户认领,止于货物的最终结算(例如,延迟交货的罚款)。
这两个传奇都可以通过编排或编排来实现。我们将实现预订传奇,通过示例,它也可以用于实现处理传奇,重点是使用 Axon 框架的内置支持实现编排。
在我们进入实现之前,让我们详细描述一下预订的各种命令、事件和事件处理程序。
图 6-50 对此进行了描述。
图 6-50
订票传奇
这本质上是使用编排方法的实现的表示,其中我们调用命令、引发事件,并且事件处理程序在一个链中处理事件,直到处理完最后一个事件。
编排方法的实现有很大的不同,其中我们有一个中央组件来处理事件和随后的命令调用。换句话说,我们将处理事件和调用命令的责任从单个事件处理程序转移到一个执行相同任务的中央组件。
图 6-51 描述了编排方法。
图 6-51
编排方法
让我们看一下代码后面的实现步骤:
-
我们表示我们的传奇故事的名称,也就是说,在这种情况下,我们将我们的传奇故事表示为预订传奇故事。
-
在处理完 货物预订命令 后,引发 货物预订事件 。
-
订舱传奇 订阅 货物订舱事件 并启动传奇过程。
-
订舱传奇 发送指令处理 分配路线给货物命令。 该命令引发 货物发送事件。
-
订舱传奇 订阅 货物路由事件 ,然后发送指令处理 分配跟踪细节给货物命令。 该命令引发 跟踪指定事件细节。
-
预订传奇订阅 跟踪细节分配事件 并且由于有 不再有要处理的命令 决定 结束传奇 。
正如所看到的,集中式 Saga 组件现在接管了跨多个有界上下文的命令和事件的整个协调和排序的责任。有界上下文中的域模型对象都不知道它们正在参与一个 Saga 过程。此外,它们不需要订阅来自其他有界上下文的事件来参与事务。他们主要依靠传奇故事来实现这一点。
基于编排的 saga 是在微服务架构中实现分布式事务的一种非常强大的方式,因为它固有的脱钩特性有助于
-
将分布式事务隔离到专用组件
-
监控和跟踪分布式事务的流程
-
微调和改进分布式事务的流程
一个传奇的代码是通过 Axon 提供的各种注释实现的。这些步骤概述如下:
-
我们取一个常规的 POJO 并用一个原型注释( @Saga )来标记它,这表示这个类充当一个 Saga 组件。
-
如上所述,Saga 响应事件并调用命令。Axon 框架提供了一个特定于 saga 的事件处理程序注释来处理事件(**@**SagaEventHandler)。就像常规的事件处理程序一样,它们被放在 Saga 类的例程中。每个 Saga 事件处理程序都需要提供一个 关联属性 。该属性有助于 Axon 框架将 Saga 映射到参与 Saga 的聚合的特定实例。
-
命令的调用是以标准的 Axon 方式完成的,即利用命令网关来调用命令。
-
最后一部分是对 Saga 组件实施生命周期方法(开始 Saga、停止 Saga)。Axon 框架提供了“ ”、@StartSaga ”注释来表示传奇的开始,以及“Saga life cycle . end()”来结束传奇。
清单 6-30 描述了预订传奇的实施:
package com.practicalddd.cargotracker.booking.application.internal.sagaparticipants;
import com.practicalddd.cargotracker.booking.application.internal.commandgateways.CargoBookingService;
import com.practicalddd.cargotracker.booking.domain.commands.AssignRouteToCargoCommand;
import com.practicalddd.cargotracker.booking.domain.commands.AssignTrackingDetailsToCargoCommand;
import com.practicalddd.cargotracker.booking.domain.events.CargoBookedEvent;
import com.practicalddd.cargotracker.booking.domain.events.CargoRoutedEvent;
import com.practicalddd.cargotracker.booking.domain.events.CargoTrackedEvent;
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.axonframework.modelling.saga.SagaEventHandler;
import org.axonframework.modelling.saga.SagaLifecycle;
import org.axonframework.modelling.saga.StartSaga;
import org.axonframework.spring.stereotype.Saga;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.invoke.MethodHandles;
import java.util.UUID;
/**
* The Booking Saga Manager is the implementation of the Booking saga.
* The Saga starts when the Cargo Booked Event is raised
* The Saga ends when the Tracking Details have been assigned to the Cargo
*/
@Saga //Stereotype Annotation depicting this as a Saga
public class BookingSagaManager {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private CommandGateway commandGateway;
private CargoBookingService cargoBookingService;
/**
* Dependencies for the Saga Manager
* @param commandGateway
*/
public BookingSagaManager(CommandGateway commandGateway,CargoBookingService cargoBookingService){
this.commandGateway = commandGateway;
this.cargoBookingService = cargoBookingService;
}
/**
* Handle the Cargo Booked Event, Start the Saga and invoke the Assign Route to Cargo Command
* @param cargoBookedEvent
*/
@StartSaga //Annotation indicating the Start of the Saga
@SagaEventHandler(associationProperty = "bookingId") // Saga specific annotation to handle an Event
public void handle(CargoBookedEvent cargoBookedEvent){
logger.info("Handling the Cargo Booked Event within the Saga");
//Send the Command to assign a route to the Cargo
commandGateway.send(new AssignRouteToCargoCommand(cargoBookedEvent.getBookingId(),
cargoBookingService.getLegsForRoute(cargoBookedEvent.getRouteSpecification())));
}
/**
* Handle the Cargo Routed Event and invoke the Assign Tracking Details to Cargo Command
* @param cargoRoutedEvent
*/
@SagaEventHandler(associationProperty = "bookingId")
public void handle(CargoRoutedEvent cargoRoutedEvent){
logger.info("Handling the Cargo Routed Event within the Saga");
String trackingId = UUID.randomUUID().toString(); // Generate a random tracking identifier
SagaLifecycle.associateWith("trackingId",trackingId);
//Send the COmmand to assign tracking details to the Cargo
commandGateway.send(new AssignTrackingDetailsToCargoCommand(
cargoRoutedEvent.getBookingId(),trackingId));
}
/**
* Handle the Cargo Tracked Event and end the Saga
* @param cargoTrackedEvent
*/
@SagaEventHandler(associationProperty = "trackingId")
public void handle(CargoTrackedEvent cargoTrackedEvent) {
SagaLifecycle.end(); // End the Saga as this is the last Event to be handled
}
}
Listing 6-30Booking Saga implementation
实施摘要
这就用 Axon 框架完成了领域模型的实现。图 6-52 描述了实施的概要。
图 6-52
领域模型实现摘要
用 Axon 实现领域模型服务
概括地说,域模型服务为域模型提供支持服务(例如,便于外部团体使用域模型,帮助域模型与外部存储库通信)。实现是使用 Spring Boot 提供的功能和 Axon 框架提供的功能的组合来完成的。我们需要实现以下类型的域模型服务:
-
入站服务
-
应用服务程序
入站服务
入站服务(或六角形架构模式中表示的入站适配器)充当我们的核心域模型的最外层网关。
在我们的货物跟踪应用中,我们提供以下入境服务:
-
基于 REST 的 API 层,外部消费者使用它来调用有界上下文上的操作(命令/查询)
-
Axon 实现的事件处理层,它从事件总线中消费事件并处理它们
应用接口
REST API 的职责是代表有界上下文接收来自外部消费者的 HTTP 请求。这个请求可能是命令或查询。REST API 层的职责是将其翻译成由有界上下文的域模型识别的命令/查询模型,并将其委托给应用服务层进行进一步处理。
图 6-53 描述了 REST API 流程/职责。
图 6-53
REST API 流程/职责
REST API 的实现利用了 Spring Web 提供的 REST 功能。回顾本章前面的内容,我们为 Spring Boot 应用添加了这种依赖性。
让我们看一个 REST API 的例子。清单 6-31 描述了我们货物预订命令的 REST API/控制器:
-
它有一个 POST 方法,接受 BookCargoResource,这是 API 的输入有效负载。
-
它依赖于 CargoBookingService,CargoBookingService 是一个应用服务(见后面)。
-
它使用汇编器实用程序类(BookCargoCommandDTOAssembler)将资源数据(BookCargoResource)转换为命令模型(BookCargoCommand)。
-
转换后,它将流程委托给 CargoBookingService 进行进一步处理。
package com.practicalddd.cargotracker.bookingms.interfaces.rest;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.transform.assembler.BookCargoCommandDTOAssembler;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.transform.dto.BookCargoResource;
import com.practicalddd.cargotracker.bookingms.application.internal.commandgateways.CargoBookingService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
/**
* REST API for the Book Cargo Command
*/
@RestController
@RequestMapping("/cargobooking")
public class CargoBookingController {
private final CargoBookingService cargoBookingService; // Application Service Dependency
/**
* Provide the dependencies
* @param cargoBookingService
*/
public CargoBookingController(CargoBookingService cargoBookingService){
this.cargoBookingService = cargoBookingService;
}
/**
* POST method to book a cargo
* @param bookCargoCommandResource
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void bookCargo(@RequestBody final BookCargoResource bookCargoCommandResource){
cargoBookingService.bookCargo(BookCargoCommandDTOAssembler.toCommandFromDTO(bookCargoCommandResource));
}
}
Listing 6-31CargoBookingController implementation
这就完成了 REST API 入站服务的实现。
事件处理程序
有界上下文中的事件处理程序负责处理该有界上下文订阅的事件。事件处理程序负责将事件数据转换为可识别的模型,以便进一步处理。事件处理程序通常委托给应用服务来处理转换后的事件。
图 6-54 描述了事件处理流程/职责。
图 6-54
事件处理流程/职责
事件处理程序的实现是通过利用 Axon Framework 原型注释( @EventHandler )来完成的。这些注释放在常规 Spring 服务的例程中,包含事件处理程序将要处理的特定事件。
让我们看一个事件处理程序的例子。清单 6-32 描述了事件处理程序 CargoProjectionsEventHandler。该事件处理器从货物集合订阅状态改变事件,并相应地更新货物集合预测(例如,货物汇总):
-
事件处理程序类用@Service 注释进行了注释。
-
它依赖于 CargoProjectionService,CargoProjectionService 是一个应用服务(见后面)。
-
它通过用@EventHandler 批注在 handler 类中标记 handleCargoBookedEvent()方法来处理 CargoBookedEvent。
-
handleCargoBookedEvent()使用 CargoBookedEvent 作为事件负载。
-
它将事件数据(CargoBookedEvent)转换为聚合预测模型(CargoSummary)。
-
转换后,它将流程委托给 CargoProjectionService 进行进一步处理。
package com.practicalddd.cargotracker.bookingms.interfaces.events;
import com.practicalddd.cargotracker.bookingms.application.internal.CargoProjectionService;
import com.practicalddd.cargotracker.bookingms.domain.events.CargoBookedEvent;
import com.practicalddd.cargotracker.bookingms.domain.projections.CargoSummary;
import org.axonframework.eventhandling.EventHandler;
import org.axonframework.eventhandling.Timestamp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.persistence.EntityManager;
import java.lang.invoke.MethodHandles;
import java.time.Instant;
/**
* Event Handlers for all events raised by the Cargo Aggregate
*/
@Service
public class CargoProjectionsEventHandler {
private final static Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private CargoProjectionService cargoProjectionService; //Dependencies
public CargoProjectionsEventHandler(CargoProjectionService cargoProjectionService) {
this.cargoProjectionService = cargoProjectionService;
}
/**
* EVent Handler for the Cargo Booked Event. Converts the Event Data to
* the corresponding Aggregate Projection Model and delegates to the
* Application Service to process it further
* @param cargoBookedEvent
* @param eventTimestamp
*/
@EventHandler
public void cargoBookedEventHandler(CargoBookedEvent cargoBookedEvent, @Timestamp Instant eventTimestamp) {
logger.info("Applying {}", cargoBookedEvent.getBookingId());
CargoSummary cargoSummary = new CargoSummary(cargoBookedEvent.getBookingId(),"","",
"","",new java.util.Date());
cargoProjectionService.storeCargoSummary(cargoSummary);
}
}
Listing 6-32CargoProjectionsEventHandler implementation
这就完成了入站服务的实现。
应用服务程序
应用服务充当入站服务和核心域模型之间的门面或端口。在 Axon Framework 应用中,有界上下文中的应用服务负责接收来自入站服务的请求,并将它们委托给相应的网关,即命令被委托给命令网关,而查询被委托给查询网关。事件被处理,并且结果根据期望的输出被持久化(例如,预测被持久化到数据存储中)。
图 6-55 描述了应用服务的职责。
图 6-55
应用服务的职责
清单 6-33 描述了货物预订服务类,该服务类负责处理发送到预订绑定上下文的所有命令:
package com.practicalddd.cargotracker.bookingms.application.internal.commandgateways;
import com.practicalddd.cargotracker.bookingms.domain.commands.AssignRouteToCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.commands.ChangeDestinationCommand;
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.springframework.stereotype.Service;
/**
* Application Service Class to Book a Cargo, Route a Cargo and Change the
* Destination of a Cargo All Commands to the Cargo Aggregate are grouped
* into this sevice class
*/
@Service
public class CargoBookingService {
private final CommandGateway commandGateway;
public CargoBookingService(CommandGateway commandGateway){
this.commandGateway = commandGateway;
}
/**
* Book a Cargo
* @param bookCargoCommand
*/
public void bookCargo(BookCargoCommand bookCargoCommand){
commandGateway.send(bookCargoCommand); //Invocation of the Command gateway
}
/**
* Change the Destination of a Cargo
* @param changeDestinationCommand
*/
public void changeDestinationOfCargo(ChangeDestinationCommand changeDestinationCommand) {
commandGateway.send(changeDestinationCommand); //Invocation of the Command gateway
}
/**
* Assigns a Route to a Cargo
* @param assignRouteToCargoCommand
*/
public void assignRouteToCargo(AssignRouteToCargoCommand assignRouteToCargoCommand){
commandGateway.send(assignRouteToCargoCommand); //Invocation of the Command gateway
}
}
Listing 6-33Cargo Booking Service implementation
这就完成了我们的应用服务和领域模型服务的实现。
摘要
总结我们的章节
-
我们从建立 Axon 平台的细节开始,包括 Axon 框架和 Axon 服务器。
-
我们深入研究了各种 DDD 工件的开发——首先是域模型,包括使用 Spring Boot 和 Axon 框架的集合、命令和查询。
-
我们深入了解了 Axon 采用的事件源模式的细节。
-
我们通过使用 Axon 框架提供的功能来实现领域模型服务,从而达到圆满的结果。***************************