Java-EE-领域驱动设计实践指南-全-
Java EE 领域驱动设计实践指南(全)
一、领域驱动设计
领域驱动设计为软件设计和开发提供了一种可靠的、系统的和全面的方法。它提供了一套工具和技术,有助于分解业务复杂性,同时保持核心业务模型作为该方法的核心。
长期以来,DDD 一直是传统(读取单片)项目的首选方法,随着微服务架构的出现,DDD 概念甚至越来越多地应用于这种新的架构范式。
这本书分成两大部分。
DDD 概念的建模
实现 DDD 从识别映射到 DDD 概念的工件(子域、有界上下文、域模型、域规则)的建模过程开始。这本书的前几章给出了 DDD 概念的高级概述,然后概述了一个完整的建模过程,以识别和记录相关的工件,遍历我们的参考应用的用例。
DDD 概念的实施
该书随后深入探讨了这些概念的实现。使用 Enterprise Java 作为基础平台,它经历了三种不同的实现:
-
第一个实现详细描述了基于使用 Java EE/Jakarta EE 平台的整体架构的 DDD 概念的实现。
-
第二个实现详细描述了 DDD 概念的实现,该实现基于微文件平台上的微服务架构。
-
最后,第三个实现详细介绍了基于 Spring 平台上微服务架构的 DDD 概念的实现。
这些实现涵盖了企业 Java 领域中流行的三个主要的主流平台,并提供了 DDD 模式实现的完整细节。
DDD 概念
清楚了这本书的意图后,让我们通过快速浏览它的概念来步入 DDD 之旅。
问题空间/业务领域
我们需要熟悉的 DDD 的第一个主要概念是“问题空间”或“业务领域”的识别问题空间/业务领域是 DDD 之旅的起点,它确定了您打算使用 DDD 解决的主要业务问题。
让我们用一些实例来阐述这个概念。
第一个例子取自汽车金融行业的一个案例,如图 1-1 所示。如果你从事汽车金融业务,你从事的是管理汽车贷款和租赁的业务,也就是说,作为汽车金融提供商,你需要向消费者发放贷款/租赁,为他们提供服务,最后如果出现问题,收回贷款或终止租赁。这种情况下的 问题空间 可以归类为汽车贷款/租赁管理,也可以称为您的 核心业务领域 和您希望使用领域驱动设计解决的 业务问题 。
图 1-1
汽车金融服务问题空间
第二个例子是银行业的一个案例。与第一个例子不同,在这种情况下,不是一个而是多个问题空间需要使用领域驱动设计来解决(图 1-2 )。
图 1-2
零售银行服务中的业务领域
作为一家银行,你可以向普通客户提供零售银行服务(图 1-2 )或者向企业客户提供企业银行服务(图 1-3 )。这些服务每个都有多个问题空间或核心业务领域。
图 1-3
企业银行服务中的核心业务领域
问题空间/业务领域 总是不变地转化为你作为一家公司提供的核心业务主张。
子域/有界上下文
一旦我们确定了主要的业务领域,下一步就是将该领域分解为其子领域。子域的识别本质上包括将主业务域的各种业务能力分解成业务功能的内聚单元。
再次以汽车金融业务领域为例,这可以分为三个子领域,如图 1-4 所示。
图 1-4
汽车金融业务领域内的子域
-
发放子域——该子域负责向客户发放新的汽车贷款/租赁的业务能力。
-
服务子域–该子域负责为这些汽车贷款/租赁服务的业务能力(例如,每月计费/支付)。
-
托收子域–该子域负责在出现问题(例如,客户拖欠付款)时管理这些汽车贷款/租赁的业务能力。
显而易见,子域是根据日常使用的主要业务的业务能力来确定的。
图 1-5 所示为另一个为我们的零售银行业务领域之一——信用卡管理业务领域确定子域的示例。
图 1-5
信用卡管理业务领域中的子域
-
产品子域——该子域负责管理所有类型的信用卡产品的业务能力。
-
计费子域–该子域负责客户信用卡的计费业务能力。
-
索赔子域–该子域负责管理客户信用卡任何类型索赔的业务能力。
再次强调实际的业务能力有助于清晰地识别子域。
那么什么是有界上下文呢?
概括地说,我们从确定我们的业务领域开始我们的旅程。我们进一步阐述了我们的业务领域,将它们分成不同的功能,以确定映射到业务中不同功能的子域。
我们需要开始为之前确定的相应领域/子域创建解决方案,也就是说,我们需要从问题空间区域转移到解决方案空间区域,这就是有界上下文发挥核心作用的地方。
简单地说,有界上下文是我们确定的业务领域/子域的设计解决方案。
有界上下文的标识主要由业务领域内和子域之间所需的内聚性控制。
回到汽车金融业务领域的第一个例子,我们可以选择为整个领域提供一个单一的解决方案,也就是说,为所有子域提供一个单一的有界上下文;或者我们可以选择将一个有界的上下文映射到一个子域/多个子域。
图 1-6 中汽车贷款/租赁管理问题空间的解决方案是所有子域的单一有界上下文。
图 1-6
汽车金融子域作为单个有界上下文解决
另一种方法是将汽车金融领域中的不同子域作为单独的有界上下文来解决。图 1-7 展示了这一点。
图 1-7
汽车金融子域作为独立的有界上下文解决
只要有界的上下文被视为一个单一的内聚单元,对部署的选择就没有限制。您可以对多绑定上下文方法进行整体部署(单个 Web Archive [WAR]文件,每个绑定上下文有多个 JAR 文件),您可以选择一个微服务部署模型,每个绑定上下文作为一个单独的容器,或者您可以选择一个无服务器模型,每个绑定上下文作为一个功能进行部署。
作为后续章节中实现的一部分,我们将研究各种可用的部署模型。
领域模型
我们现在处于领域解决过程中最重要和最关键的部分,即有界上下文的领域模型的建立。简而言之,领域模型是核心业务逻辑在特定范围内的实现。
在商业语言中,这包括识别
-
商业实体
-
商业规则
-
业务流程
-
业务操作
-
企业业务
在 DDD 世界的技术语言中,这可以解释为识别
-
聚合/实体/值对象
-
域规则
-
萨迦
-
命令/查询
-
事件
这如图 1-8 所示。如图所示,业务语言结构被映射到它们相应的 DDD 技术语言结构。
图 1-8
在 DDD 范式中,按照业务语言及其相应的技术语言,有界上下文的领域模型
虽然我们将在随后的章节中详细阐述这些不同的概念,但让我们在这里简单地讨论一下。如果现在还没有什么意义,不要担心。接下来的章节将确保你对这些概念有一个良好的基础。
聚合/实体对象/值对象
聚合(也称为根聚合)是有界上下文中的中心业务对象,它定义了有界上下文中的一致性范围。有界上下文的每个方面都在根聚合中开始和结束。
- Aggregate =您的有界上下文的主标识符
实体对象有自己的身份,但没有根聚合就不能存在,也就是说,它们在创建根聚合时创建,在销毁根聚合时销毁。
- 实体对象=你的有界上下文的二级标识符
值对象没有标识,在根聚合或实体的实例中很容易替换。
作为一个例子,让我们以我们的汽车贷款/租赁管理领域的发起有界上下文为例(图 1-9 )。
图 1-9
发起有界上下文中的聚合/实体/值对象
**贷款申请集合是发起有界上下文中的根集合。如果没有贷款申请,在这个有界的上下文中什么都不存在,因此在这个有界的上下文或根聚合中没有主体标识符。
*贷款申请人详细信息实体对象捕获贷款申请的申请人详细信息(人口统计、地址等。).它有自己的标识符(申请人 ID),但没有贷款申请就不能存在,也就是说,在创建贷款申请时,会创建贷款申请人详细信息;同样,当贷款申请被取消时,贷款申请人的详细信息也被删除。
**贷款金额值对象*表示贷款申请的贷款金额。它没有自己的身份,可以在贷款申请聚合实例中替换。
我们在下一章中的参考应用将更详细地介绍所有这些概念,所以如果现在还没有什么意义,不要担心。请注意,我们需要识别集合/实体和值对象。
域规则
领域规则是纯业务规则定义。也被建模为对象,它们帮助聚合在一个有限的上下文范围内执行任何类型的业务逻辑。
在我们的 Originations Bounded 上下文中,域规则的一个很好的例子是“State Applicant Compliance Validation”业务规则。该规则基本上规定,根据贷款申请的“州”(例如,CA,NY),额外的验证检查可能适用于贷款申请人。
州申请人合规性验证域规则与贷款聚合一起工作,根据创建贷款申请的州来验证贷款申请,如图 1-10 所示。
图 1-10
始发有界上下文中的域规则
命令/查询
命令和查询代表有界上下文内的任何种类的操作,这些操作或者影响集合/实体的状态,或者查询集合/实体的状态。
如图 1-11 所示,发起有界上下文中的一些命令示例包括“开立贷款账户”和“修改贷款申请人详细信息”,而查询示例包括“查看贷款账户详细信息”和“查看贷款申请人详细信息”
图 1-11
始发有界上下文中的命令/查询
事件
事件捕获有界上下文中聚合或实体的任何类型的状态变化。如图 1-12 所示。
图 1-12
发起有界上下文中的事件
萨迦
DDD 模型的最后一个方面是在你的业务领域内清除任何种类的业务流程/工作流。在 DDD 的术语中,这些被称为传奇。如上所述,sagas 是唯一不局限于单个有界上下文的工件,并且可能跨越多个有界上下文,并且在大多数情况下,它将跨越多个有界上下文。
有界上下文或者具体地说有界上下文内的集合充当传奇参与者。Sagas 对跨越有界上下文的多个业务事件做出反应,并通过协调这些有界上下文之间的交互来"编排业务流程。
让我们来看一个汽车金融业务领域的传奇案例——开立贷款账户。
如果我们为贷款账户的开立制定业务流程
-
客户向 X 汽车金融公司提出 贷款申请 购买新车。
-
x 汽车金融公司验证贷款申请详细信息,以确定最适合客户的贷款产品。
-
x 汽车金融公司要么批准贷款申请,要么拒绝贷款申请。
-
如果贷款申请获得批准,X 汽车金融公司向客户提供贷款产品条款,包括利率、期限等。
-
客户接受贷款产品条款。
-
x 汽车金融公司受理贷款申请后进行审批。
-
x 汽车金融公司为客户开立新的 贷款账户 。
很明显,这个业务流程涉及多个有界上下文,也就是说,它从发起有界上下文(批准贷款申请)开始,在服务有界上下文(开立贷款帐户)中结束。这如图 1-13 所示。
图 1-13
贷款开户传奇
我们现在已经为我们的业务领域建立了一个领域模型,并准备实现它。
摘要
总结我们的章节
-
我们从建立主要问题空间或我们打算使用 DDD 解决的业务问题开始。
-
一旦建立起来,我们就将问题空间分成多个业务能力或子域。然后,我们开始通过确定有界上下文来进入解决方案空间。
-
最后一部分是通过为有界上下文建立域模型来深入解决方案空间。这包括在每个限定的上下文中识别集合/操作/过程流。**
二、货物跟踪器
货物跟踪项目将作为这本书的主要参考应用。它作为 DDD 技术的参考已经在 DDD 世界流传了很长时间,在本书的过程中,我们将利用各种企业 Java 平台提供的工具/技术和功能来实现它。
货物跟踪应用由从事货物业务的企业使用。它提供了管理货物整个生命周期的能力,包括预订、路线安排、跟踪和处理。该应用旨在供企业运算符、客户和港口处理人员使用。
我们将通过首先建立 一个特定于 DDD 的领域建模过程 来为本章中我们随后的 DDD 实现打下基础。建模过程的意图是捕捉一组 高级和低级的 DDD 工件 。高级别工件需要的实现程度较低,也就是说,这些是更多的设计概念,只需要最少的物理工件。另一方面,低级工件具有高度的实现,也就是说,它们将是我们实现的实际物理工件。
无论我们是在着手一个基于单片或微服务的架构,这个领域建模过程都是适用的。
核心域
本着真正的 DDD 精神,我们首先声明我们的核心领域/问题空间是货物跟踪,而货物跟踪参考应用解决了这个核心领域/问题空间。
随着核心域的确定,我们接着建立核心域的 DDD 工件。作为该过程的一部分,我们确定了四个主要工件:
-
我们核心领域的子域/有界上下文
-
领域模型
-
领域传奇
-
领域模型服务
图 2-1 说明了领域建模过程。
图 2-1
我们有界环境中的聚合
货物跟踪器:子域/有界上下文
为了识别货物核心领域/问题空间中的各个子域,我们将该领域划分为多个业务领域,每个业务领域被归类为一个子域。
在货物跟踪领域,我们有四个主要业务领域:
-
订舱–该区域涵盖货物订舱的所有方面,包括以下内容:
-
货物预订
-
给货物分配路线
-
货物的变更(例如,预订货物目的地的变更)
-
取消货物
-
-
路线–该区域涵盖货物行程的所有方面,包括以下内容:
-
基于路线规格的货物最优路线分配
-
承运货物的承运人的航程维护(例如,增加新航线)
-
-
搬运——随着货物沿着其指定路线前进,需要在各个转运港进行检查/搬运。该区域涵盖所有与货物装卸活动相关的操作。
-
跟踪–客户需要其预订货物的全面、详细和最新信息。跟踪业务领域提供了这种能力。
这些业务领域中的每一个都可以归类为 DDD 范式中的子领域。虽然识别子域是问题空间识别的一部分,但我们也需要它们的解决方案。正如我们在前一章所看到的,我们使用了有界环境的概念。有界上下文是我们主要问题空间的设计解决方案,每个有界上下文可以有一个或多个映射到它的子域。
对于我们所有的实现,我们假设每个有界上下文被映射到一个子域。
捕获子域的需求与您在构建应用时打算遵循的架构风格无关,无论是整体式应用还是基于微服务的应用。捕获子域的想法是为了确保在练习结束时,我们已经清楚地将核心域划分为不同的业务领域,这些业务领域是独立的,并且可以在特定的业务领域/子域中拥有自己的可识别的业务语言。
图 2-2 将我们的货物跟踪核心域的各个子域显示为一个整体中的模块,也就是说,有界上下文被解决为模块。
图 2-2
货物跟踪器应用的子域作为一个整体中的独立模块
图 2-3 将我们的货物跟踪核心域的各个子域显示为独立的微服务,也就是说,有界上下文被解决为微服务。
图 2-3
货物跟踪器应用的子域作为独立的微服务
这些子域 的设计解决方案是通过 有界上下文 完成的,这些上下文或者被部署为单片架构 中的 模块,或者被部署为我们基于微服务的架构中的 单独的微服务。
为了总结这一部分,使用 业务领域 的概念,我们 将我们的核心域 划分成多个子域,并且 将有界上下文识别为它们的解决方案 。根据我们正在开发的解决方案的类型,有界上下文的设计是不同的。在整体架构的环境中,它们被 实现为模块, 而在微服务架构的环境中,它们被 实现为独立的微服务 。这里要注意的一点是,我们的有界上下文的设计实现是基于我们最初的决定,即每个子域映射一个有界上下文。在某些情况下,在单片架构的情况下,在相同的有界环境中解决多个模块,以及在基于微服务的架构的情况下,在相同的有界环境中解决多个微服务是非常常见和必要的。有界的上下文是最终的解决方案。
下一步是现在用 为每个有界上下文 捕获域模型。
货物跟踪系统:领域模型
有界上下文的域模型是任何基于 DDD 的架构的基础部分,用于表达有界上下文的业务意图。领域模型的识别包括两组主要的工件:
-
核心域模型——聚合、聚合标识符、实体和值对象
-
域模型操作–命令、查询和事件
总计
设计领域模型的最基本和最重要的方面是识别有界上下文 中的 集合。聚合定义了有界上下文中的一致性范围,也就是说,聚合由一个根实体和一组实体/值对象组成。您可以将聚合视为一个单元,其中任何操作都会整体更新聚合的状态。聚合负责 捕获所有与有界上下文相关的状态和业务规则 。
图 2-4 说明了货物跟踪器的有界上下文中的集合。
图 2-4
货物跟踪者的有限范围内的集合
聚合的识别有助于建立每个有界上下文的范围。让我们确定每个聚合的聚合标识符。
聚合标识符
每个集合需要使用一个 集合标识符 进行唯一标识。聚合标识符是使用业务密钥实现的。对于货物跟踪器的实现,图 2-5 说明了我们集合的业务关键。
图 2-5
使用业务关键字聚合我们的聚合标识符
每个有界上下文通过一组 关联来表达其域逻辑,这些关联通过实体和值对象 来实现。让我们在货物跟踪应用中识别这些。
实体
有界上下文中的实体有它们自己的身份,但是没有集合就不能存在。除此之外,聚合中的实体不能被替换。让我们看一个例子来帮助定义识别实体的规则。
在货物集合中(预订限制上下文),作为预订过程的一部分,预订员需要指定货物的来源。这被映射为实体对象,也就是说,位置显然具有其自身的身份,但是没有货物集合也不能独立存在。
图 2-6 展示了我们的有界上下文中的实体对象。识别实体的经验法则是确保它们有自己的身份,并且不能在聚合中被替换。
图 2-6
我们实体的一个例子
价值对象
有界上下文中的值对象没有自己的标识,在聚合的任何实例中都是可替换的。
让我们看一个例子来帮助定义识别值对象的规则。
货物集合具有以下值对象:
-
货物的预订金额 。
-
路线规格 (始发地、目的地、目的地到达截止日期)。
-
根据路线规格将货物分配到的 路线 。路线由多段 路程 组成,货物可能会通过这些路程到达目的地。
-
货物的交付进度 对照其指定的路线规格和路线。交付进度提供了关于 路线状态、运输状态、货物的当前航程、货物的最后已知位置、下一预期活动以及货物上发生的最后活动的细节。
让我们浏览一下场景和基本原理,为什么我们将它们作为值对象而不是实体,因为这是一个 重要的领域建模决策 :
-
当一个新的货物被预订,我们将有 一个新的路线规格 、 一个空货物行程、 和 没有交货进度 。
-
当货物被分配一个行程单时, 空载货物行程单 被一个 已分配货物行程单替代。
-
当货物作为其行程的一部分通过多个港口时, 交货 进度在货物集合内被更新和替换。
-
最后,如果客户选择更改货物的交货地点或交货截止日期, 路线规格更改 ,将分配一个 新的货物路线 ,重新计算 交货, 和 预订金额更改。
它们有 没有自己的身份, 并且它们在货物集合体中是 可替换的,因此被建模为价值对象。 那是识别值对象的 经验法则。
图 2-7 展示了添加值对象后货物集合的完整类图。
图 2-7
货物总分类图
让我们看看其他聚合的简化类图,从处理活动开始(图 2-8 )。
图 2-8
处理活动类图
图 2-9 显示了航行总量。
图 2-9
航次总分类图
最后,图 2-10 显示了跟踪活动。
图 2-10
跟踪活动类图
注意
本书的源代码通过包分离展示了核心领域模型。您可以在 www.github.com/apress/practical-ddd-in-enterprise-java
查看源代码,更清楚地了解领域模型内的对象类型。
货物跟踪:领域模型操作
我们已经概述了货物跟踪器的有界上下文,并为它们中的每一个列出了核心域模型。下一步是捕获发生在有界上下文中的域模型操作。
有界上下文中的操作可能是
-
命令 请求有界上下文内的状态改变
-
查询 请求有界上下文的状态
-
事件 通知有界上下文的状态变化
图 2-11 说明了有界上下文中的系统操作。
图 2-11
有界环境中的系统操作
图 2-12 说明了我们的货物跟踪器的有界上下文的域模型操作。
图 2-12
我们的货物跟踪器的有界上下文的域模型操作
萨迦
当我们采用微服务架构风格开发应用时,主要使用 Sagas。微服务应用的分布式本质要求我们实现一种机制来维护可能跨越多个微服务的用例的数据一致性。传奇帮助我们实现这一点。传奇可以通过两种方式实现,要么通过 事件编排 ,要么通过 事件编排:
-
基于编排的传奇的实现很简单,因为参与特定传奇的微服务将直接发起和订阅事件。
-
On the other hand, in orchestration-based Sagas, the lifecycle coordination happens through a central component. This central component is responsible for Saga creation, coordination of the flow across the various Bounded Contexts participating in the Saga, and finally the Saga Termination itself.
图 2-13
货物跟踪应用中的 Sagas
图 2-13 展示了货物跟踪应用中的几个故事。
订舱传奇包括货物订舱、货物运输路线和货物跟踪中的业务操作。它从货物被预订到其后续路线开始,最后以分配给预订货物的跟踪标识符结束。客户使用该跟踪标识符来跟踪货物的进度。
装卸传奇包括货物装卸、检验、索赔和最终结算中的业务操作。它始于货物在港口的处理,货物在港口经过一次航行,并在最终目的地被客户认领,止于货物的最终结算(例如,延迟交货的罚款)。
这两个传奇都跨越多个受限的上下文/微服务,并且在传奇结束时,需要在所有这些受限的上下文中维护事务一致性。
领域模型服务
使用域模型服务有两个主要原因。首先是通过 定义好的接口使 有界上下文的域模型 对外部方 可用。第二个是与外部方交互,无论是 将有界上下文的状态保存到 【数据库】, 将有界上下文的状态改变事件发布到外部 消息代理,还是与其他有界上下文通信。
对于任何有界的上下文,有三种类型的域模型服务:
-
入站服务 其中我们实现了定义良好的接口,使外部各方能够与域模型进行交互
-
出站服务 在这里,我们实现了与外部存储库/其他有界上下文的所有交互
-
应用服务 ,它充当域模型与入站和出站服务之间的外观层
图 2-14 说明了货物跟踪单块中的一组域模型服务。
图 2-14
货物跟踪单块中的域模型服务
图 2-15 展示了货物跟踪微服务中的一组域模型服务。与 monolith 不同,微服务不提供本地 web 界面。
图 2-15
货物跟踪微服务中的领域模型服务
领域模型服务设计
那么我们如何设计这些服务呢?我们可以遵循哪种架构模式来实现这些支持服务?
六边形架构模式非常适合帮助我们建模/设计和实现域模型支持服务。图 2-16 图示六边形建筑格局。
图 2-16
六边形建筑模式
六边形架构使用 端口和 适配器的概念来实现域模型服务。让我们稍微扩展一下这个概念。
六边形架构模式中的端口可以是入站端口或出站端口:
-
入站端口为我们的域模型的业务操作提供了一个接口。这通常是通过应用服务实现的。看(1)。
-
出站端口为我们的域模型所需的技术操作提供了一个接口。域模型使用这些接口来存储或发布来自子域的任何类型的状态。看(2)和(3)。
六边形架构模式中的适配器可以是一个 入站适配器,也可以是一个出站适配器:
-
入站适配器使用入站端口为外部客户机提供使用域模型的能力。这些是通过 REST API、本地 Web API 或事件 API 实现的。
-
出站适配器是特定存储库的出站端口的实现。看看下面的出站适配器。
总而言之,“领域模型”需要一组支持服务,也称为“领域模型服务”这些支持服务使外部客户能够使用我们的域模型,同时也使域模型能够在多个存储库中存储和发布子域的状态。
这些支持服务使用六边形架构模式建模,其中这些服务被映射为“入站/出站端口”或“入站/出站适配器”六边形架构模式使得“领域模型”能够独立于这些支持服务。
这就完成了我们针对 DDD 的设计流程。我们为我们的问题空间计算出了 子域/有界上下文 ,为每一个有界上下文 细化出了 域模型,细化出了在有界上下文内发生的 域模型操作 ,最后得出了域模型所需的 域模型支持服务 。
**无论我们是要遵循微服务架构还是基于整体架构的架构,都要遵循这个设计流程。当我们开始使用企业 Java 空间中可用的工具和技术实现前面确定的 DDD 工件时,我们将扩展设计过程并获得更多的细节。
货物跟踪系统:DDD 实施
接下来的章节将详细描述我们之前识别的 DDD 工件的货物跟踪应用的实现。
作为实施的一部分,我们将设计和开发货物跟踪应用:
-
作为一个总部设在 DDD 的利用 Jakarta EE 平台的整体
-
作为一个基于 DDD 的微服务应用,它利用了 Eclipse MicroProfile 平台
-
作为一个基于 DDD 的利用 Spring Boot 平台的微服务应用
-
作为一个基于 DDD 的微服务应用,使用 Axon 框架
让我们朝着货物跟踪器作为一个整体的第一个实现前进。
摘要
总结我们的章节
-
我们对货物跟踪参考应用进行了概述,并确定了应用的子域/有界上下文。
-
我们清除了货物跟踪器的核心域模型,包括集合、实体和价值对象的标识。我们还建立了与货物跟踪应用相关的领域模型操作和 Sagas。
-
我们通过使用六边形架构模式来确定货物跟踪器的域模型所需的域模型服务,从而结束了这一章。**
三、货物跟踪器:Jakarta EE
我们现在已经有了一个为任何应用建模各种 DDD 工件的过程,并为货物跟踪应用详述了同样的过程。
快速回顾一下
我们将货物跟踪确定为主要问题空间/核心领域,并将货物跟踪应用作为解决这一问题空间的解决方案。
我们确定了货物跟踪应用的各种子域/有界上下文。
我们详细描述了每个有界上下文的域模型,包括集合、实体、值对象和域规则的标识。
我们确定了有界环境中所需的支持领域服务。
我们在有限的上下文中确定了各种操作(命令、查询、事件和故事)。
这结束了我们 DDD 之旅的建模阶段,我们已经准备好了开始实施阶段的所有细节。
我们的书介绍了四个独立的 DDD 实现,并以 Enterprise Java 作为开发这些实现的基础:
-
使用 Java EE 8/Jakarta EE 的整体实现
-
基于 Eclipse MicroProfile 的微服务实现
-
一种基于 Spring Boot 的微服务实现
-
基于使用 Axon 框架的纯播放命令/查询责任分离(CQRS)/事件源(es)设计模式的微服务实施
企业 Java 环境提供了一个巨大的工具、框架和技术生态系统,它将帮助我们实现前面章节中概述的 DDD 概念。
本章详细介绍了我们的货物跟踪应用的第一个 DDD 实现,它使用 Java EE 8 平台作为实现的基础。Cargo Tracker 应用将被设计成一个模块化的整体,我们将把 DDD 工件映射到 Java EE 8 平台中可用的相应实现。
首先,这里是 Java EE 平台的概述。
Java EE 平台
近 20 年来,Java EE(企业版)平台一直是企业应用开发的标准。该平台提出了一组规范,涵盖了企业以可伸缩、安全、健壮和标准的方式构建应用所需的一系列技术能力。
该平台的目标是简化开发人员的体验,使他们能够构建业务功能,同时该平台通过使用应用服务器实现规范来完成系统服务的繁重工作。
在 Oracle 的领导下,该平台得到了广泛的认可和广泛的社区参与,并有近 15 家供应商实现了各种规范。该平台的当前版本是 Java EE 8,Oracle GlassFish Application Server v . 5.0 提供了参考应用。
更名为 Jakarta EE 和前进的道路
2017 年,Oracle 在 IBM 和 Red Hat 的支持下,决定将 Java EE 源代码转移到 Eclipse Foundation 的一个新项目EE4J(Eclipse Enterprise for Java)下。该运动的目的是创建一个更灵活的治理过程,以更快的发布节奏来跟上企业空间中快速发展的技术前景。
EE4J 是 Eclipse Foundation 中的一个顶级项目,所有的 Java EE 源代码、参考实现和 tck(技术兼容性工具包)都被转移到这个项目中。Jakarta EE 平台是 EE4J 下的一个项目,旨在成为未来的平台,取代当前的 Java EE 平台。
简而言之,所有新的规范发布或维护规范发布现在都将在 Jakarta EE 上进行,Java EE 8 将是该平台的最后一个版本。
Jakarta EE 已经看到了众多供应商加入工作委员会的巨大势头,旨在实现栈的现代化,使其与传统企业应用相关,同时与基于云原生/微服务的新架构保持一致。
Jakarta EE 平台的第一个版本旨在成为 Java EE 8 平台的精确复制品,主要关注 Oracle 和 Eclipse Foundation 之间各种规范的传输过程。新 Jakarta EE 平台品牌下的第一个参考实现已经发布为 Eclipse GlassFish 5.1,它被认证为 Java EE 8 兼容。
本章将重点介绍兼容 Java EE 8 的 GlassFish 版本,以及 Jakarta EE 平台上的第一个版本——Eclipse GlassFish 5.1。我们在这一章的目的是在基于传统整体架构风格的货物跟踪参考应用中实现 DDD 概念。
让我们深入了解一下规格。
Jakarta EE 平台规范
Jakarta EE(基于 Java EE 8)规范非常广泛,旨在提供企业构建应用所需的一组标准功能。规范在多个版本中不断发展,功能作为新规范或维护规范被添加或修改。
规格分为两种配置文件——完整配置文件或 Web 配置文件。引入概要文件的概念是为了对应用所需的功能进行分类。对于纯 web 应用,Web Profile 规范提供了所需的一组功能(Java 持久性 API [JPA]、上下文和依赖注入[CDI]、用于 RESTful Web 服务的 Java API[JAX-RS]),而对于可能需要消息传递功能或具有遗留应用集成需求的大型复杂应用,完整的配置文件提供了额外的功能。
就我们的目的而言,Web Profile 足以帮助我们以整体架构风格实现 Cargo Tracker,因此我们将只扩展那组规范。
Jakarta EE 的 Web Profile 规范集(基于 Java EE 8)如图 3-1 所示,按其覆盖的区域分组。访问这些规范的官方网址是 www.oracle.com/technetwork/java/javaee/tech/index.html
。
图 3-1
Jakarta EE 的 Web 概要规范(基于 Java EE 8)
Web 应用技术
Web 应用技术代表了 Jakarta EE 平台中的所有规范,包括
-
HTTP 协议请求/响应处理能力
-
HTML 组件来构建基于浏览器的瘦客户端应用
-
JSON 数据处理能力
小服务程序
Servlets 本质上接收 HTTP(s)请求,处理它们,并将响应发送回客户机,客户机通常是 web 浏览器。servlet 规范是自 Java EE 1.0 以来最重要的规范之一,是 web 应用的基础技术。
许多 web 框架(例如 JavaServer Faces [JSF],Spring Model View Controller [MVC])使用 servlet 作为基础工具包,并抽象它们的用法,也就是说,在使用 web 框架时,直接使用 servlet 是很少见的。
在 Java EE 8 中,该规范的最新版本是 Servlet 4.0,它为 Servlet API 引入了一个主要特性——支持 HTTP/2。传统的 HTTP 请求只有一个请求/响应,而使用 HTTP/2,您可以有一个请求,但服务器可以选择同时提供多个响应,从而优化资源并改善用户体验。
JavaServer Faces
JavaServer Faces 提供了一种基于组件的方法来构建 web 应用。基于服务器端呈现方面,它实现了众所周知的 MVC 模式和清晰的分离。视图通常基于 JSF 的 Facelets 模板技术,模型使用 JSF 支持 Beans 构建,控制器构建在 Servlet API 之上。
由于其坚实的设计原则和规范的稳定性,JSF 得到了广泛的采用,并一直被列为企业客户为其 web 应用采用的顶级 web 框架之一。存在多种实现,包括 Oracle 的 ADF Faces、PrimeFaces 和 BootsFaces。
该规范的最新版本是 JavaServer Faces 2.3。
JavaServer 页面
JavaServer Pages (JSP)是在创建 Java EE 平台时提出的第一种视图技术。JSP 在运行时被翻译成 servlets,帮助在 Java web 应用中创建动态 web 内容。由于优先选择 JSF 作为 Java web 应用的 UI 技术,JSP 不再被广泛使用,并且规范已经很长时间没有更新了。
该规范的最新版本是 JavaServer Pages 2.3。
表达语言
表达式语言(EL)规范有助于访问和操作数据。这被多个规范使用,包括 JSP、JSF 和 CDI。EL 非常强大,被广泛采用。最新的改进包括支持 Java 8 中引入的 lambda 表达式。
该规范的最新版本是 EL 3.0。
JSP 标准标记库(JSTL)
JSTL 提供了一组可以在 JSP 页面中使用的实用程序标签。这些实用程序标签涵盖了迭代/条件/SQL 访问等任务。自从 JSF 出现后,该规范已经有一段时间没有更新了,也不再被广泛使用。
该规范的当前版本是 1.2。
WebSocket 的 Java API
提供该规范是为了在 Java web 应用中集成 WebSockets。该规范详细描述了一个 API,它涵盖了 WebSockets 的服务器端和客户端实现。
该规范在 Java EE 8 中进行了维护,最新版本是 1.1。
JSON 绑定的 Java API
Java EE 8 中引入的一个新规范,它详细描述了一个 API,该 API 提供了一个绑定层来将 Java 对象转换为 JSON 消息,反之亦然。
该规范的第一个版本是 1.0 版。
用于 JSON 处理的 Java API
这个规范提供了一个 API,可以用来访问和操作 JSON 对象。Java EE 8 中规范的最新版本是一个主要版本,有各种增强,比如 JSON 指针、JSON 补丁、JSON 合并补丁和 JSON 收集器。
该规范的当前版本是 1.1 版。
企业应用技术
企业应用技术代表了 Jakarta EE 平台中的所有规范,包括
-
构建企业业务组件
-
业务组件依赖管理/注入功能
-
验证功能
-
事务管理
-
ORM(对象关系映射)功能
企业 Java bean(3.2)
从 Java EE 平台的 v. 1.0 开始,Enterprise JavaBean s(EJB)就提供了一种为企业应用实现服务器端业务逻辑的标准方法。EJB 将开发人员从一堆基础设施问题(例如,事务处理、生命周期管理)中抽象出来,允许他们只关注业务逻辑。作为最受欢迎的规范之一,它确实存在使用起来过于复杂和笨重的问题。该规范经历了重大的转变,去掉了这些标签。作为该规范的最新版本,它为构建业务对象提供了一个极其简单和精简的编程模型。
该规范的最新版本是 3.2 版。
Java 的上下文和依赖注入(2.0)
CDI 是在 Java EE 规范中引入的,通过注入来构建组件并管理其依赖关系。引入该规范是为了将 EJB 限制在外围基础设施的职责上,同时用 CDI Beans 编写核心业务逻辑。随着平台的最新发布,这些基础设施问题现在也可以在 CDI Beans 中编写。CDI 现在已经成为该平台几乎所有其他部分的基础技术,而 EJB 正慢慢地被挤出人们的视线。该规范在 Java EE 8 中发布了一个主要版本,支持异步事件、观察者排序和与 Java 8 流的对齐。
CDI 最强大的方面之一是它提供的扩展框架,用来创建标准规范集目前不支持的功能。
这些能力可能包括以下内容:
-
与新的消息代理(例如 Kafka)集成
-
与非关系型数据存储集成(例如,MongoDB、Cassandra)
-
与新时代云基础架构的集成(例如,AWS S3、Oracle 对象存储)
一些知名的 CDI 扩展包括 Apache DeltaSpike ( https://deltaspike.apache.org/
)和 Arquillian ( http://arquillian.org/
)。
该规范的最新版本是 2.0 版。
Bean 验证
该规范提供了一个 Java API 来实现应用中的验证。该规范在 Java EE 8 中有一个主要版本,支持新类型的验证,集成了新的 Java Time API,等等。
该规范的最新版本是 2.0 版。
Java 持久性 API (JPA)
该规范提供了一个 Java API 来实现 Java 对象和关系数据存储之间的 ORM(对象关系映射)工具。这是一个比较流行的规范,它被广泛采用并有多种实现,其中最著名的是 Hibernate。
该规范的最新版本是 2.2 版。
Java 事务 API (JTA)
该规范提供了一个 Java API 来实现应用中的编程事务功能。API 支持跨多个存储库的分布式事务,这对于需要高事务一致性的 monolith 来说是最重要的方面之一。
该规范的最新版本是 1.2 版。
常见注释
该规范提供了一组注释或标记,帮助容器执行常见任务(例如,资源注入、生命周期管理)。
该规范的最新版本是 1.3 版。
拦截器
该规范帮助开发人员在相关的托管 bean(EJB、CDI)上编写拦截器方法。拦截器通常用于集中式横切关注点,比如审计和日志记录。
该规范的最新版本是 1.2 版。
Jakarta 的网络服务
Web 服务技术代表了 Jakarta EE 平台中涵盖构建企业 REST 服务的所有规范。目前,有一个主要的 API。
用于 RESTful Web 服务的 Java API(JAX-RS)
该规范为开发人员实现 RESTful web 服务提供了一个标准的 Java API。另一个流行的规范,该规范的最新版本发布了一个主要版本,支持反应式客户端和服务器端事件。
该规范的最新版本是 2.1 版。
安全技术
安全技术代表了 Jakarta EE 平台中涵盖保护企业业务组件的所有规范。
Java EE 安全 API (1.0)
Java EE 8 中引入的新规范,为以用户管理为中心的安全实现提供了标准的 Java API。为身份验证管理、身份存储交互和安全上下文实现(检索用户信息)引入了新的 API。
Jakarta EE 规范摘要
这就完成了我们对基于 Java EE 8 的 Jakarta EE 平台规范的高级概述。可以看出,这些规范非常全面,提供了构建企业应用所需的几乎所有功能。该平台还提供了扩展点,以防它不能满足企业的任何特定需求。
最重要的一点是,这些是由多个供应商支持的标准规范,以遵守这些标准。这为企业选择部署平台提供了极大的灵活性。
随着新的治理结构在 Eclipse Foundation 下就位,该平台正在为正在构建的下一代企业应用做准备。
作为模块化整体的货物跟踪器
很长一段时间以来,整体架构风格一直是企业项目的基础。
单片架构的主要关注点如下:
-
强大的事务一致性
-
更易维护
-
集中式数据管理
-
分担责任
随着最近微服务的出现,单片架构的压力无疑越来越大。微服务架构风格在应用的开发、测试和部署方面为团队提供了高度的独立性;但是,在您开始拆除一个整体并将它转移到基于微服务的架构之前,需要采取适当的措施。微服务本质上是分布式系统,这反过来需要在自动化、监控和一致性妥协方面的大量投资。单片对于复杂的商业应用有相当大的价值。
然而,通过借用微服务的概念,尤其是在构建单片应用的领域,单片的架构方法已经发生了变化。这是 DDD 发挥核心作用的地方。我们已经看到,有界上下文帮助我们将特定领域的业务能力划分为独立的" "解决方案领域。 “将这些有界的上下文构建为一个整体中的独立模块,并使用域事件在它们之间进行通信,这有助于我们实现松散耦合,从而实现“”或称为“ 模块化整体。
**走“ 真正模块化 ”或“ 模块化单片 ”之路的优势是,使用 DDD 有助于我们获得拥有单片架构的好处,同时有助于保持一定程度的独立性,这有助于我们在需要时过渡到微服务。
在前面的章节中,我们已经为我们的 Cargo Tracker 应用划分出了我们的业务能力/子域,并用有限的上下文解决了它们。在这一章中,我们将把货物跟踪器应用构造成一个模块化的整体,每个有界的上下文被建模成一个独立的模块。
图 3-2 显示了有界上下文到货物跟踪单块中相应模块的映射。
图 3-2
作为集中数据库上的货物跟踪模块的有界上下文
随着一系列规范的概述和我们对 Cargo Tracker 作为基于 DDD 的模块化整体架构的意图的明确,让我们继续在 Java EE 平台上实现它。
具有 Jakarta EE 的有界上下文
有界的上下文是我们的货物跟踪单块的 DDD 实现的解决方案阶段的起点。每个有界的上下文都将被构造成一个模块,作为它自己独立的可部署的工件。
Cargo Tracker monolith 的主要部署工件将是一个标准的 WAR (Web Archive)文件,它将被部署到一个应用服务器(Eclipse GlassFish)上。如前所述,应用服务器为 Jakarta EE 规范的特定版本(在本例中是 Java EE 8)提供了一个实现。每个有界上下文的部署工件将是一个标准的 JAR (Java Archive)文件,它将被捆绑在 WAR 文件中。
这个 WAR 文件将包含一组 JAR 文件,每个 JAR 文件代表模块/有界上下文。部署架构如图 3-3 所示。
实现有界上下文包括将我们的 DDD 工件逻辑分组到一个可部署的工件中。逻辑分组包括识别一个包结构,我们将各种 DDD 工件放置在这个包结构中,以实现我们对有界上下文的整体解决方案。也就是说,我们不专门使用任何特定的 Java EE 规范来实现有界上下文。我们只是在有限的上下文中为我们的 DDD 工件识别一个良好识别的包结构。
封装结构需要反映我们在第二章中布局的六边形架构(图 2-16 )。
图 3-3 显示了我们的任何有界上下文的包结构。
图 3-3
有界上下文的包结构
让我们扩展一下包的结构。
接口
这个包包含一个有界上下文提供的所有可能的入站服务,按协议分类。
它们有两个主要目的:
-
代表域模型的协议协商(例如,REST API、Web API、WebSocket、FTP)
-
数据的视图适配器(例如,浏览器视图、移动视图)
例如,预订有界上下文提供多种类型的服务。一个例子是货物跟踪应用中的本地 UI 的 Web API,用于为客户预订货物/修改货物以及货物列表。类似地,处理有界上下文为由处理移动应用消费的任何种类的处理操作提供 RESTful API。所有这些服务都是“接口”包的一部分。
包装结构如图 3-4 所示。
图 3-4
接口的封装结构
应用
这个包包含了一个有界上下文的域模型所需要的应用服务。
应用服务类有多种用途:
-
充当输入接口和输出库的端口
-
命令、查询、事件和 Saga 参与者
-
交易启动、控制和终止
-
底层领域模型的集中关注点(例如,日志记录、安全性、指标)
-
数据传输对象转换
-
对其他有界上下文的标注
包装结构如图 3-5 所示。
图 3-5
应用服务的包结构
领域
这个包包含有界上下文的域模型。
以下是我们的有界上下文的核心类:
-
总计
-
实体
-
价值对象
-
域规则
包装结构如图 3-6 所示。
图 3-6
我们的领域模型的包结构
基础设施
该包包含有界上下文的域模型所需的基础设施组件,以与任何外部存储库(例如,关系数据库、NoSQL 数据库、消息队列、事件基础设施)进行通信。
包装结构如图 3-7 所示。
图 3-7
基础设施组件的包装结构
共享内核
有时候,领域模型可能需要在多个有界的上下文中共享。DDD 的共享内核为我们提供了一个健壮的机制来共享领域模型,减少了重复代码的数量。共享内核更容易在一个整体中实现,而不是基于微服务的应用,后者提倡更高水平的独立性。
这确实带来了相当程度的挑战,尽管多个团队需要就领域模型的哪个方面需要在有限的上下文中共享达成一致。
在我们的例子中,在 Cargo Tracker monolith 中,我们将保存由共享内核中的各种有界上下文引发的所有事件(package–events . CDI)。
这如图 3-8 所示。
图 3-8
包含所有 CDI 事件的共享基础架构
现在,我们已经将有界的上下文按照模块整齐地分组到一个包结构中,并且关注点明确分离。
用 Jakarta EE 实现领域模型
我们的核心域模型是我们的有界上下文的中心特征,并且如前所述,有一组与之相关的工件。这些工件的实现是在 Java EE 提供的工具的帮助下完成的。
简单总结一下,我们需要实现的领域模型工件如下:
-
总计
-
实体
-
价值对象
让我们逐一查看这些工件,看看 Java EE 为我们实现这些工件提供了哪些相应的工具。
总计
聚合是我们领域模型的核心。简单回顾一下,我们在每个有界上下文中有四个聚合,如图 3-9 所示。
图 3-9
我们有界环境中的聚合
聚合的实现包括以下几个方面:
-
聚合类实现
-
领域丰富性(业务属性、业务方法)
-
国家建设
-
状态持久性
-
聚合间引用
-
事件
聚合类实现
为了实现我们的根聚合,我们将使用来自 Java EE 框架的 JPA (Java Persistence API)作为主要工具。我们的每个根聚合类都被实现为一个 JPA 实体。JPA 没有提供特定的注释来将特定的类注释为根聚合,所以我们使用 JPA 提供的标准注释"@Entity"
。
货根集合体清单 3-1 如下所示:
package com.practicalddd.cargotracker.booking.domain.model.aggregate;
import javax.persistence.Entity;
@Entity // JPA provided annotation
public class Cargo implements Serializable{
@Id
@GeneratedValue
private Long id; // Surrogate Key
@Embedded //To retain domain richness use an Embedded class instead of the direct Java implementation
private BookingId bookingId // Globally unique identifier of the Cargo Root Aggregate (Booking Id)
}
Listing 3-1.Cargo Root Aggregate
清单 3-2 显示了BookingID
集合标识符:
@Embeddable
public class BookingId implements Serializable{
@Column(name="booking_id", unique=true,updateable=false)
private String id;
public BookingId(){
}
public BookingId(String id){
this.id = id;
}
public String getBookingId(){
return id;
}
Listing 3-2.Booking ID Aggregate Identifier
对于我们的聚合标识符实现,我们选择拥有一个技术/代理键(主键)和一个相应的业务键(唯一键)。业务键传达聚合标识符 clear 的业务意图,即新预订货物的预订标识符,并且是向域模型的消费者公开的键。另一方面,技术键是聚合标识符的纯内部表示,对于聚合间引用等用例非常有用。
JPA 为我们提供了@Id
注释来表示我们的根聚合的主键。
富含结构域的聚集体与贫血的聚集体
DDD 的基本前提是在领域模型中表达和集中领域丰富性,我们的集合构成了我们的领域模型的核心。
聚集应该是领域丰富的,并使用清晰的业务概念传达有界上下文的意图。
一个集合也可能会变得贫血,也就是说,一个只有 getters 和 setters 的集合。这在 DDD 世界被认为是反模式的。
总结
-
贫血的聚集没有给出领域的目的或意图。
-
该模式仅用于捕获属性,在表示数据传输对象而非核心业务对象时最为有用。
-
缺乏活力的聚集导致域逻辑泄漏到周围的服务中,从而污染周围服务的意图。
-
在一段时间内,不完善的聚合会导致不可维护的代码。
我们应该尽可能避免贫血的聚集,并将它们限制在预期的用途上,即纯数据对象。
另一方面,域丰富的聚合,顾名思义,就是丰富。它们根据业务属性和业务方法清楚地表达了它们所代表的子领域的意图。让我们在接下来的章节中对此进行更详细的解释。
业务属性覆盖范围
根聚合应该覆盖有界上下文运行所需的所有业务属性。这些属性应该用业务术语而不是技术术语来建模。
让我们看一下我们的货物根集合的例子。
一个货物会有
-
原始位置
-
预订金额
-
路线说明(起点位置/目的地位置/目的地到达期限)
-
旅行路线
-
交付进度
货物根聚合类将这些作为单独的类捕获到主聚合类中。
货物根集合的清单 3-3 演示了这些注释:
@ManyToOne // JPA Provided annotation
private Location origin;
@Embedded // JPA Provided annotation
private CargoBookingAmount bookingAmount;
@Embedded // JPA Provided annotation
private RouteSpecification routeSpecification;
@Embedded // JPA Provided annotation
private Itinerary itinerary;
@Embedded // JPA Provided annotation
private Delivery delivery;
Listing 3-3.Cargo root aggregate - Business attribute coverage
注意我们如何使用业务术语来表达这些依赖类,它们清楚地表达了货物根集合的意图。
Java Persistence API (JPA)为我们提供了一组结构化(例如,嵌入/可嵌入)和关系(例如,ManyToOne)注释,它们有助于在纯业务概念中定义根聚合类。
关联的类被建模为实体对象或值对象。我们将在后面详述这些概念;但是快速总结一下,有界上下文中的实体对象有它们自己的身份,但是总是存在于根聚合中,也就是说,它们不能独立存在,并且在聚合的整个生命周期中从不改变。另一方面,值对象没有自己的身份,在聚合的任何实例中很容易被替换。
商业方法覆盖面
聚合的另一个重要方面是通过业务方法表达领域逻辑。这增加了在 DDD 世界中最重要的领域丰富性。
聚合需要捕获特定子域运行所需的域逻辑。例如,当我们请求装载一个货物集合时,该货物集合应该获得其交付进度并呈现给消费者。这应该通过聚合中的域方法,而不是在支持层中实现。
业务方法在聚合中作为简单方法实现,并与聚合的当前状态一起工作。清单 3-4 展示了一些商业方法的概念。注意聚合如何处理这个域逻辑,而不是支持层:
public class Cargo{
public void deriveDeliveryProgress() {
//Implementation goes here
}
public void assignToRoute(Itinerary itinerary){
//implementation goes here
}
}
Listing 3-4.Cargo root aggregate - Business methods
关于完整的实现,请参考本章的源代码。
聚集态结构
聚合状态构造可以针对新的聚合,也可以在我们必须加载现有聚合时进行。
创建新的聚合就像在 JPA 实体类上使用构造函数一样简单。清单 3-5 显示了创建我们的货物根集合类的新实例的构造函数:
public Cargo(BookingId bookingId, RouteSpecification routeSpecification) {
this.bookingId = bookingId;
this.origin = routeSpecification.getOrigin();
this.routeSpecification = routeSpecification;
}
Listing 3-5.Cargo root aggregate construction
创建新聚合的另一种机制是使用工厂设计模式,也就是说,利用静态工厂返回新的聚合。
在我们的处理有界上下文中,我们根据正在执行的活动的类型来构造处理活动根集合。某些装卸作业类型不需要航行。当客户认领货物时,相应的处理活动不需要航行。然而,当货物在港口被卸载时,相关的处理活动要求航行。因此,创建各种类型的处理活动集合的工厂是这里推荐的方法。
清单 3-6 显示了一个创建处理活动集合实例的工厂。工厂类是使用 CDI Bean 实现的,而聚合实例是使用常规构造函数创建的:
package com.practicalddd.cargotracker.handling.domain.model.aggregate;
@ApplicationScoped // CDI scope of the factory (Application scope indicates a single instance at the application level)
public class HandlingActivityFactory implements Serializable{
public HandlingActivity createHandlingActivity(Date registrationTime,
Date completionTime, BookingId bookingId,
VoyageNumber voyageNumber, UnLocode unlocode,
HandlingActivity.Type type){
if (voyage == null) {
return new HandlingActivity(cargo, completionTime,
registrationTime, type, location);
} else {
return new HandlingActivity(cargo, completionTime,
registrationTime, type, location, voyage);
}
}
}
Listing 3-6.Handling Activity root aggregate
可以通过两种方式加载现有聚合,即获取聚合的状态:
-
源自域,我们通过直接从数据存储区加载聚合的当前状态来构建聚合状态
-
事件源,其中我们通过加载一个空聚合并重放该特定聚合上发生的所有事件来构建聚合状态
对于我们的整体实现,我们将使用一个状态源聚合。
使用基础设施数据储存库类加载以状态为源的集合,该基础设施数据储存库类接受集合的主标识符,并从数据存储(例如,关系数据库或 NoSQL 数据库)加载集合的整个对象层次,包括其相关实体和值对象。
状态源聚合的加载通常在应用服务中完成(参见下面的应用服务部分)。
清单 3-7 中显示了装载国家采购的货物集合体。这通常放在应用服务中:
Cargo cargo = cargoRepository.find(bookingId);
Listing 3-7.Cargo root aggregate - loading state via repositories
这段代码使用了CargoRepository
基础设施类,它接受一个货物预订 ID 并加载货物的对象层次结构,其中包括货物的预订金额、货物的路线规范、货物的路线以及货物的交付进度。作为这个实现的一部分,我们将使用一个特定于 JPA 的实现(JPACargoRepository
类),它从一个关系数据库中加载货物集合。
快速总结一下
-
可以使用常规构造函数或静态工厂来构造新的聚合。
-
现有的聚合是使用域源构建的,即使用存储库类直接从数据库加载聚合及其对象层次结构状态。
聚合状态持久性
聚合的持久化操作应该只影响该聚合的状态。一般来说,聚合本身不会持久化,而是依赖于存储库来执行这些操作,在本例中是我们的 JPA 存储库。如果需要持久化多个聚合,这将需要在其中一个应用服务类中。
聚合间引用
聚合间引用是存在于有界上下文中的聚合之间的关系。在整体实现中,这些是通过使用 JPA 提供的注释作为关联来实现的。
例如,在我们的处理有界上下文的根聚合处理活动中,我们通过作为连接列的货物的预订 id 与货物建立了多对一的关联。
清单 3-8 显示了关联:
public class HandlingActivity implements Serializable {
@ManyToOne
@JoinColumn(name = "cargo_ id")
@NotNull
private Cargo cargo; // Aggregate reference linked via association
}
Listing 3-8.Root Aggregate associations
因此,每当我们从数据存储中加载一个HandlingActivity
集合时,它对应的货物关联就会通过前面定义的关联进行加载。
可能还有其他方法来设计集合引用,例如,您可以只使用货物集合的主键标识符,即预订 id,并通过服务调用来检索货物的详细信息。或者,您可以存储处理绑定上下文中所需的货物详细信息的子集,并通过拥有货物集合的预订绑定上下文触发的事件获得货物集合变化的通知。
聚合关联的选择总是引起争论。根据纯粹的 DDD 方法,这是完全可以避免的,因为这表示泄漏,并且需要重新查看有界上下文的边界。然而,有时考虑到应用需求(例如,事务一致性)和底层平台的能力(例如,事件基础设施),有必要采用实用的方法。
我们的微服务实现采用了最纯粹的方法,而对于 Cargo Tracker monolith,我们通过 JPA 协会实现聚合引用。
聚合事件
按照真正的 DDD,领域事件总是需要由集合发布。如果事件是由应用的任何其他部分(例如,应用服务类)发布的,则它被视为技术事件,而不是业务领域事件。虽然这个定义还存在争议,但是域事件的定义是源自聚合,因为只有聚合知道状态变化的发生。
Java EE 没有为我们提供任何直接的功能来从构建在 JPA 之上的聚合层发布域事件,所以我们将这部分实现转移到了应用服务层。在接下来的章节中,我们将看到底层工具包提供的功能,它支持从我们的聚合层发布域事件(例如,作为 Spring 框架的一部分,Spring Data Commons 项目为我们提供了注释@DomainEvents
,我们可以将它添加到 JPA 聚合中)。虽然理论上您可以使用EntityListener
类来监听底层聚合的生命周期事件,但它代表的是实体数据的变化,而不是业务事件本身。
图 3-10 显示了我们使用 Java EE 的聚合实现的概要。
图 3-10
聚合实施摘要
实体
有界上下文中的实体有自己的身份,但总是存在于根聚合中,也就是说,它们不能独立存在。在聚合的整个生命周期中,实体对象从不改变。
如第二章所见,在我们的 Booking Bounded 上下文中,我们有一个实体对象——货物的原产地。货物的起始位置在货物的整个生命周期中从不改变,因此是被建模为实体对象的合适候选。
实体对象的实现包括以下几个方面:
-
实体类实现
-
实体-聚合关系
-
实体国家建设
-
实体状态持久性
实体类实现
实体类使用 JPA 提供的标准@Entity 注释作为 JPA 实体单独实现。
Location 实体类包含生成的主键、联合国(UN)位置代码和描述,如清单 3-9 所示:
package com.practicalddd.cargotracker.booking.domain.model.entities;
import com.practicalddd.cargotracker.booking.domain.model.entities.UnLocode;
@Entity
public class Location implements Serializable {
@Id
@GeneratedValue
private Long id;
@Embedded
private UnLocode unLocode;
@NotNull
private String name;
}
Listing 3-9.Location Entity Class
实体标识符使用与聚合标识符相同的概念——技术/代理键和业务键。
实体-聚合关系
实体类与它们的根聚合有很强的关联,也就是说,没有根聚合它们就不能存在。使用标准 JPA 关联注释对根聚合的关联进行建模。
在货物根集合中,位置实体类用于表示货物的起始位置。在没有货物存在的情况下,预订范围内的起点位置不能存在。
清单 3-10 显示了位置实体类和货物根集合之间的关联:
public class Cargo implements Serializable {
@ManyToOne
@JoinColumn(name = "origin_id", updatable = false) //Not the responsibility of Location to update the root aggregate i.e. Cargo
private Location origin;
}
Listing 3-10.Cargo root aggregate associations
实体状态构造/持久性
当在根聚合上执行这些操作时,实体总是只与底层根聚合一起被构造/持久化。
构建货物集合时,始终会构建货物始发地位置。对于持久化来说也是一样,当我们持久化一个新的货物预订时,我们会持久化它的起始位置。
图 3-11 显示了我们使用 Java EE 的实体实现的概要。
图 3-11
实体对象实现概要
价值对象
值对象存在于有界上下文的聚合范围内。它们没有自己的身份,在任何聚合实例中都是可替换的。
重复我们在第二章中看到的例子,在我们的预订限制上下文中,我们有多个值对象,它们是货物根聚合的一部分:
-
货物的路线说明
-
货物的路线
-
货物的交付进度
这些都很容易在我们的货物根集合中替换。让我们看一下场景和为什么我们把它们作为值对象而不是实体的基本原理,因为这是一个重要的领域建模决策:
-
订了新的货,我们会有新的路线规范,空的行程单,没有发货进度。
-
当货物被分配路线时,空的路线值对象被分配的路线对象替换。
-
随着货物在其行程中经过多个港口,交付值对象在根聚合中被更新和替换。
-
最后,如果客户选择更改货物的交货地点或交货截止日期,则路线规格会发生变化,分配新的行程,并更新交货进度。
在每个场景中,很明显这些对象需要在根聚合中被替换,因此它们被建模为值对象。
值对象的实现包括以下几个方面:
-
值对象类实现
-
值对象-聚合关系
-
价值对象结构
-
值对象持久性
值对象类实现
使用 JPA 提供的@Embeddable
注释,值对象被实现为 JPA 可嵌入对象。
由于值对象没有自己的身份,所以它们没有任何主标识符。
清单 3-11 显示了我们的价值对象——RouteSpecification
、Itinerary,
和Delivery
——实现为 JPA 可嵌入对象:
@Embeddable
public class RouteSpecification implements Serializable{
@ManyToOne
@JoinColumn(name = "spec_origin_id", updatable = false)
private Location origin;
@ManyToOne
@JoinColumn(name = "spec_destination_id")
private Location destination;
@Temporal(TemporalType.DATE)
@Column(name = "spec_arrival_deadline")
@NotNull
private LocalDate arrivalDeadline;
}
@Embeddable
public class Delivery implements Serializable{
public static final LocalDate ETA_UNKOWN = null;
public static final HandlingActivity NO_ACTIVITY = new HandlingActivity();
@Enumerated(EnumType.STRING)
@Column(name = "transport_status")
@NotNull
private TransportStatus transportStatus;
@ManyToOne
@JoinColumn(name = "last_known_location_id")
private Location lastKnownLocation;
@ManyToOne
@JoinColumn(name = "current_voyage_id")
private Voyage currentVoyage;
@NotNull
private boolean misdirected;
private LocalDate eta;
@Embedded
private HandlingActivity nextExpectedActivity;
@Column(name = "unloaded_at_dest")
@NotNull
private boolean isUnloadedAtDestination;
@Enumerated(EnumType.STRING)
@Column(name = "routing_status")
@NotNull
private RoutingStatus routingStatus;
@Column(name = "calculated_at")
@NotNull
private LocalDateTime calculatedAt;
@ManyToOne
@JoinColumn(name = "last_event_id")
private HandlingEvent lastEvent;
}
@Embeddable
public class Itinerary implements Serializable{
public static final Itinerary EMPTY_ITINERARY = new Itinerary();
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "booking_id")
@OrderBy("loadTime")
@Size(min = 1)
private List<Leg> legs = Collections.emptyList();
}
Listing 3-11.Delivery Value Objects
值对象-聚合关系
没有根聚合,值对象就不能存在,但是因为它们没有标识符,所以在聚合实例中很容易被替换。
使用 JPA 提供的@Embedded 注释来实现值对象和聚合之间的关联。
清单 3-12 显示了我们的价值对象——RouteSpecification
、Itinerary,
和Delivery
——作为嵌入对象与我们的货物根集合相关联:
@Embedded
private RouteSpecification routeSpecification
;
@Embedded
private Itinerary itinerary;
@Embedded
private Delivery delivery;
Listing 3-12.Cargo root aggregate’s Value Objects
价值对象构造/持久性
当在根聚合上执行这些操作时,值对象总是只与底层根聚合一起被构造/持久化。
当我们预订一个新的货物时,在那个时间点,该集合没有分配路线;如果我们试图获得交付进度,它将显示为空,因为它还没有被路由。注意我们如何在清单 3-13 中的根聚合中映射这些业务概念。我们为我们的货物总量分配一个空行程和一个基于空处理历史的交货快照。
清单 3-13 显示了构建货物根集合时如何创建价值对象路线和交付:
public Cargo(BookingId bookingId, RouteSpecification routeSpecification) {
this.bookingId = bookingId;
this.origin = routeSpecification.getOrigin();
this.routeSpecification = routeSpecification;
this.itinerary = Itinerary.EMPTY_ITINERARY; // Empty Itinerary since the cargo is not routed yet
this.delivery = new Delivery(this.routeSpecification, this.itinerary, HandlingHistory.EMPTY); // Delivery snapshot derived based on an empty handling history since this is a new cargo booking
}
Listing 3-13.Value Objects state construction
图 3-12 总结了我们使用 Jakarta EE 实现的值对象。
图 3-12
值对象实施摘要
域规则
域规则帮助聚合在有界上下文范围内执行任何类型的业务逻辑。虽然这些规则通常会丰富集合的状态,但它们本身并不会保持状态的变化。它们向应用服务呈现新的状态变化,应用服务检查状态变化以采取相应的动作。正如在前一章中所看到的,这些规则可以存在于域模型之内,也可以存在于域模型之外(服务层之内)。
业务规则(在域模型中)通常作为私有例程在值对象中找到自己的位置。我们通过一个例子来说明这一点。
货物根集合总是与一个Delivery
值对象相关联。当这三者中的任何一个发生变化时,即当为货物指定新的路线时,货物被分配到一个路线;或者在处理货物时,必须重新计算交货进度。让我们来看看清单 3-14 中Delivery
值对象的构造函数:
public Delivery(HandlingEvent lastEvent, Itinerary itinerary,
RouteSpecification routeSpecification) {
this.calculatedAt = new Date();
this.lastEvent = lastEvent;
this.misdirected = calculateMisdirectionStatus(itinerary);
this.routingStatus = calculateRoutingStatus(itinerary,
routeSpecification);
this.transportStatus = calculateTransportStatus();
this.lastKnownLocation = calculateLastKnownLocation();
this.currentVoyage = calculateCurrentVoyage();
this.eta = calculateEta(itinerary);
this.nextExpectedActivity = calculateNextExpectedActivity(
routeSpecification, itinerary);
this.isUnloadedAtDestination = calculateUnloadedAtDestination(routeSpecification);
}
Listing 3-14.Delivery value object
这些计算是域规则,它们检查聚合的当前状态,以确定聚合的下一个状态。
同样,正如我们之前看到的,域模型中的域规则的执行只依赖于现有的聚合状态。如果该规则需要聚合状态之外的数据,则域规则应该被推送到服务层。
我们的实施总结如图 3-13 所示。
图 3-13
域规则实施摘要
命令
有界上下文中的命令是改变聚集状态的任何操作。Java EE 没有为我们提供任何表示命令操作的特定内容,所以在我们的实现中,这是跨应用服务和域模型的。域模型部分改变聚集状态,而应用服务保存这些改变。
对于命令更改目的地,这包括将货物根集合与新的路线规范和新的交付状态相关联。
清单 3-15 显示了货物根集合中的实现部分:
public void specifyNewRoute(RouteSpecification routeSpecification) {
Validate.notNull(routeSpecification, "Route specification is required");
this.routeSpecification = routeSpecification;
this.delivery = delivery.updateOnRouting(this.routeSpecification,
this.itinerary);
}
Listing 3-15.Command example within the Cargo root aggregate
清单 3-16 显示了预订绑定上下文的应用服务中的实现部分:
public void changeDestination(BookingId bookingId, UnLocode unLocode) {
Cargo cargo = cargoRepository.find(bookingId);
Location newDestination = locationRepository.find(unLocode);
RouteSpecification routeSpecification = new RouteSpecification(
cargo.getOrigin(), newDestination,
cargo.getRouteSpecification().getArrivalDeadline());
cargo.specifyNewRoute(routeSpecification); //Call to domain model
cargoRepository.store(cargo); //Store the State
}
Listing 3-16.Command example within the Cargo root aggregate
我们的实施总结如图 3-14 所示。
图 3-14
命令执行摘要
问题
有界上下文中的查询是检索聚集状态的任何操作。JPA 为我们提供了命名查询,我们可以在聚合 JPA 实体上标记这些查询来查询聚合的状态。JPA 存储库可以使用命名查询来检索聚合的状态。
清单 3-17 显示了命名查询在货物根集合中的用法。我们得到了用于查找所有货物、通过特定预订 id 查找货物以及最终获得所有预订 id 的指定查询:
@Entity
@NamedQueries({
@NamedQuery(name = "Cargo.findAll",
query = "Select c from Cargo c"),
@NamedQuery(name = "Cargo.findByBookingId",
query = "Select c from Cargo c where c.bookingId = :bookingId"),
@NamedQuery(name = "Cargo.getAllBookingIds",
query = "Select c.bookingId from Cargo c") })
public class Cargo implements Serializable {}
Listing 3-17.Named queries within the Cargo root aggregate
清单 3-18 显示了在 Cargo JPA 存储库中命名查询findByBookingId
的用法:
@Override
public Cargo find(BookingId bookingId) {
Cargo cargo;
try {
cargo = entityManager.createNamedQuery("Cargo.findByBookingId",
Cargo.class)
.setParameter("bookingId", bookingId)
.getSingleResult();
} catch (NoResultException e) {
logger.log(Level.FINE, "Find called on non-existant Booking ID.", e);
cargo = null;
}
return cargo;
}
Listing 3-18.Named queries within the Cargo root aggregate
我们的实施总结如图 3-15 所示。
图 3-15
查询实施摘要
用 Jakarta EE 实现领域模型服务
如前所述,核心领域模型需要三种类型的支持服务。
入站服务
入站服务(或六角形架构模式中表示的入站适配器)充当我们的核心域模型的最外层网关。
在 Cargo Tracker 中,我们根据域模型的消费者类型实现了两种类型的入站服务:
-
使用 RESTful web 服务实现的 HTTP API
-
使用 JSF (JavaServer Faces)管理的 Beans 实现的本机 Web API
我们可以根据需要支持的协议对额外的入站服务/适配器进行分类,例如,我们可以使用基于 WebSocket 的入站服务进行实时更新,或者使用基于文件的入站服务进行批量上传。所有这些协议都应该被建模为入站服务的一部分。
让我们以 Cargo Tracker 应用中的每种入站服务为例,看看它们是如何使用 Jakarta EE 实现的。
RESTful API
Jakarta EE 提供了使用 JAX-RS 规范实现 RESTful API 的能力。该规范是平台中使用最广泛的规范之一。
清单 3-19 中显示了一个使用 JAX-RS 的货物跟踪应用中的 RESTful API 示例:
package com.practicalddd.cargotracker.handling.interfaces.rest;
@Path("/handling")
public class HandlingService{
@POST
@Path("/reports")
@Consumes("application/json")
public void submitReport(@NotNull @Valid HandlingReport handlingReport){
}
}
Listing 3-19.REST API example
这个 RESTful API 作为处理绑定上下文的一部分公开,并在中转港处理货物的处理。API 的路径在/handling/reports;它使用一个 JSON 结构,并且是一个 POST 请求,JAX-RS 支持的典型 RESTful 结构。
本机 Web API
第二种类型的入站服务是通过本地 Web API 实现的。Cargo Admin Web 界面是一个基于浏览器的瘦界面,使用 JavaServer Faces (JSF)实现,JavaServer Faces 是在 Jakarta EE 平台中构建 HTML 应用的标准。
JSF 基于流行的 MVC(模型视图控制器),使用基于 CDI(组件依赖注入)的 JSF 托管 Beans 实现模型。该模型充当 web 接口的入站服务层。
清单 3-20 显示了使用 JSF/CDI 的货物跟踪应用中的本地 Web API 示例:
package com.practicalddd.cargotracker.booking.interfaces.web;
@Named //Name of the bean
@RequestScoped //Scope of the bean
public class CargoAdmin {
public String bookCargo() {
//Invoke the domain model to book a new cargo
}
}
Listing 3-20.Web API example
Cargo Admin 类是使用 JSF 和 CDI Beans 作为 Web API 实现的。它将一组操作(例如,bookCargo)暴露给货物管理 Web 界面,职员使用该界面来执行各种操作(例如,货物的预订)。Cargo Admin Web 界面可以调用这些 CDI Beans 上的操作。
实施总结如图 3-16 所示。
图 3-16
入站服务实施摘要
应用服务程序
应用服务是使用 Jakarta EE 平台中可用的 CDI(组件依赖注入)组件构建的。虽然我们前面已经谈到了 CDI 的主题,但是我们并没有深入讨论它的很多细节。现在让我们来谈谈这一方面。
CDI 最初是在 Java EE 6.0 中引入的,它已经有效地取代 EJB,成为 Jakarta EE 平台中构建业务组件的事实上的工具。CDI 通过支持类型安全依赖注入来管理组件的生命周期和交互。此外,CDI 提供了全面的 SPI(服务提供商框架),允许在 Jakarta EE 平台内构建和集成可移植扩展。
使用 CDI 构建应用服务包括以下步骤。我们将以货物预订申请服务为例:
-
使用应用服务提供的操作创建一个常规的 Java 接口。清单 3-21 展示了这一点:
-
提供用特定于 CDI 的批注标记的接口的实现。CDI 注释通常用于给组件提供一个范围和一个名称(在一个特定接口有多个实现的情况下)。
我们提供了预订服务接口的实现,并给它一个应用范围,即整个应用的单个实例。
清单 3-22 演示了这一点:
package com.practicalddd.cargotracker.booking.application;
public interface BookingService {
BookingId bookNewCargo(UnLocode origin, UnLocode destination, LocalDate arrivalDeadline);
List<Itinerary> requestPossibleRoutesForCargo(BookindId bookingId);
void assignCargoToRoute(Itinerary itinerary, BookingId bookingId);
void changeDestination(BookingId bookingId, UnLocode unLocode);
}
Listing 3-21.Booking Application services interface
-
一个应用服务将有一组依赖项,例如,它需要访问存储库基础结构类来检索作为特定操作一部分的聚合细节。清单 3-6 确实简单地提到了这一点,其中在应用服务中,我们使用了一个货物存储库类来加载货物集合。通过 CDI“Inject”注释,为应用服务类提供了对货物储存库类的依赖
清单 3-23 展示了这一点:
package com.practicalddd.cargotracker.booking.application.internal;
@ApplicationScoped //CDI Annotation to determine scope
public class DefaultBookingService implements BookingService {
BookingId bookNewCargo(UnLocode origin, UnLocode destination, LocalDate arrivalDeadline){
//Implementation provided here
}
List<Itinerary> requestPossibleRoutesForCargo(BookindId bookingId){
//Implementation provided here
}
void assignCargoToRoute(Itinerary itinerary, BookingId bookingId){
//Implementation provided here
}
void changeDestination(BookingId bookingId, UnLocode unLocode){
//Implementation provided here
}
}
Listing 3-22.Booking Application services implementation
package com.practicalddd.cargotracker.booking.application.internal;
@Inject //Inject the dependency of the Cargo Repository infrastructure
private CargoRepository cargoRepository;
@ApplicationScoped //CDI Annotation to determine scope
public class DefaultBookingService implements
BookingService {
@Override
public List<Itinerary> requestPossibleRoutesForCargo(BookingId bookingId) {
Cargo cargo = cargoRepository.find(bookingId); //Use the Cargo Repository
//Subsequent implemtnation here.
}
}
Listing 3-23.Booking Application services dependencies
实施总结如图 3-17 所示。
图 3-17
应用服务实施摘要
应用服务:事件
正如我们前面所说的,真正意义上的 DDD 内部的领域事件必须由集合体产生。由于 Jakarta EE 没有为我们提供一种通过聚合生成域事件的机制,我们需要将这种能力推送到应用服务中。
事件基础架构基于 CDI 事件。它是在 CDI 2.0 中引入的,提供了一个非常简洁的事件通知/观察者模型的实现。这导致了各种有界上下文之间的松散耦合,这反过来又帮助我们实现了我们想要的真正模块化整体设计。
CDI 事件模型如图 3-18 所示。
图 3-18
CDI 事件模型
CDI 事件总线不是专用的事件总线;它是 Observer 模式的内部实现,在容器中实现,具有增强的支持,包括事务性观察器、条件性观察器、排序以及同步和异步事件。
让我们看一下货物跟踪应用中的一个实现。作为处理活动的一部分,每次检查货物时,处理有界上下文都会触发一个“货物检查”事件。跟踪有界上下文观察该事件,并相应地更新货物的跟踪进度。
流程如图 3-19 所示。
图 3-19
事件在处理有界上下文和跟踪有界上下文之间流动
在实际实施方面,遵循以下步骤:
-
创建一个事件类(通过原型)。
清单 3-24 演示了这一点。我们创建一个“货物检查”事件类:
-
炒事件。
清单 3-25 展示了从应用服务触发“货物检查”事件,在本例中,货物检查应用服务:
package com.practicalddd.cargotracker.handling.infrastructure.events.cdi;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.inject.Qualifier;
@Qualifier // Stereotype annotations
@Retention(RUNTIME) //Stereotype annotations
@Target({FIELD, PARAMETER}) //Stereotype annotations
public @interface CargoInspected { //Event
}
Listing 3-24.Cargo Inspected event class stereotype
-
观察事件。
跟踪有界环境中的货物跟踪服务观察该事件,并相应地更新货物的跟踪进度。
清单 3-26 演示了这一点:
package com.practicalddd.cargotracker.handling.application.internal;
import javax.enterprise.event.Event;
import javax.inject.Inject;
import com.practicalddd.cargotracker.infrastructure.events.cdi.CargoInspected; //Import the stereotype event class
public class DefaultCargoInspectionService implements CargoInspectionService{
@Inject
@CargoInspected
private Event<Cargo> cargoInspected; //The event that will get fired. The payload is the Cargo aggregate
/**
* Method which will process the inspection of the cargo and fire a subsequent event
*/
public void inspectCargo(BookingId bookingId) {
//Load the Cargo
Cargo cargo = cargoRepository.find(bookingId);
//Process the inspection
// Fire the event post inspection
cargoInspected.fire(cargo);
}
}
Listing 3-25.Firing the cargo inspected event
package com.practicalddd.cargotracker.tracking.application.internal;
import javax.enterprise.event.Event;
import javax.inject.Inject;
import com.practicalddd.cargotracker.infrastructure.events.cdi.CargoInspected;
public class DefaultTrackingService implements TrackingService{
@Inject
@CargoInspected
private Event<Cargo> cargoInspected; //Subscription to the event
/**
* Method which observes the CDI event and processes the payload
*/
public void onCargoInspected(@Observes @CargoInspected Cargo cargo) {
//Process the event
}
}
Listing 3-26.Observing the Cargo Inspected event
实施总结如图 3-20 所示。
图 3-20
应用服务事件实施摘要
出站服务
在 Cargo Tracker monolith 中,我们主要使用出站服务与底层数据库存储库进行通信。出站服务被实现为“存储库”类,是基础设施层的一部分。
存储库类使用 JPA 构建,并使用 CDI 进行生命周期管理。JPA 提供了一个名为“EntityManager”的托管资源,它抽象了数据库配置细节(例如,数据源)。
存储库类通常围绕特定的聚合,并处理该聚合的所有数据库操作,包括以下内容:
-
新聚集及其关联的持久性
-
更新聚合及其关联
-
查询聚合及其关联
清单 3-27 展示了一个存储库类 JPACargoRepository 的例子:
package com.practicalddd.cargotracker.booking.infrastructure.persistence.jpa;
//JPA Annotations
import javax.enterprise.context.ApplicationScoped;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
@ApplicationScoped
public class JpaCargoRepository implements CargoRepository, Serializable {
@PersistenceContext
private EntityManager entityManager; //Managed resource used by the Repository class to interact with the database
// Store a cargo
@Override
public void store(Cargo cargo) {
entityManager.persist(cargo);
}
//Find all cargos. Uses a Named Query defined on the Cargo root aggregate. See also Queries
@Override
public List<Cargo> findAll() {
return entityManager.createNamedQuery("Cargo.findAll", Cargo.class)
.getResultList();
}
//Find a specific cargo. Uses a Named Query defined on the Cargo root aggregate
@Override
public Cargo find(BookingId bookingId) {
Cargo cargo;
try {
cargo = entityManager.createNamedQuery("Cargo.findByBookingId",
Cargo.class)
.setParameter("bookingId", bookingId)
.getSingleResult();
} catch (NoResultException e) {
logger.log(Level.FINE, "Find called on non-existant Booking ID.", e);
cargo = null;
}
return cargo;
}
}
Listing 3-27.Cargo Repository class implementation
实施总结如图 3-21 所示。
图 3-21
出站服务实施摘要
这完成了我们使用 Jakarta EE 8 将货物跟踪器实现为一个模块化的整体。
实施摘要
我们现在有了一个完整的单片货物跟踪应用的 DDD 实现,其中使用 Java EE 中可用的相应规范实现了各种 DDD 工件。
实施总结如图 3-22 所示。
图 3-22
使用 Java EE 的 DDD 工件实现概要
摘要
总结我们的章节
-
我们从建立 Jakarta EE 平台及其提供的各种功能的细节开始。
-
然后,我们使用领域驱动设计,将货物跟踪系统作为一个模块化的整体来实现,并使其背后的决策合理化。
-
我们还深入研究了各种 DDD 工件的开发——首先是域模型,然后是使用 Jakarta EE 平台上可用的技术支持服务的域模型。**
四、货物跟踪器:Eclipse MicroProfile
快速回顾一下
我们将货物跟踪确定为主要问题空间/核心领域,并将货物跟踪应用作为解决这一问题空间的解决方案。
我们确定了货物跟踪应用的各种子域/有界上下文。
我们详细描述了每个有界上下文的域模型,包括集合、实体、值对象和域规则的标识。
我们确定了有界环境中所需的支持领域服务。
我们在有限的上下文中确定了各种操作(命令、查询、事件和故事)。
我们使用 Jakarta EE 实现了一个单一版本的货物跟踪器。
本章详细介绍了使用新的 Eclipse MicroProfile 平台的货物跟踪应用的第二个 DDD 实现。货物跟踪器应用将使用基于微服务的架构进行设计。和以前一样,我们将把 DDD 工件映射到 Eclipse MicroProfile 平台中可用的相应实现。
首先,这里是 Eclipse MicroProfile 平台的概述。
Eclipse 微文件
随着微服务架构风格开始在企业中迅速流行,Java EE 平台迫切需要发展以满足这些需求。受发布流程的限制,再加上 Java EE 平台更侧重于传统的整体应用,一组现有的 Java EE 供应商决定构建一个更优化的平台,适合具有加速发布周期的微服务架构。
这个新平台命名为 MicroProfile,于 2016 年首次发布,并加入了 Eclipse 基金会,因此得名“Eclipse MicroProfile”。MicroProfile 平台的目标是利用 Java EE 平台强大的基础规范,并通过一组特定于云/微服务的规范对其进行增强,从而成为一个由 Jakarta EE 支持的完整微服务平台。
该平台在很短的时间内就获得了广泛的认可和广泛的社区参与,并有近九家供应商实现了各种规范。考虑到微服务领域目前和未来的挑战,这些规范已经非常成熟。
微服务架构风格
微服务架构风格已经迅速成为构建下一代企业应用的基础。微服务架构风格促进了整个软件开发和交付生命周期的独立性,显著加快了企业应用的交付速度。图 4-1 简要总结了微服务的优势。
图 4-1
基于微服务的架构的优势
微服务架构确实有其自身的复杂性。微服务架构固有的分布式特性导致了以下领域的实现复杂性
*** 事务管理
-
数据管理
-
应用模式(例如,报告、审计)
-
部署架构
-
分布式测试
随着这种架构风格变得越来越流行,这些领域正在以各种方式使用开源框架和专有供应商软件来解决。
Eclipse MicroProfile:功能
Eclipse MicroProfile 平台为计划迁移到微服务架构风格的应用提供了一个非常强大的基础平台。加上 DDD 为我们提供了设计和开发微服务的明确流程和模式,这一组合为构建基于微服务的应用提供了一个强大的平台。
在我们深入研究微档案平台的技术组件之前,让我们阐明微服务平台的需求,如图 4-2 所示。
图 4-2
完整微服务平台的要求
这些要求被划分到各个领域,以满足微服务平台的独特要求。
Eclipse MicroProfile 平台提供的一组规范如图 4-3 所示。规格分为两类:
图 4-3
Eclipse 微文件规范
-
核心集 ,有助于迎合云原生/微服务架构风格的特定需求。这些规范提供了配置、健康检查、通信模式、监控、安全性和弹性方面的解决方案。
-
支持套件 有助于满足应用的传统需求,无论是微服务还是单片应用。这包括构建业务逻辑、API 设计、数据管理和数据处理的能力。
与主 Java EE/Jakarta EE 平台不同,MicroProfile 项目中没有概要文件。只有一套规范,任何供应商都需要实现它才能与 MicroProfile 兼容。
下面是当前实现微概要规范的一组供应商。
|实现名称
|
实施版本
|
| --- | --- |
| Helidon (Oracle) | 微概要文件 2.2 |
| SmallRye(社区) | 微概要文件 2.2 |
| 红帽 | 微型文件 3.0 |
| 开放自由(IBM) | 微型文件 3.0 |
| WebSphere Liberty (IBM) | 微型文件 3.0 |
| Payara 服务器(Payara 服务) | 微概要文件 2.2 |
| Payara Micro (Payara 服务) | 微概要文件 2.2 |
| 汤米(阿帕拉契省) | 微档案 2.0 |
| 库穆卢兹 | 微概要文件 2.2 |
让我们深入了解一下规格。我们将首先浏览核心规范,然后是支持集。
Eclipse MicroProfile:核心规范
核心微配置文件规范有助于实现云原生/微服务应用要求的一系列技术问题。这组规范经过精心设计,旨在让希望采用微服务风格的开发人员能够轻松实现这些特性。
从微服务需求映射的角度来看,图 4-4 中所示的阴影框是用核心规范实现的。
图 4-4
映射到微服务需求的 Eclipse MicroProfile 核心规范
让我们浏览一下 MicroProfile 的核心规范集。
Eclipse 微配置文件配置
配置规范定义了一种易于使用的机制,用于实现微服务所需的应用配置。每个微服务都需要某种配置(例如,其他服务 URL 或数据库连接、业务配置、功能标志等资源位置)。根据微服务被部署到的环境(例如,开发、测试、生产),配置信息也可以不同。微服务工件不应该被改变以适应不同的配置模式。
微配置文件配置定义了一种聚合和注入微服务所需的配置信息的标准方式,而不需要重新打包工件。它提供了一种注入缺省配置的机制,该机制通过外部手段(环境变量、Java 命令行变量、容器变量)覆盖缺省配置。
除了注入配置信息,MicroProfile Config 还定义了实现配置源的标准方式,即存储配置信息的存储库。配置源可以是 GIT 存储库或数据库。
该规范的当前版本是 1.3 版。
Eclipse MicroProfile 健康检查
微概要健康检查规范定义了确定微服务的状态和可见性的标准运行时机制。它旨在通过机器对机器机制在集装箱化环境中使用。
该规范的当前版本是 2.0 版。
Eclipse 微文件 JWT 认证
微配置文件 JSON Web Token (JWT)身份验证规范定义了一个标准的安全机制,用于使用 JSON Web Token (JWT)为微服务端点实现身份验证和授权(RBAC[基于角色的访问控制])。
该规范的当前版本是 1.1 版。
Eclipse 微概要度量
MicroProfile Metrics 规范为微服务定义了一种标准机制,以发出可被监控工具识别的度量。
该规范的当前版本是 2.0 版。
eclipse open API 微配置文件
微文件 OpenAPI 规范定义了为微服务生成符合 OpenAPI 的合同/文档的标准机制。
该规范的当前版本是 1.1 版。
Eclipse 微文件 OpenTracing
微文件 OpenTracing 规范定义了在微服务应用中实现分布式跟踪的标准机制。
该规范的当前版本是 1.3 版。
Eclipse 微概要类型安全休息客户端
MicroProfile Rest 客户端规范定义了一种在微服务之间实现 RESTful 调用的标准机制。
该规范的当前版本是 1.3 版。
这完善了 Eclipse MicroProfile 提供的核心规范集。可以看出,这些规范是经过深思熟虑的,提供了一套全面完整的功能来帮助构建基于标准的微服务应用。
Eclipse MicroProfile:支持规范
虽然核心规范集帮助我们实现横切微服务关注点,但是支持规范帮助我们构建微服务的业务逻辑方面。这包括领域模型、API、数据处理和数据管理。
从微服务需求映射的角度来看,如图 4-5 所示的橙色方框是根据支持规范实现的。
图 4-5
Eclipse 微文件支持规范
接下来,我们将快速了解一下支持规范。
Java 的上下文和依赖注入(2.0)
如前一章所述,Java EE 规范中引入了 CDI 来构建一个组件,并通过注入来管理它的依赖关系。CDI 现在已经成为该平台几乎所有其他部分的基础技术,而 EJB 正慢慢地被挤出人们的视线。
该规范的最新版本是 2.0 版。
常见注释
该规范提供了一组注释或标记,帮助运行时容器执行常见任务(例如,资源注入、生命周期管理)。
该规范的最新版本是 1.3 版。
用于 RESTful Web 服务的 Java API(JAX-RS)
该规范为开发人员实现 RESTful web 服务提供了一个标准的 Java API。另一个流行的规范,该规范的最新版本发布了一个主要版本,支持反应式客户端和服务器端事件。
该规范的最新版本是 2.1 版。
JSON 绑定的 Java API
Java EE 8 中引入的一个新规范,它详细描述了一个 API,该 API 提供了一个绑定层来将 Java 对象转换为 JSON 消息,反之亦然。
该规范的第一个版本是 1.0 版。
用于 JSON 处理的 Java API
这个规范提供了一个 API,可以用来访问和操作 JSON 对象。Java EE 8 中规范的最新版本是一个主要版本,有各种增强,比如 JSON 指针、JSON 补丁、JSON 合并补丁和 JSON 收集器。
该规范的当前版本是 1.1 版。
可以看出,该平台没有使用基于编排的 Sagas 为分布式事务管理提供开箱即用的支持。我们需要使用 事件编排 来实现分布式事务。在未来的版本中,MicroProfile 平台正在计划实现 saga 编排模式,作为 MicroProfile LRA(长期运行操作)规范的一部分。
Eclipse 微概要规范概要
这就完成了我们对 Eclipse MicroProfile 平台规范的高级概述。这些规范非常全面,提供了构建想要采用微服务架构风格的企业应用所需的几乎所有功能。该平台还提供了扩展点,以防它不能满足企业的任何特定需求。
就像 Jakarta EE 平台一样,最重要的一点是,这些是由多个供应商支持的标准规范。这为企业选择微服务平台提供了灵活性。
货物跟踪器实现:Eclipse MicroProfile
重申一下,本章的目标是利用领域驱动设计和 Eclipse MicroProfile 平台 将货物跟踪系统 实现为微服务应用。
作为实施的一部分,我们将使用 DDD 作为基础 来帮助我们 设计和开发 我们的微服务。当我们在实现中导航时,我们将使用 Eclipse MicroProfile 平台中可用的相应工具来映射和实现 DDD 工件。
任何基于 DDD 的架构的两个基础部分是域模型和域模型服务。图 4-6 说明了这一点。前一章演示了使用 DDD 来帮助我们实现货物跟踪作为一个整体。本章将演示如何使用 DDD 来帮助我们实现既定的目标,将货物跟踪系统作为一个微服务应用来实施。
图 4-6
DDD 工件实施摘要
由于一些 DDD 工件的实现的共性,可能会有一些与前一章的重复。建议您阅读本章,因为您将使用 Eclipse MicroProfile 从头实现一个微服务项目。
实施选择:Helidon MP
第一步是选择我们将用来实现 DDD 工件的微概要文件实现。如前所述,我们确实有多种实施方案可供选择。对于我们的实现,我们选择使用 Oracle 的 Helidon MP 项目( https://helidon.io
)。
Helidon MP 项目支持 Eclipse MicroProfile 规范;它被设计得易于使用,并且运行在一个快速反应的 web 服务器上,开销非常小。除了支持核心/支持规范集,它还提供了一组扩展,包括对 gRPC、Jedis (Redis 库)、HikariCP(连接池库)和 JTA/JPA 的支持。
图 4-7 概述了 Helidon MP 项目提供的能力。
图 4-7
Helidon MP 对微文件规范和附加扩展的支持
Helidon MP 的当前版本是 1.2,它支持 Eclipse MicroProfile 2.2。
货物跟踪器实现:有界上下文
我们的实现首先从将货物跟踪器分割成一组微服务开始。为此,我们将货物跟踪域划分为一组 业务能力/子域 。图 4-8 展示了货物跟踪领域的业务能力。
- –该业务能力/子域负责与新货物的预订、货物路线的分配以及货物的任何更新(例如,目的地的变更/取消)相关的所有操作。
*** 处理–该业务能力/子域负责与货物航程中各港口的货物处理相关的所有操作。这包括登记货物上的处理活动或检查货物(例如,检查它是否在正确的路线上)。
* ***跟踪***–该业务能力/子域为最终客户提供了一个界面,以准确跟踪货物的进度。
* ***路由***——该业务能力/子域负责所有与调度和路由维护相关的操作。**
虽然 DDD 的子域名在所谓的"****,"问题空间中运行,但我们也需要为它们提出解决方案。我们在 DDD 通过使用 有界上下文 的概念来实现这一点。简单地说,有界上下文在",**"解空间中操作,也就是说,它们代表我们的问题空间的实际解工件。
****对于我们的实现, 有界上下文被建模为包含单个或一组微服务 。这是有意义的,原因显而易见,因为有界上下文提供的 独立性 满足了基于微服务架构所需的基本方面。管理有界上下文状态的所有操作,无论是改变状态的命令、检索状态的查询,还是发布状态的事件,都是微服务的一部分。
从部署的角度来看,如图 4-8 所示,每个绑定的上下文都是一个独立的自包含的可部署单元。可部署单元可以以 fat JAR 文件或 Docker 容器映像 的形式打包。由于 Helidon MP 为 Docker 提供一流的支持,我们将利用它作为我们的包装选择。
微服务将需要一个 数据存储 来存储它们的状态。我们选择按照服务模式 ,采用 数据库,也就是说,我们的每个微服务都将拥有自己独立的数据存储。就像我们的应用层有多种语言的技术选择一样,我们的数据存储也有多种语言的选择。我们可以选择一个普通的关系数据库(如 Oracle、MySQL、PostgreSQL),一个 NoSQL 数据库(如 MongoDB、Cassandra),甚至一个内存中的数据存储(如 Redis)。选择主要取决于微服务想要满足的可伸缩性需求和用例类型。对于我们的实现,我们决定选择 MySQL 作为数据存储。
图 4-8
有界上下文工件
在我们进一步讨论实现之前,这里有一个简短的注释,说明我们将进一步使用的语言。有界语境术语是 DDD 特有的术语,因为这是一本关于 DDD 的书,所以主要使用 DDD 术语是有意义的。这一章也是关于微服务的实现。正如我们已经说过的,这个实现 将一个有界的上下文建模为一个微服务 。因此,我们对术语有界上下文的使用本质上与微服务相同。
有界上下文:打包
将有界的上下文打包包括将我们的 DDD 工件逻辑分组到一个可部署的自给自足的工件中。我们的每个有界上下文都将被构建成一个 Eclipse 微概要应用 。Eclipse MicroProfile 应用是自给自足的,因为它们包含所有的 依赖项、配置和运行时 ,也就是说,它们不需要任何外部依赖项(比如应用服务器)来运行。
图 4-9 展示了 Eclipse MicroProfile 应用的结构。
图 4-9
Eclipse 微文件应用的剖析
Helidon MP 提供了一个 Maven 原型(helidon-quickstart-mp)来帮助我们搭建 Eclipse MicroProfile 应用。清单 4-1 显示了 Helidon MP Maven 命令,我们将使用该命令为 预订有界上下文 生成微文件应用:
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=io.helidon.archetypes -DarchetypeArtifactId=helidon-quickstart-mp -DarchetypeVersion=1.2 -DgroupId=com.practicalddd.cargotracker -DartifactId=bookingms -Dpackage=com.practicalddd.cargotracker.bookingms
Listing 4-1.Helidon MP quickstart archetype
原型生成的源代码包含一个“”主类。main 类包含了一个 Main 方法,当我们运行应用时,这个方法会调用 Helidon MP web 服务器。图 4-10 展示了快速入门原型生成的代码。
**除了 main 类之外,它还生成一个 示例 REST 资源文件(Greeter) 来帮助测试应用, 一个 microprofile 配置文件(micro profile-config . properties)可用于为应用设置配置信息,以及一个用于 CDI 集成的 beans.xml 文件 。
图 4-10
使用 Helidon 原型生成的项目
我们可以用两种方式运行应用:
- 作为 JAR 文件
构建项目将会产生一个 JAR 文件( bookingms.jar )。使用命令“Java-JAR booking ms . JAR”将其作为一个简单的 JAR 文件运行,将在已配置的端口(8080)上调出 Helidon MP 的 web 服务器,并使 Greeter REST 资源在http://<
我们可以使用 curl 实用程序来测试 Greeter REST 资源
curl -X GET http://<<Machine-Name>>:8080/greet
.
这将显示消息“Hello World”。这表明预订微服务 Helidon MP 应用实例作为 JAR 文件正确启动和运行。
- 作为码头工人的形象
Helidon MP 提供的另一种方法是将 MicroProfile 应用作为 Docker 映像来构建和运行。这符合 MicroProfile 应用提供构建云原生应用的能力的原则。
构建 Docker 映像是使用以下命令完成的
docker build -t bookingms.
使用以下命令运行 Docker 映像
docker run --rm -p 8080:8080 bookingms:latest.
我们可以再次使用 curl 实用程序来测试 Greeter REST 资源
curl -X GET http://<<Machine-Name>>:8080/greet.
这将显示消息“Hello World”。这表明预订微服务 Helidon MP 应用实例作为 Docker 映像正常启动和运行。
因为我们的首选方法是使用容器,所以我们将在 Cargo Tracker 应用中构建并运行所有的微服务作为 Docker 映像。
有界上下文:包结构
决定了打包方面之后,下一步是决定我们每个有界上下文的包结构,也就是说,将各种 DDD 微概要文件工件逻辑分组到一个可部署的工件中。逻辑分组包括识别一个包结构,我们在这个包结构中放置各种 DD MicroProfile 工件,以实现我们对有界上下文的整体解决方案。
图 4-11 显示了我们任何有界上下文的高级包结构。
图 4-11
有界上下文的包结构
让我们稍微扩展一下包的结构。
接口
这个包将所有入站接口封装到由通信协议分类的有界上下文中。 接口 的主要用途是代表域模型协商协议(如 REST API(s)、WebSocket(s)、FTP(s)、自定义协议)。
例如,预订有界上下文提供了 REST APIs,用于向其发送****,即** 命令 ,(例如,预订货物命令,将路线分配给货物命令)。同样,Booking Bounded Context 为发送 状态检索请求 提供 REST APIs,即 查询 ,给它(如检索货物订舱明细,列出所有货物)。这被分组到“ 其余的 包中。**
它还有事件处理程序,订阅由其他有界上下文生成的各种事件。所有事件处理程序被分组到“事件处理程序”包中。除了这两个包,接口包还包含了“ 变换 ”包。这用于将传入的 API 资源/事件数据转换为域模型所需的相应命令/查询模型。**
**由于我们需要支持 REST、事件和数据转换,包的结构如图 4-12 所示。
图 4-12
接口的封装结构
应用
应用服务充当有界上下文的域模型的外观。它们提供外观服务,将命令/查询发送到底层的域模型。作为命令/查询处理的一部分,它们也是我们向其他有界上下文发出出站调用的地方。
总而言之,应用服务
-
参与命令和查询调度。
-
作为命令/查询处理的一部分,在必要时调用基础设施组件。
-
为基础领域模型提供集中的关注点(例如,日志、安全性、度量)。
-
对其他有界上下文进行标注。
包装结构如图 4-13 所示。
图 4-13
应用服务的包结构
领域
这个包包含有界上下文的域模型。这是有界上下文的域模型的核心,它包含核心业务逻辑的实现。
我们的有界上下文的核心类如下:
-
总计
-
实体
-
价值对象
-
命令
-
事件
包装结构如图 4-14 所示。
图 4-14
我们的领域模型的包结构
基础设施
基础设施包有四个主要目的:
-
当有界上下文接收到与其状态相关的操作(状态的改变、状态的检索)时,它 需要一个底层知识库来处理操作;在我们的例子中,这个存储库是我们的 MySQL 数据库实例。基础设施包包含有界上下文与底层存储库通信所需的所有必要组件。作为我们实现的一部分,我们打算使用 JPA 或 JDBC 来实现这些组件。
-
当有界上下文需要传递状态变化事件时,它需要底层事件基础结构来发布状态变化事件。在我们的实现中,我们打算使用一个 消息代理作为底层事件基础设施 (RabbitMQ)。基础设施包包含有界上下文与底层消息代理通信所需的所有必要组件。
-
当一个有界上下文需要与另一个有界上下文同步通信时,它需要一个底层基础设施来支持通过 REST 的服务到服务的通信。基础设施包包含有界上下文与其他有界上下文通信所需的所有必要组件。
-
我们在基础设施层中包括的最后一个方面是任何种类的特定于微概要文件的配置。
包装结构如图 4-15 所示。
图 4-15
基础设施组件的包装结构
图 4-16 显示了我们任何有界上下文的整个包结构的完整摘要。
图 4-16
任何有界上下文的包结构
这就完成了我们的货物跟踪微服务应用的有界上下文的实现。我们的每个有界上下文都是作为一个微文件应用实现的,使用 Helidon MP 项目,Docker 图像作为工件。有界的上下文被模块整齐地分组在一个包结构中,具有清晰分离的关注点。
让我们进入货物跟踪应用的实现。
货物跟踪实施
本章的下一节将详细介绍货物跟踪应用的实现,它是一个利用 DDD 和 Eclipse MicroProfile (Helidon MP)的微服务应用。
图 4-17 显示了我们的各种 DDD 工件的逻辑分组的高级概述。正如所见,我们需要实现两组工件:
图 4-17
DDD 工件的逻辑分组
-
域模型 将包含我们的 核心域/业务逻辑
-
域模型服务 ,其中包含 对我们核心域模型 的支持服务
就域模型的实际实现而言,这转化为特定有界上下文/微服务的各种 值对象、命令和查询 。
就域模型服务的实际实现而言,这转化为 、接口 、 应用服务,以及有界上下文/微服务的域模型所需的基础设施 。
回到我们的货物跟踪器应用,图 4-18 从各种有界上下文及其支持的操作方面说明了我们的微服务解决方案。如所见,这包含每个有界上下文将处理 的各种 命令,每个有界上下文将服务的 查询,以及每个有界上下文将订阅/发布 的 事件。每个微服务都是一个独立的可部署工件,有自己的存储。
**
图 4-18
货物跟踪微服务解决方案
注意
某些代码实现将只包含摘要/片段,以帮助理解实现概念。本章的源代码包含这些概念的完整实现。
领域模型:实现
我们的领域模型是我们的有界上下文的中心特征,并且如前所述,有一组与之相关的工件。这些工件的实现是在 Eclipse MicroProfile 提供的工具的帮助下完成的。
简单总结一下,我们需要实现的领域模型工件如下:
-
核心域模型——聚集、实体和值对象
-
域模型操作–命令、查询和事件
让我们逐一查看这些工件,看看 Eclipse Microprofile 为我们实现这些工件提供了哪些相应的工具。
核心领域模型:实现
任何有界上下文的核心域的实现都包括那些将清楚地表达有界上下文的业务意图的工件的识别。在高层次上,这包括聚合、实体和值对象的识别和实现。
聚合/实体/值对象
聚合是我们领域模型的核心。概括地说,我们在每个有界上下文中有四个聚合,如图 4-19 所示。
图 4-19
我们有限的上下文/微服务中的聚合
聚合的实现包括以下几个方面:
-
聚合类实现
-
通过业务属性丰富领域
-
实现实体/值对象
聚合类实现
因为我们打算使用 MySQL 作为每个有界上下文的数据存储,所以我们打算使用 Java EE 规范中的 JPA (Java Persistence API)。JPA 提供了一种定义和实现与底层 SQL 数据存储交互的实体/服务的标准方法。
JPA 集成:Helidon MP
Helidon MP 通过外部整合机制为美国利用 JPA 提供支持。为了包括这种支持,我们需要添加一些额外的配置/依赖关系:
pom.xml
清单 4-2 显示了需要在 Helidon MP 生成的 pom.xml 依赖文件 中进行的更改。
依赖关系列表包括以下内容:
-
Helidon MP JPA 集成支持()heli don-integrations-CDI-JPA)
-
使用 HikariCP 作为我们的数据源连接池机制
-
Java 的 MySQL 驱动库(MySQL-connector-Java)
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-datasource-hikaricp</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-jpa-weld</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-eclipselink</artifactId>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-jta</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.integrations.cdi</groupId>
<artifactId>helidon-integrations-cdi-jta-weld</artifactId>
</dependency>
<dependency>
<groupId>javax.transaction</groupId>
<artifactId>javax.transaction-api</artifactId>
</dependency>
microprofile-config
Listing 4-2.pom.xml dependencies
我们需要为每个 MySQL 数据库实例配置连接属性。清单 4-3 显示了需要添加的配置属性。必要时,您需要用 MySQL 实例的详细信息替换这些值:
javax.sql.DataSource.<<BoundedContext-Name>>.dataSourceClassName=com.mysql.cj.jdbc.MysqlDataSource
javax.sql.DataSource.<<BoundedContext-Name>>.dataSource.url=jdbc:mysql://<<Machine-Name>>:<<Machine-Port>>/<<MySQL-Database-Instance-Name>>
javax.sql.DataSource.<<BoundedContext-Name>>.dataSource.user=<<MySQL-Database-Instance-UserID>>
javax.sql.DataSource.<<BoundedContext-Name>>.dataSource.password=<<MySQL-Database-Instance-Password>>
persistence.xml
Listing 4-3.Configuration information for the datasource connectivity
最后一步是配置一个 JPA " 持久性单元 "映射到在micro profile-config文件中配置的数据源,如清单 4-4 所示:
<persistence version="2.2" xmlns:="http://xmlns.jcp.org/xml/ns/persistence xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="<<BoundedContext-Name>>" transaction-type="JTA">
<jta-data-source><<BoundedContext-Datasource>></jta-data-source>
</persistence-unit>
</persistence>
Listing 4-4.Configuration information for the persistence unit
我们现在准备在我们的微概要应用中实现 JPA。除非另有说明,否则我们所有有界上下文中的所有聚合都实现了相同的机制。
我们的每个根聚合类都被实现为一个 JPA 实体。JPA 没有提供特定的注释来将特定的类作为根聚合进行注释,所以我们采用常规的 POJO 并使用 JPA 提供的标准注释@Entity
。以 Booking Bounded Context 为例,它将 Cargo 作为根聚合,清单 4-5 显示了 JPA 实体所需的最小代码:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.∗;
@Entity //JPA Entity Marker
public class Cargo {
}
Listing 4-5.Cargo root aggregate implemented as a JPA Entity
每个 JPA 实体都需要一个标识符。对于我们的聚合标识符实现,我们选择从 MySQL 序列中为我们的货物聚合生成一个 【技术/代理标识符(主键) 。除了技术标识符,我们还选择拥有一个 业务密钥 。
业务键传达聚合标识符 clear 的业务意图,即新预订货物的预订标识符,并且是向域模型的外部消费者公开的键(稍后将详细介绍)。另一方面,技术关键字是集合标识符的纯内部表示,并且对于在集合及其依赖对象(参见下面的值对象/实体)之间的有界上下文 内维护关系 是有用的。
继续我们在 Booking Bounded 上下文中的 Cargo Aggregate 的例子,我们将技术和业务键添加到类实现中,直到现在。
清单 4-6 演示了这一点。“@ Id”注释标识我们的货物集合上的主键。没有特定的注释来标识业务键,所以我们只是使用 JPA 提供的“@ Embedded ”注释将其实现为常规的 POJO ( BookingId )和Embeddedit:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.∗;
@Entity
public class Cargo {
@Id //Identifier Annotation provided by JPA
@GeneratedValue(strategy = GenerationType.AUTO) // Rely on a MySQL generated sequence
private Long id;
@Embedded //Annotation which enables usage of Business Objects instead of primitive types
private BookingId bookingId; // Business Identifier
}
Listing 4-6.Cargo root aggregate identifier implementation
清单 4-7 显示了 BookingId 业务键类的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.∗;
import java.io.Serializable;
/∗∗
∗ Business Key Identifier for the Cargo Aggregate
∗/
@Embeddable
public class BookingId implements Serializable {
@Column(name="booking_id")
private String bookingId;
public BookingId(){}
public BookingId(String bookingId){this.bookingId = bookingId;}
public String getBookingId(){return this.bookingId;}
}
Listing 4-7.BookingId business key class implementation
我们现在有了一个使用 JPA 的聚合(Cargo)的基本实现。除了处理活动绑定的上下文之外,其他聚合具有相同的实现机制。由于它是一个事件日志,我们决定只为聚合实现一个键,即活动 Id。
图 4-20 总结了我们所有聚合的基本实现。
图 4-20
我们聚合的基本实现
领域丰富性:业务属性
准备好基本的实现后,让我们继续讨论集合的核心部分——域丰富性。 任何有界语境的聚合应该能够清晰地表达有界语境的业务语言 。本质上,用纯技术术语来说,它意味着我们的集合不应该是贫血的,也就是说,只包含 getter/setter 方法。
一个缺乏活力的集合违背了 DDD 的基本原则,因为它本质上意味着 在应用的多个层中表达的商业语言 ,从长远来看,这反过来又会导致一个不可维护的软件。
**那么我们如何实现一个域丰富的聚合呢?简而言之就是 业务属性和业务方式 。在这一节中,我们的重点将放在业务属性方面,而我们将把业务方法部分作为域模型操作实现的一部分。
聚合的业务属性捕获聚合的状态,作为使用业务术语而不是技术术语描述的属性。
让我们看一下我们的货物总量的例子。
将状态转换为业务概念,货物集合具有以下属性:
-
货物的始发地 。
-
货物的预订金额 。
-
路线规格 (始发地、目的地、目的地到达截止日期)。
-
根据路线规格将货物分配到的 路线 。路线由多段 路程 组成,货物可能会通过这些路程到达目的地。
-
货物的交付进度 对照其指定的路线规格和路线。交货进度提供了关于 路线状态、运输状态、货物的当前航程、货物的最后已知位置、下一个预期活动 、 和货物上发生的最后活动 的细节。
图 4-21 显示了货物集合及其与相关对象的关系。请注意我们如何能够在 的纯业务术语 中清楚地表示货物总量。
图 4-21
货物集合及其从属关联
JPA 为我们提供了一组注释 (@Embedded,@ Embedded),帮助我们使用业务对象实现聚合类。
清单 4-8 显示了我们的货物集合的例子,其中所有的 依赖项都被建模为业务对象 :
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.∗;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.∗;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.∗;
@Entity
public class Cargo {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Embedded
private BookingId bookingId; // Aggregate Identifier
@Embedded
private BookingAmount bookingAmount; //Booking Amount
@Embedded
private Location origin; //Origin Location of the Cargo
@Embedded
private RouteSpecification routeSpecification; //Route Specification of the Cargo
@Embedded
private CargoItinerary itinerary; //Itinerary Assigned to the Cargo
@Embedded
private Delivery delivery; // Checks the delivery progress of the cargo against the actual Route Specification and Itinerary
}
Listing 4-8.Business object dependencies for the Cargo root aggregate
聚合的依赖类 建模为实体对象或值对象 。概括地说,有界上下文中的实体对象有自己的身份,但总是存在于根聚合中,也就是说,它们不能独立存在,并且在聚合的整个生命周期中从不改变。另一方面,值对象没有自己的身份,在聚合的任何实例中很容易被替换。
继续我们的示例,在货物集合中,我们有以下内容:
-
【原点】 的货物作为一个 的实体对象 (位置)。这在货物集合实例中不能改变,因此被建模为实体对象。
-
预订金额 的货物, 路线规格 的货物, 的货物行程 分配给货物,而 交付的货物 作为价值对象。这些对象在任何货物集合实例中都是可替换的,因此被建模为值对象。
让我们浏览一下场景和基本原理,为什么我们将它们作为值对象而不是实体,因为这是一个 重要的领域建模决策 :
-
当预订一个新的货物时,我们会有 一个新的路线规格一个空货行程 , 没有交货进度 。
-
当货物被分配一个行程单时, 空载货物行程单 被一个 已分配货物行程单 替代。
-
当货物作为其行程的一部分通过多个港口时, 交付 进度在根集合内被更新和替换。
-
最后,如果客户选择更改货物的交货地点或交货截止日期,则 路线规格更改 ,分配一个 新的货物行程 , 交货重新计算 , 订舱金额更改 。
它们 都是可替换的,因此被建模为值对象 。这就是在聚合 中建模实体和值对象的 经验法则。
实现实体对象/值对象
使用 JPA 提供的" @ embedded "注释,将实体对象/值对象实现为 JPA 可嵌入对象。然后使用“ @Embedded ”注释将它们嵌入到聚合中。
清单 4-9 展示了嵌入到聚合类中的机制。
让我们看一下 Cargo Aggregate 的实体对象/值对象的实现。
清单 4-9 展示了 位置实体对象 。注意分组在 model.entities 下的包名( ):
package com.practicalddd.cargotracker.bookingms.domain.model.entities;
import javax.persistence.Column;
import javax.persistence.Embeddable;
/∗∗
∗ Location Entity class represented by a unique 5-digit UN Location code.
∗/
@Embeddable
public class Location {
@Column(name = "origin_id")
private String unLocCode;
public Location(){}
public Location(String unLocCode){this.unLocCode = unLocCode;}
public void setUnLocCode(String unLocCode){this.unLocCode = unLocCode;}
public String getUnLocCode(){return this.unLocCode;}
}
Listing 4-9.Location entity class implementation
清单 4-10 展示了 预订金额/路线规格值对象 的示例。注意分组在 model.valueobjects 下的包名( ):
package com.practicalddd.cargotracker.bookingms.domain.model.valueobjects;
import javax.persistence.Column;
import javax.persistence.Embeddable;
/∗∗
∗ Domain model representation of the Booking Amount for a new Cargo.
∗ Contains the Booking Amount of the Cargo
∗/
@Embeddable
public class BookingAmount {
@Column(name = "booking_amount", unique = true, updatable= false)
private Integer bookingAmount;
public BookingAmount(){}
public BookingAmount(Integer bookingAmount){this.bookingAmount = bookingAmount;}
public void setBookingAmount(Integer bookingAmount){this.bookingAmount = bookingAmount;}
public Integer getBookingAmount(){return this.bookingAmount;}
}
Listing 4-10.Booking Amount Value object implementation
清单 4-11 展示了 路线规范值对象 :
package com.practicalddd.cargotracker.bookingms.domain.model.valueobjects;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.Location;
import javax.persistence.∗;
import javax.validation.constraints.NotNull;
import java.util.Date;
/∗∗
∗ Route Specification of the Booked Cargo
∗/
@Embeddable
public class RouteSpecification {
private static final long serialVersionUID = 1L;
@Embedded
@AttributeOverride(name = "unLocCode", column = @Column(name = "spec_origin_id"))
private Location origin;
@Embedded
@AttributeOverride(name = "unLocCode", column = @Column(name = "spec_destination_id"))
private Location destination;
@Temporal(TemporalType.DATE)
@Column(name = "spec_arrival_deadline")
@NotNull
private Date arrivalDeadline;
public RouteSpecification() { }
/∗∗
∗ @param origin origin location
∗ @param destination destination location
∗ @param arrivalDeadline arrival deadline
∗/
public RouteSpecification
(Location origin, Location destination,
Date arrivalDeadline) {
this.origin = origin;
this.destination = destination;
this.arrivalDeadline = (Date) arrivalDeadline.clone();
}
public Location getOrigin() {
return origin;
}
public Location getDestination() {
return destination;
}
public Date getArrivalDeadline() {
return new Date(arrivalDeadline.getTime());
}
}
Listing 4-11.Route Specification Value object implementation
其余的值对象( RouteSpecification、Cargo internary、 和 Delivery )使用“@ Embedded”注释以相同的方式实现,并使用“ @Embedded ”注释嵌入到货物聚合中。
注意
完整的实现请参考本章的源代码。
让我们看看其他聚合(处理活动、航行和跟踪)的简化类图。图 4-22 、 4-23 和 4-24 说明了这一点。
图 4-24
跟踪活动及其相关关联
图 4-23
航程及其从属关联
图 4-22
处理活动及其相关关联
这就完成了核心域模型的实现。接下来让我们看看域模型操作的实现。
注意
本书的源代码通过包分离展示了核心领域模型。您可以在github . com/practical DD查看源代码,以便更清楚地了解域模型中的对象类型。
领域模型操作
有界上下文中的域模型操作处理与有界上下文集合的状态相关联的任何种类的操作。这包括更改聚合状态( 命令 )、检索聚合的当前状态( 查询 )或通知聚合状态更改( 事件 )的操作。
命令
命令负责在有界上下文中改变聚集的状态。
在有界环境中实现命令包括以下步骤:
-
命令的识别/执行
-
识别/实现处理命令的命令处理程序
命令的识别
命令的识别围绕着识别影响集合状态的任何操作。例如,预订命令有界上下文具有以下操作或命令:
-
预订货物
-
运送货物
这两种操作都导致有界环境内货物集合体的状态改变,因此被识别为命令。
命令的实现
一旦被识别,使用常规的 POJOs 在微概要实现中实现被识别的命令。清单 4-12 展示了 BookCargoCommand 类对于 Book Cargo 命令的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.commands;
import java.util.Date;
/∗∗
∗ Book Cargo Command class
∗/
public class BookCargoCommand {
private String bookingId;
private int bookingAmount;
private String originLocation;
private String destLocation;
private Date destArrivalDeadline;
public BookCargoCommand(){}
public BookCargoCommand(int bookingAmount,
String originLocation, String destLocation, Date destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingId(String bookingId){this.bookingId = bookingId;}
public String getBookingId(){return this.bookingId;}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 4-12.BookCargoCommand class implementation
命令处理程序的标识
每个命令都有一个相应的命令处理程序。命令处理程序 的作用是处理输入的命令并设置集合的状态。命令处理程序是 域模型中唯一设置聚合状态的地方 。这是一个严格的规则,需要遵循它来帮助实现一个丰富的领域模型。
命令处理程序的实现
由于 Eclipse MicroProfile 平台不提供任何现成的功能来实现命令处理程序,我们的实现方法将是 只识别集合上的例程,这些例程可以表示为命令处理程序 。对于我们的第一个命令 【书货】我们把 构造器标识为我们的命令处理程序;而对于我们的第二个命令我们创建一个新的例程“【assignor Route()”作为我们的命令处理程序。******
******清单 4-13 显示了货物集合构造器的代码片段。构造函数接受 BookCargoCommand 作为输入参数,并设置相应的聚集状态:
/∗∗
∗ Constructor Command Handler for a new Cargo booking
∗/
public Cargo(BookCargoCommand bookCargoCommand){
this.bookingId = new BookingId(bookCargoCommand.getBookingId());
this.routeSpecification = new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline()
);
this.origin = routeSpecification.getOrigin();
this.itinerary = CargoItinerary.EMPTY_ITINERARY; //Empty Itinerary since the Cargo has not been routed yet
this.bookingAmount = bookingAmount;
this.delivery = Delivery.derivedFrom(this.routeSpecification,
this.itinerary, LastCargoHandledEvent.EMPTY);
}
Listing 4-13.BookCargoCommand command handler
清单 4-14 显示了assigno route()命令处理程序的代码片段。它接受RouteCargoCommand类作为输入,并设置聚合的状态:
/∗∗
∗ Command Handler for the Route Cargo Command. Sets the state of the Aggregate and registers the
∗ Cargo routed event
∗ @param routeCargoCommand
∗/
public void assignToRoute(RouteCargoCommand routeCargoCommand) {
this.itinerary = routeCargoCommand.getCargoItinerary();
// Handling consistency within the Cargo aggregate synchronously
this.delivery = delivery.updateOnRouting(this.routeSpecification,
this.itinerary);
}
Listing 4-14.RouteCargoCommand command handler
图 4-25 展示了我们的命令处理程序实现的类图。
图 4-25
命令处理程序实现的类图
总之,命令处理程序在管理有限上下文中的聚合状态方面起着非常重要的作用。命令处理程序的实际调用是通过应用服务进行的,我们将在下面的章节中看到。
问题
有界上下文中的查询负责 向外部消费者提供有界上下文的集合 的状态。
为了实现查询,我们利用 JPA 命名查询 ,即可以定义在聚合上的查询,以各种形式检索状态。清单 4-15 展示了来自 Cargo Aggregate 的代码片段,它定义了需要可用的查询。在这种情况下,我们有三个查询-查找所有货物,通过货物的预订标识符查找货物,以及所有货物的最终预订标识符 :
@NamedQueries({
@NamedQuery(name = "Cargo.findAll",
query = "Select c from Cargo c"),
@NamedQuery(name = "Cargo.findByBookingId",
query = "Select c from Cargo c where c.bookingId = :bookingId"),
@NamedQuery(name = "Cargo.findAllBookingIds",
query = "Select c.bookingId from Cargo c") })
public class Cargo{}
Listing 4-15.Named Queries within the Cargo root aggregate
总之,查询扮演着在有限的上下文中呈现聚合状态的角色。这些查询的实际调用和执行是通过应用服务和存储库类进行的,我们将在接下来的章节中看到。
这就完成了域模型中查询的实现。我们现在将看到如何实现事件。
事件
有界上下文中的事件是将 有界上下文的聚集状态改变为事件 的任何操作。由于命令会改变聚合的状态,因此可以有把握地假设在有界上下文中的任何命令操作都会导致相应的事件。这些事件的订阅者可以是同一域内的其他有界上下文,也可以是属于任何其他外部域的有界上下文。
域事件在微服务架构中扮演着核心角色,以健壮的方式实现它们非常重要。微服务架构的分布式本质要求通过 编排机制 到 使用事件,在基于微服务的应用的各种有界上下文之间保持状态和事务一致性 。
图 4-26 举例说明了在货物跟踪应用的各种有界上下文之间流动的事件。
图 4-26
微服务架构中的事件流
让我们用一个商业案例来解释这一点。
当货物被分配路线时,这意味着货物现在可以被跟踪,这需要向货物发出跟踪标识符。货物路线的分配是在预订有界环境中处理的,而跟踪标识符的发布是在跟踪有界环境中处理的。在整体式工作方式中,为货物分配路线和发布跟踪标识符的过程同时发生,因为 由于流程、运行时和数据存储 的共享模型,我们可以跨多个有界上下文维护相同的事务上下文。
然而,在微服务架构中,这是不可能实现的,因为它是一个 无共享架构 。当货物被分配一条路线时,Booking Bounded 上下文只负责确保货物集合的状态反映新的路线。跟踪有界上下文需要知道这种状态的变化,以便它能够相应地发布跟踪标识符,从而 完成业务用例 。这就是 域事件和 事件编排发挥重要作用的地方。如果货物绑定上下文可以引发货物集合已经被分配了路线的事件,则跟踪绑定上下文可以订阅该特定事件并发布跟踪标识符来完成该业务用例。 引发事件 和 将事件 传递到各种有界上下文来完成一个业务用例的机制是 事件 编排模式 。简而言之,领域事件/事件编排有助于构建事件驱动的微服务应用。
实现健壮的事件驱动的编排架构有四个阶段:
-
注册 需要从有界上下文中提出的域事件。
-
从有界上下文中提出 需要发布的领域事件。
-
发布 从有界上下文中引发的事件。
-
订阅 其他有界上下文中已经发布的事件。
考虑到 MicroProfile 平台提供的功能,实现被划分到多个领域:
-
事件的引发由 应用服务 实现。
-
事件的发布由出站服务 实现 。
-
订阅事件由接口/入站服务 处理 。
作为我们实现领域事件的一部分,我们将使用 CDI 事件作为逻辑基础设施 用于事件发布/订阅,同时我们将使用 RabbitMQ 为我们提供物理基础设施 来实现事件编排。
由于我们正处于实现领域模型的阶段,所以我们将在本节中涉及的唯一领域是创建事件类,这些事件类跨多个有界上下文参与编排。本章的后续部分将处理其他方面 (应用服务将涵盖这些事件的引发的实现,出站服务将涵盖事件的发布的实现,入站服务将涵盖事件的订阅的实现) 。
使用“ @interface ”注释将域模型中的事件类创建为自定义注释。当我们实现事件编排架构的其他领域时,我们将在后面的小节中看到这些事件的用法。
清单 4-16 展示了作为定制注释实现的 CargoBookedEvent:
import javax.inject.Qualifier;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/∗∗
∗ Event Class for the Cargo Booked Event. Implemented as a custom annotation
∗/
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER})
public @interface CargoBookedEvent {
}
Listing 4-16.CargoBookedEvent stereotype annotation
类似地,清单 4-17 演示了 CargoRoutedEvent 事件类的实现:
import javax.inject.Qualifier;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/∗∗
∗ Event Class for the Cargo Routed Event. Wraps up the Cargo
∗/
@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER})
public @interface CargoRoutedEvent {
}
Listing 4-17.CargoRoutedEvent stereotype annotation
这就完成了概念的 演示,以实现核心域模型的 。现在让我们转到 实现领域模型服务 。
领域模型服务
使用域模型服务有两个主要原因。第一种是通过 明确定义的接口 使有界上下文的状态 对外部方 可用。第二是 与外部方 交互,将有界上下文的状态持久化到 数据存储库 (数据库),将有界上下文的状态改变事件发布到外部 消息代理或 与其他有界上下文 通信。
**对于任何有界的上下文,有三种类型的域模型服务:
-
入站服务 其中我们实现了定义良好的接口,使外部各方能够与域模型进行交互
-
出站服务 在这里,我们实现了与外部存储库/其他有界上下文的所有交互
-
应用服务 ,它充当域模型与入站和出站服务之间的外观层
图 4-27 展示了域模型服务的实现。
图 4-27
域模型服务实现摘要
入站服务
入站服务(或六角形架构模式中表示的入站适配器)充当我们的核心域模型的最外层网关。如上所述,它涉及到定义良好的接口的实现,这些接口使外部消费者能够与核心域模型进行交互。
入站服务 的类型取决于我们需要向 公开的操作 的类型,启用域模型 的外部消费者。
考虑到我们正在为货物跟踪应用实现微服务架构模式,我们提供两种类型的入站服务:
-
一个基于 REST 的 API 层,供外部消费者调用有界上下文上的操作( 命令/查询 )
-
基于 CDI 事件 的事件处理层,它消费来自消息代理的事件并处理它们
应用接口
REST API 的职责是代表有界上下文接收来自外部消费者的 HTTP 请求。这个请求可能是命令或查询。REST API 层的职责是将其翻译成由有界上下文的域模型识别的命令/查询模型,并将其委托给应用服务层进行进一步处理。
回头看图 4-5 ,其中详细列出了各种受限上下文 (例如,预订货物、分配货物路线、处理货物、跟踪货物) 的所有操作,所有这些操作都将有相应的 REST APIs 来接受这些请求并处理它们。
Eclipse MicroProfile 中 REST API 的实现是通过利用 Helidon MP 基于 JAX-RS (用于 RESTful Web 服务的 Java API)提供的 REST 功能。顾名思义,这个规范提供了构建 RESTful web 服务的能力。 Helidon MP 为这个规范 提供了一个实现,当我们创建脚手架项目时,这个功能会自动添加进来。
让我们看一个使用 JAX RS 构建的 REST API 的例子。清单 4-18 描述了 CargoBookingController 类 ,它为我们的 Cargo Booking 命令 提供了一个 REST API。
-
REST API 可在网址“/ cargobooking ”获得。
-
它有一个 POST 方法,接受 BookCargoResource,这是 API 的输入有效负载。
-
它依赖于 CargoBookingCommandService,后者是一个应用服务,充当一个外观(参见下面的实现)。利用“ @Inject ”注释将该依赖注入到 API 类中。这个注释是 CDI 的一部分。
-
它使用汇编器实用程序类(BookCargoCommandDTOAssembler)将资源数据(BookCargoResource)转换为命令模型( BookCargoCommand )。
-
转换后,它将流程委托给 CargoBookingCommandService 进行进一步处理。
-
它用新预订货物的预订标识符向外部消费者返回一个响应,响应状态为“200 OK ”。
package com.practicalddd.cargotracker.bookingms.interfaces.rest;
import com.practicalddd.cargotracker.bookingms.application.internal.commandservices.CargoBookingCommandService;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.dto.BookCargoResource;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.transform.BookCargoCommandDTOAssembler;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@Path("/cargobooking")
@ApplicationScoped
public class CargoBookingController {
private CargoBookingCommandService cargoBookingCommandService; // Application Service Dependency
/∗∗
∗ Inject the dependencies (CDI)
∗ @param cargoBookingCommandService
∗/
@Inject
public CargoBookingController(CargoBookingCommandService cargoBookingCommandService){
this.cargoBookingCommandService = cargoBookingCommandService;
}
/∗∗
∗ POST method to book a cargo
∗ @param bookCargoResource
∗/
@POST
@Produces(MediaType.APPLICATION_JSON)
public Response bookCargo(BookCargoResource bookCargoResource){
BookingId bookingId = cargoBookingCommandService.bookCargo(
BookCargoCommandDTOAssembler.toCommandFromDTO(bookCargoResource));
final Response returnValue = Response.ok()
.entity(bookingId)
.build();
return returnValue;
}
}
Listing 4-18.CargoBooking controller class implementation
清单 4-19 展示了book cargo resource类的实现:
package com.practicalddd.cargotracker.bookingms.interfaces.rest.dto;
import java.time.LocalDate;
/∗∗
∗ Resource class for the Book Cargo Command API
∗/
public class BookCargoResource {
private int bookingAmount;
private String originLocation;
private String destLocation;
private LocalDate destArrivalDeadline;
public BookCargoResource(){}
public BookCargoResource(int bookingAmount,
String originLocation, String destLocation, LocalDate destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 LocalDate getDestArrivalDeadline() { return destArrivalDeadline; }
public void setDestArrivalDeadline(LocalDate destArrivalDeadline) { this.destArrivalDeadline = destArrivalDeadline; }
}
Listing 4-19.BookCargo resource class for the controller
清单 4-20 显示了BookCargoCommandDTOAssembler类的实现:
package com.practicalddd.cargotracker.bookingms.interfaces.rest.transform;
import com.practicalddd.cargotracker.bookingms.domain.model.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.dto.BookCargoResource;
/∗∗
∗ Assembler class to convert the Book Cargo Resource Data to the Book Cargo Model
∗/
public class BookCargoCommandDTOAssembler {
/∗∗
∗ Static method within the Assembler class
∗ @param bookCargoResource
∗ @return BookCargoCommand Model
∗/
public static BookCargoCommand toCommandFromDTO(BookCargoResource bookCargoResource){
return new BookCargoCommand(
bookCargoResource.getBookingAmount(),
bookCargoResource.getOriginLocation(),
bookCargoResource.getDestLocation(),
java.sql.Date.valueOf(bookCargoResource.getDestArrivalDeadline()));
}
}
Listing 4-20.DTO Assembler class implementation
清单 4-21 展示了 BookCargoCommand 类的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.commands;
import java.util.Date;
/∗∗
∗ Book Cargo Command class
∗/
public class BookCargoCommand {
private int bookingAmount;
private String originLocation;
private String destLocation;
private Date destArrivalDeadline;
public BookCargoCommand(){}
public BookCargoCommand(int bookingAmount,
String originLocation, String destLocation, Date destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 4-21.BookCargoCommand class implementation
图 4-28 展示了我们实现的类图。
图 4-28
REST API 实现的类图
我们所有的入站 REST API 实现都遵循相同的方法,如图 4-29 所示。
图 4-29
入站服务实施流程摘要
-
对命令/查询的入站请求到达 REST API。API 等级是使用 Helidon MP 提供的 JAX 遥感功能实现的。
-
REST API 类使用实用汇编器组件将资源数据格式转换为域模型所需的命令/查询数据格式。
-
命令/查询数据被发送到应用服务以供进一步处理。
事件处理程序
我们的有界上下文中存在的另一种类型的接口是事件处理程序。在有界上下文中,事件处理程序负责处理有界上下文感兴趣的事件。这些事件由 applicationThese 中的其他有界上下文引发。“ 事件处理程序 ”在驻留在 入站/接口 层内的订阅有界上下文内创建。事件处理程序接收事件以及事件有效负载数据,并将它们作为常规操作进行处理。
清单 4-22 展示了位于跟踪有界上下文中的“”cargoroutdeventhandler。它 观察CargoRoutedEvent并接收“CargoRoutedEventData”作为净荷:
package com.practicalddd.cargotracker.trackingms.interfaces.events;
import com.practicalddd.cargotracker.shareddomain.events.CargoRoutedEvent;
import com.practicalddd.cargotracker.shareddomain.events.CargoRoutedEventData;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;
@ApplicationScoped
public class CargoRoutedEventHandler {
public void receiveEvent(@Observes @CargoRoutedEvent CargoRoutedEventData eventData) {
//Process the Event
}
}
Listing 4-22.CargoRouted event handler implementation
我们所有的事件处理器实现都遵循图 4-30 所示的相同方法。
图 4-30
事件处理程序实现的实现过程摘要
-
事件处理程序从消息代理接收入站事件。
-
事件处理程序使用实用汇编器组件将资源数据格式转换为域模型所需的命令数据格式。
-
命令数据被发送到应用服务以供进一步处理。
将入站事件映射到我们的消息代理(RabbitMQ)的相应物理队列的实现包含在 出站服务-消息代理 实现中。
应用服务程序
应用服务在有界上下文中充当入站/出站服务和核心域模型之间的门面或端口。
在一个有界上下文内,应用服务负责 接收来自入站服务 的请求,并委托给相应的服务,即命令委托给 命令服务 ,查询委托给 查询服务 ,与其他有界上下文通信的请求委托给 出站服务 。作为命令/查询/外部有界上下文通信处理的一部分,应用服务可能需要与 存储库、消息代理或其他有界上下文进行通信。出站服务 用于帮助这种通信。
最后,由于 MicroProfile 规范不提供直接从域模型引发域事件的能力,我们依赖于 应用服务来引发域事件。 使用出站服务将域事件发布到消息代理上。
应用服务类使用 CDI 托管 bean 实现 。 Helidon MP 为 CDI ,提供了一个实现,当我们创建脚手架项目时,这个功能会自动添加进来。
图 4-31 描述了应用服务的职责。
图 4-31
应用服务的责任
应用服务:命令/查询委托
作为该职责的一部分,有界上下文中的应用服务接收处理命令/查询的请求。这些请求通常来自入站服务(API 层)。作为处理的一部分,应用服务首先利用域模型的 命令处理程序/查询处理程序 (参见域模型部分)来设置状态或查询状态。然后,它们利用出站服务来保存状态或执行对聚合状态的查询。
让我们看一个命令委托者应用服务类的例子, 货物预订命令应用服务类 ,,它处理所有与预订相关的命令。我们还将更详细地查看其中一个命令, 预订货物命令 ,这是预订新货物的指令:
-
Application services 类被实现为一个附加了作用域的 CDI Bean(在本例中为 @Application )。
-
通过@Inject 注释为应用服务类提供了必要的依赖关系。在这种情况下,CargoBookingCommandApplicationService 类依赖于一个 出站服务存储库类(cargo repository),它使用该存储库类来持久存储新创建的货物。它还依赖于“cargobokedevent”,一旦货物被持久化,就需要引发该事件。CargoBookedEvent 是一个原型类,将货物作为其有效负载事件。我们将在接下来的小节中更详细地查看域事件,所以现在让我们继续。
-
应用服务将处理委托给“ bookCargo ”方法。在处理该方法之前,应用服务确保通过 "@Transactional" 注释打开新的事务。
-
应用服务类将新预订的货物存储在预订 MySQL 数据库表(cargo)中,并触发 货物预订事件 。
-
它向入站接口返回一个响应,其中包含新预订货物的预订标识符。
清单 4-23 演示了 货物订舱命令应用服务类 的实现:
package com.practicalddd.cargotracker.bookingms.application.internal.commandservices;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import com.practicalddd.cargotracker.bookingms.domain.model.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.Location;
import com.practicalddd.cargotracker.bookingms.domain.model.events.CargoBookedEvent;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.BookingAmount;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.RouteSpecification;
import com.practicalddd.cargotracker.bookingms.infrastructure.repositories.jpa.CargoRepository;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Event;
import javax.inject.Inject;
import javax.transaction.Transactional;
/∗∗
∗ Application Service class
for the Cargo Booking Command
∗/
@ApplicationScoped // Scope of the CDI Managed Bean. Application Scope indicates a single instance for the service class.
public class CargoBookingCommandService // CDI Managed Bean
{
@Inject
private CargoRepository cargoRepository; // Outbound Service to connect to the Booking Bounded Context MySQL Database Instance
@Inject
@CargoBookedEvent
private Event<Cargo> cargoBooked; // Event that needs to be raised when the Cargo is Booked
/∗∗
∗ Service Command method to book a new Cargo
∗ @return BookingId of the Cargo
∗/
@Transactional // Inititate the Transaction
public BookingId bookCargo(BookCargoCommand bookCargoCommand){
BookingId bookingId = cargoRepository.nextBookingId();
RouteSpecification routeSpecification = new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline()
);
BookingAmount bookingAmount = new BookingAmount(bookCargoCommand.getBookingAmount());
Cargo cargo = new Cargo(
bookingId,
bookingAmount,
routeSpecification);
cargoRepository.store(cargo); //Store the Cargo
cargoBooked.fire(cargo); // Fire the Cargo Booked Event
return bookingId;
}
// All other implementations of Commands for the Booking Bounded Context
}
Listing 4-23.Cargo Booking Command Application services implementation class
清单 4-24 展示了 货物预订查询应用服务类 的实现,它服务于与预订相关的所有查询。除了 没有引发任何域事件 之外,实现与货物预订命令应用服务类相同,因为它只是查询有界上下文的状态,而不改变其状态:
package com.practicalddd.cargotracker.bookingms.application.internal.queryservices;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import com.practicalddd.cargotracker.bookingms.infrastructure.repositories.jpa.CargoRepository;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.transaction.Transactional;
import java.util.List;
/∗∗
∗ Application Service
which caters to all queries related to the Booking Bounded Context
∗/
@ApplicationScoped
public class CargoBookingQueryService {
@Inject
private CargoRepository cargoRepository; // Inject Dependencies
/∗∗
∗ Find all Cargos
∗ @return List<Cargo>
∗/
@Transactional
public List<Cargo> findAll(){
return cargoRepository.findAll();
}
/∗∗
∗ List All Booking Identifiers
∗ @return List<BookingId>
∗/
public List<BookingId> getAllBookingIds(){
return cargoRepository.getAllBookingIds();
}
/∗∗
∗ Find a specific Cargo based on its Booking Id
∗ @param bookingId
∗ @return Cargo
∗/
public Cargo find(String bookingId){
return cargoRepository.find(new BookingId(bookingId));
}
}
Listing 4-24.Cargo Booking Query Application services implementation
图 4-32 展示了实现的类图。
图 4-32
将应用服务实现为命令/查询代理的类图
我们所有的应用服务实现(命令/查询)都遵循相同的方法,如图 4-33 所示。
图 4-33
作为命令/查询委托人的应用服务实施流程摘要
-
对命令/查询操作的请求到达有界上下文的应用服务。此请求是从入站服务发送的。应用服务类 被实现为 CDI 托管 bean,它们使用 CDI 注入注释 注入它们所有的 依赖项,它们 有一个作用域 ,最后它们负责 创建一个事务上下文 来开始处理操作。
-
应用服务依赖命令/查询处理程序来设置/查询聚合状态。
-
作为操作处理的一部分,应用服务需要与外部存储库进行交互。它们依赖出站服务来执行这些交互。
应用服务:引发域事件
应用服务扮演的另一个角色是引发每当有界上下文处理命令时生成的域事件。
在前一章中,我们使用 CDI 2.0 事件模型实现了域事件。基于事件通知/观察者模型,事件总线充当事件生产者和消费者的协调者。在单片实现中,我们使用事件总线的纯内部实现,事件在同一个执行线程中产生和消费。
在微服务的世界里,这是行不通的。由于每个微服务都是单独部署的,因此需要一个 集中式消息代理 来协调跨多个受限上下文/微服务的事件生产者和消费者之间的事件,如图 4-34 所示。我们的实现将使用 RabbitMQ 作为集中式消息代理。
在 Eclipse MicroProfile 的情况下,由于我们使用 CDI 事件,这基本上转化为
-
激发 CDI 事件并将其作为消息发布给 RabbitMQ 或
-
观察 CDI 事件并将它们作为来自 RabbitMQ 的消息使用
图 4-34
域事件摘要
我们已经讨论了跟踪有界上下文的业务用例,它需要从预订有界上下文订阅货物路由事件,以便为预订的货物分配跟踪标识符。让我们通过这个业务用例的实现来演示应用服务的事件发布。
清单 4-25 展示了来自CargoBookingCommandService类的代码部分,该类处理为货物分配路线的命令,然后触发“ CargoRouted ”事件:
package com.practicalddd.cargotracker.bookingms.application.internal.commandservices;
import javax.enterprise.event.Event; // CDI Eventing
/∗∗
∗ Application Service class for the Cargo Booking Command
∗/
@ApplicationScoped
public class CargoBookingCommandService {
@Inject
private CargoRepository cargoRepository;
@Inject
@CargoRoutedEvent // Custom annotation for the Cargo Routed Event
private Event<CargoRoutedEventData> cargoRouted; // Event that needs to be raised when the Cargo is Routed
/∗∗
∗ Service Command method to assign a route to a Cargo
∗ @param routeCargoCommand
∗/
@Transactional
public void assignRouteToCargo(RouteCargoCommand routeCargoCommand){
Cargo cargo = cargoRepository.find(new BookingId(routeCargoCommand.getCargoBookingId()));
CargoItinerary cargoItinerary = externalCargoRoutingService.fetchRouteForSpecification(new RouteSpecification(
new Location(routeCargoCommand.getOriginLocation()),
new Location(routeCargoCommand.getDestinationLocation()),
routeCargoCommand.getArrivalDeadline()
));
routeCargoCommand.setCargoItinerary(cargoItinerary);
cargo.assignToRoute(routeCargoCommand);
cargoRepository.store(cargo);
cargoRouted.fire(new CargoRoutedEventData(routeCargoCommand.getCargoBookingId()));
}
}
Listing 4-25.Firing the CargoRouted event from the Application services class
要激发 CDI 事件及其有效负载,我们需要实现三个步骤:
-
用需要触发的事件注入应用服务。这是通过为作为域模型操作的一部分实现的事件使用定制注释来完成的( 事件 )。在本例中,我们将 CargoRoutedEvent 自定义注释注入到CargoBookingCommandService中。
-
我们创建一个事件有效负载数据对象,它将是发布的事件的有效负载。在本例中,它是CargoRoutedEventData对象。
-
我们使用 CDI 提供的“fire()”方法来引发事件 并封装要随事件一起发送的有效载荷。
图 4-35 展示了引发域事件的应用服务实现的类图。
图 4-35
引发域事件的应用服务实现的类图
我们负责引发域事件的应用服务的所有实现都遵循相同的方法。如图 4-36 所示。
图 4-36
负责引发域事件的应用服务的进程概要
域事件的引发实现演示到此结束。引发域事件的实现仍然处于逻辑级别。我们仍然需要将这些事件发布到物理消息代理(在我们的例子中是 RabbitMQ)来完成我们的事件架构。我们使用 绑定类 来实现这一点,这是我们 出站服务代理实现 的一部分。
出站服务
出站服务提供了与有界上下文 外部的 服务进行交互的能力。 外部服务可以是存储有界上下文聚合状态的数据存储库 ,可以是发布聚合状态 的 消息代理,也可以是与另一个有界上下文 的 交互。
图 4-37 说明了出站服务的职责。作为操作(命令、查询、事件)的一部分,它们接收与外部服务通信的请求。它们使用基于外部服务类型的 API(持久性 API、REST APIs、代理 API)与它们进行交互。
图 4-37
出站服务
让我们看看这些出站服务类型的实现。
出站服务:存储库
数据库访问的出站服务被实现为 【储存库】类 。存储库类是围绕特定的聚合构建的,处理该聚合的所有数据库操作,包括以下内容:
-
新聚集及其关联的持久性
-
更新聚合及其关联
-
查询聚合及其关联
让我们看一个仓库类的例子, 货物仓库类 ,,它处理与 货物集合 相关的所有数据库操作:
-
Repository 类被实现为一个附加了作用域的 CDI Bean(在本例中为 @Application )。
-
因为我们将使用 JPA 作为与数据库实例交互的机制,所以我们将为 Repository 类提供由 JPA 管理的实体管理器资源 。实体管理器资源通过提供封装层来实现与数据库的交互。使用“@ persistence context”注释注入实体管理器。持久性上下文注释依赖于 persistence.xml 文件,该文件包含到实际物理数据库的连接信息。我们已经看到了 persistence.xml 文件的实现,它是 Helidon MP 项目设置过程的一部分。
-
存储库类使用实体管理器提供的方法(【persist()】)来保存/更新货物集合实例。
-
Repository 类使用实体管理器提供的方法创建 JPA 命名查询(【createNamedQueries())并运行它们以返回结果。
清单 4-26 显示了 Cargo Repository 类的实现:
package com.practicalddd.cargotracker.bookingms.infrastructure.repositories.jpa;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import javax.enterprise.context.ApplicationScoped;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
/∗∗
∗ Repository class for the Cargo Aggregate. Deals with all repository operations
∗ related to the state of the Cargo
∗/
@ApplicationScoped
public class CargoRepository {
private static final long serialVersionUID = 1L;
private static final Logger logger = Logger.getLogger(
CargoRepository.class.getName());
@PersistenceContext(unitName = "bookingms")
private EntityManager entityManager;
/∗∗
∗ Returns the Cargo Aggregate based on the Booking Identifier of a Cargo
∗ @param bookingId
∗ @return
∗/
public Cargo find(BookingId bookingId) {
Cargo cargo;
try {
cargo = entityManager.createNamedQuery("Cargo.findByBookingId",
Cargo.class)
.setParameter("bookingId", bookingId)
.getSingleResult();
} catch (NoResultException e) {
logger.log(Level.FINE, "Find called on non-existant Booking ID.", e);
cargo = null;
}
return cargo;
}
/∗∗
∗ Stores the Cargo Aggregate
∗ @param cargo
∗/
public void store(Cargo cargo) {
entityManager.persist(cargo);
}
/∗∗
∗ Gets next Booking Identifier
∗ @return
∗/
public BookingId nextBookingId() {
String random = UUID.randomUUID().toString().toUpperCase();
return new BookingId(random.substring(0, random.indexOf("-")));
}
/∗∗
∗ Find all Cargo Aggregates
∗ @return
∗/
public List<Cargo> findAll() {
return entityManager.createNamedQuery("Cargo.findAll", Cargo.class)
.getResultList();
}
/∗∗
∗ Get all Booking Identifiers
∗ @return
∗/
public List<BookingId> getAllBookingIds() {
List<BookingId> bookingIds = new ArrayList<BookingId>();
try {
bookingIds = entityManager.createNamedQuery(
"Cargo.getAllTrackingIds", BookingId.class).getResultList();
} catch (NoResultException e) {
logger.log(Level.FINE, "Unable to get all tracking IDs", e);
}
return bookingIds;
}
}
Listing 4-26.Cargo repository class implementation
我们所有的存储库实现都遵循相同的方法,如图 4-38 所示。
图 4-38
存储库实施流程摘要
-
储存库接收改变/查询聚集状态的请求。
-
存储库使用实体管理器在集合上执行数据库操作(存储、查询)。
-
实体管理器执行操作并将结果返回给存储库类。
出站服务:REST API
使用 REST API 作为微服务之间的通信模式是一个很常见的需求。虽然我们已经看到事件编排是实现这一点的一种机制,但有时只需要有界上下文之间的同步调用。
让我们通过一个例子来说明这一点。作为货物预订流程的一部分,我们需要根据路线规范为货物分配一个行程。生成最佳路线所需的数据作为维护船只运动、路线和时间表的路线约束上下文的一部分来维护。这需要预订受限上下文的预订服务向路由受限上下文的路由服务发出一个出站调用,路由服务提供一个 REST API 来根据货物的路线规范检索所有可能的路线。
如图 4-39 所示。
图 4-39
两个有界上下文之间的 HTTP 调用
然而,这确实对领域模型提出了挑战。预订有界上下文的货物集合将路线表示为“ ”对象,而路由有界上下文将路线表示为“ ”对象。因此,两个有界上下文之间的调用将需要在它们的域模型之间进行转换。
这种转换通常在反讹误层中完成,反讹误层充当两个有界上下文之间通信的桥梁。
如图 4-40 所示。
图 4-40
两个有界上下文之间的反腐败层
预订限制上下文依赖于 Helidon MP 提供的微配置文件类型安全 Rest 客户端功能来调用路由服务的 REST API。
让我们通过完整的实现来更好地理解这个概念:
- 第一步是实现路由服务 REST API。这是通过使用我们在前一章中已经实现的标准 JAX 遥感能力来完成的。清单 4-27 展示了路由服务 REST API 实现:
package com.practicalddd.cargotracker.routingms.interfaces.rest;
import com.practicalddd.cargotracker.TransitPath;
import com.practicalddd.cargotracker.routingms.application.internal.CargoRoutingService;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.∗;
@Path("/cargoRouting")
@ApplicationScoped
public class CargoRoutingController {
private CargoRoutingService cargoRoutingService; // Application Service Dependency
/∗∗
∗ Provide the dependencies
∗ @param cargoRoutingService
∗/
@Inject
public CargoRoutingController(CargoRoutingService cargoRoutingService){
this.cargoRoutingService = cargoRoutingService;
}
/∗∗
∗
∗ @param originUnLocode
∗ @param destinationUnLocode
∗ @param deadline
∗ @return TransitPath - The optimal route for a Route Specification
∗/
@GET
@Path("/optimalRoute")
@Produces({"application/json"})
public TransitPath findOptimalRoute(
@QueryParam("origin") String originUnLocode,
@QueryParam("destination") String destinationUnLocode,
@QueryParam("deadline") String deadline) {
TransitPath transitPath = cargoRoutingService.findOptimalRoute(originUnLocode,destinationUnLocode,deadline);
return transitPath;
}
}
Listing 4-27.Cargo Routing controller implementation
路由服务实现在“/ optimalRoute ”提供了一个 REST API。它接受一组规范——起始位置、目的位置和截止时间。然后,它使用货物路由应用服务类根据这些规范计算最佳路线。路由有界上下文中的域模型根据 运输路径(类似于路线) 和 运输边(类似于路线) 来表示最优路由。
清单 4-28 展示了传输路径域模型类的实现:
import java.util.ArrayList;
import java.util.List;
/∗∗
∗ Domain Model representation of the Transit Path
∗/
public class TransitPath {
private List<TransitEdge> transitEdges;
public TransitPath() {
this.transitEdges = new ArrayList<>();
}
public TransitPath(List<TransitEdge> transitEdges) {
this.transitEdges = transitEdges;
}
public List<TransitEdge> getTransitEdges() {
return transitEdges;
}
public void setTransitEdges(List<TransitEdge> transitEdges) {
this.transitEdges = transitEdges;
}
@Override
public String toString() {
return "TransitPath{" + "transitEdges=" + transitEdges + '}';
}
}
Listing 4-28.Transit path model implementation
清单 4-29 演示了 Transit Edge 域模型类的实现:
package com.practicalddd.cargotracker;
import java.io.Serializable;
import java.util.Date;
/∗∗
∗ Represents an edge in a path through a graph, describing the route of a
∗ cargo.
∗/
public class TransitEdge implements Serializable {
private String voyageNumber;
private String fromUnLocode;
private String toUnLocode;
private Date fromDate;
private Date toDate;
public TransitEdge() { }
public TransitEdge(String voyageNumber, String fromUnLocode,
String toUnLocode, Date fromDate, Date toDate) {
this.voyageNumber = voyageNumber;
this.fromUnLocode = fromUnLocode;
this.toUnLocode = toUnLocode;
this.fromDate = fromDate;
this.toDate = toDate;
}
public String getVoyageNumber() {
return voyageNumber;
}
public void setVoyageNumber(String voyageNumber) {
this.voyageNumber = voyageNumber;
}
public String getFromUnLocode() {
return fromUnLocode;
}
public void setFromUnLocode(String fromUnLocode) {
this.fromUnLocode = fromUnLocode;
}
public String getToUnLocode() {
return toUnLocode;
}
public void setToUnLocode(String toUnLocode) {
this.toUnLocode = toUnLocode;
}
public Date getFromDate() {
return fromDate;
}
public void setFromDate(Date fromDate) {
this.fromDate = fromDate;
}
public Date getToDate() {
return toDate;
}
public void setToDate(Date toDate) {
this.toDate = toDate;
}
@Override
public String toString() {
return "TransitEdge{" + "voyageNumber=" + voyageNumber
+ ", fromUnLocode=" + fromUnLocode + ", toUnLocode="
+ toUnLocode + ", fromDate=" + fromDate
+ ", toDate=" + toDate + '}';
}
}
Listing 4-29.Transit Edge domain model implementation
图 4-41 展示了实现的类图。
图 4-41
出站服务的类图
下一步是为我们的 Routing Rest 服务实现客户端实现。客户端是CargoBookingCommandService类,负责处理“ 给货物分配路线 ”命令。作为命令处理的一部分,这个服务类将需要调用路由服务 REST API 来获得基于货物路线规范的最佳路线。
CargoBookingCommandService 使用一个出站服务类–ExternalCargoRoutingService–来调用路由服务 REST API。ExternalCargoRoutingService类还将路由服务的 REST API 提供的数据转换成预订绑定上下文的域模型可识别的格式。
清单 4-30 演示了CargoBookingCommandService 中的方法“assignRouteToCargo”。 该服务类被注入了ExternalCargoRoutingService依赖项,该依赖项处理调用路由服务的 REST API 的请求,并返回cargo interary对象,该对象随后被分配给 cargo:
@ApplicationScoped
public class CargoBookingCommandService {
@Inject
private ExternalCargoRoutingService externalCargoRoutingService;
/∗∗
∗ Service Command method to assign a route to a Cargo
∗ @param routeCargoCommand
∗/
@Transactional
public void assignRouteToCargo(RouteCargoCommand routeCargoCommand){
Cargo cargo = cargoRepository.find(new BookingId(routeCargoCommand.getCargoBookingId()));
CargoItinerary cargoItinerary = externalCargoRoutingService.fetchRouteForSpecification(new RouteSpecification(
new Location(routeCargoCommand.getOriginLocation()),
new Location(routeCargoCommand.getDestinationLocation()),
routeCargoCommand.getArrivalDeadline()
));
cargo.assignToRoute(cargoItinerary);
cargoRepository.store(cargo);
}
// All other implementations
of Commands for the Booking Bounded Context
}
Listing 4-30.Outbound service class dependency
清单 4-31 演示了 ExternalCargoRoutingService 出站服务类。这个类执行两件事:
-
它使用 MicroProfile 提供的类型安全 Rest 客户端注释( @RestClient )注入一个 Rest 客户端“ExternalCargoRoutingClient”。这个客户端使用 MicroProfile 提供的REST client builderAPI 调用路由服务的 REST API。
-
它还将路由服务的 Rest API 提供的数据(transi path,transi edge)转换为预订有界上下文的域模型(cargo internary/Leg)。
package com.practicalddd.cargotracker.bookingms.application.internal.outboundservices.acl;
import com.practicalddd.cargotracker.TransitEdge;
import com.practicalddd.cargotracker.TransitPath;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.CargoItinerary;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.Leg;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.RouteSpecification;
import com.practicalddd.cargotracker.bookingms.infrastructure.services.http.ExternalCargoRoutingClient;
import org.eclipse.microprofile.rest.client.RestClientBuilder;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.List;
/∗∗
∗ Anti Corruption Service Class
∗/
@ApplicationScoped
public class ExternalCargoRoutingService {
@Inject
@RestClient // MicroProfile Type safe Rest Client API
private ExternalCargoRoutingClient externalCargoRoutingClient;
/∗∗
∗ The Booking Bounded Context makes an external call to the Routing Service of the Routing Bounded Context to
∗ fetch the Optimal Itinerary for a Cargo based on the Route Specification
∗ @param routeSpecification
∗ @return
∗/
public CargoItinerary fetchRouteForSpecification(RouteSpecification routeSpecification){
ExternalCargoRoutingClient cargoRoutingClient =
RestClientBuilder
.newBuilder().build(ExternalCargoRoutingClient.class); // MicroProfile Type safe Rest Client API
TransitPath transitPath = cargoRoutingClient.findOptimalRoute(
routeSpecification.getOrigin().getUnLocCode(),
routeSpecification.getDestination().getUnLocCode(),
routeSpecification.getArrivalDeadline().toString()
); // Invoke the Routing Service’s API using the client
List<Leg> legs = new ArrayList<Leg>(transitPath.getTransitEdges().size());
for (TransitEdge edge : transitPath.getTransitEdges()) {
legs.add(toLeg(edge));
}
return new CargoItinerary(legs);
}
/∗∗
∗ Anti-corruption layer conversion method from the routing service's domain model (TransitEdges)
∗ to the domain model recognized by the Booking Bounded Context (Legs)
∗ @param edge
∗ @return
∗/
private Leg toLeg(TransitEdge edge) {
return new Leg(
edge.getVoyageNumber(),
edge.getFromUnLocode(),
edge.getToUnLocode(),
edge.getFromDate(),
edge.getToDate());
}
}
Listing 4-31.Outbound service class implementation
清单 4-32 演示了ExternalCargoRoutingClient类型的安全休息客户端实现。它被实现为一个 接口 ,并利用@ register Rest client注释将其标记为 Rest 客户端。方法签名/方法资源细节应该与其调用的 API 的服务完全相同(在本例中是路由服务的optimalRouteAPI):
package com.practicalddd.cargotracker.bookingms.infrastructure.services.http;
import javax.ws.rs.∗;
import com.practicalddd.cargotracker.TransitPath;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/∗∗
∗ Type safe Rest client for the Routing Service API
∗/
@Path("cargoRouting")
@RegisterRestClient //Annotation to register this as a Rest client
public interface ExternalCargoRoutingClient {
// The method signature / method resource details should be exactly as the calling service
@GET
@Path("/optimalRoute")
@Produces({"application/json"})
public TransitPath findOptimalRoute(
@QueryParam("origin") String originUnLocode,
@QueryParam("destination") String destinationUnLocode,
@QueryParam("deadline") String deadline);
}
Listing 4-32.ExternalCargoRoutingClient typesafe implementation
图 4-42 展示了实现的类图。
图 4-42
出站服务(HTTP)实现流程类图
我们所有需要与其他有界上下文通信的出站服务实现都遵循相同的方法,如图 4-43 所示。
图 4-43
出站服务(HTTP)实施流程
-
应用服务类接收命令/查询/事件。
-
作为处理的一部分,如果它需要使用 REST 与另一个有界上下文的 API 进行交互,它会使用出站服务。
-
出站服务使用类型安全 Rest 客户端来调用有界上下文的 API。它还执行从该有界上下文的 API 提供的数据格式到当前有界上下文识别的数据模型的转换。
出站服务:消息代理
需要实现的最后一种出站服务是与消息代理的交互。消息代理为发布/订阅域事件提供了必要的物理基础设施。
我们已经看到了几个使用定制注释实现的事件类 (CargoBooked,CargoRouted) 。我们还看到了发布它们 (使用 fire()方法) 以及订阅它们 (使用 observes()方法) 的实现。
让我们看一下如何从 RabbitMQ 服务器的队列/交换中发布和订阅这些事件的实现。
请注意,Eclipse MicroProfile 平台和 Helidon MP 的扩展都没有提供帮助将 CDI 事件发布到 RabbitMQ 上的功能,因此我们需要为此提供自己的实现。该章的源代码提供了一个单独的项目(cargo-tracker-rabbi MQ-adaptor)。该项目提供以下内容:
-
基础设施功能(RabbitMQ 服务、托管发布者和托管消费者的连接工厂)
-
向 RabbitMQ 交易所发布 CDI 事件的 AMQP 消息的能力
-
为 RabbitMQ 队列中的事件使用 AMQP 消息的能力
我们将不会进入这个项目的详细实施。我们将使用这个项目提供的 API 来帮助我们实现 CDI 事件与 RabbitMQ 交换/队列集成的用例。要使用这个项目,我们需要向每个 MicroProfile 项目的 pom.xml 依赖项文件添加以下依赖项:
<dependency>
<groupId>com.practicalddd.cargotracker</groupId>
<artifactId>cargo-tracker-rabbimq-adaptor</artifactId>
<version>1.0.FINAL</version>
</dependency>
实现连通性的第一步是创建一个“”绑定器类。Binder 类有以下用途:
-
将 CDI 事件绑定到交换和路由关键字
-
将 CDI 事件绑定到队列
清单 4-33 演示了“ RoutedEventBinder ,它负责将“cargo routed”CDI 事件绑定到相应的 RabbitMQ 交换。它扩展了 adaptor 项目提供的“ EventBinder ”类。我们需要覆盖" bindEvents() "方法,在这里我们执行所有的绑定,用于将 CDI 事件映射到交换/队列 。还要注意,我们在 CDI 提供的 构建后生命周期方法 中执行绑定初始化:
package com.practicalddd.cargotracker.bookingms.infrastructure.brokers.rabbitmq;
import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import com.practicalddd.cargotracker.rabbitmqadaptor.EventBinder; //Adaptor Class
/∗∗
∗ Component which initializes the Cargo Routed Events <-> Rabbit MQ bindings
∗/
@ApplicationScoped
public class RoutedEventBinder extends EventBinder {
/∗∗
∗ Method to bind the Cargo Routed Event class to the corresponding exchange in Rabbit MQ with
∗ the corresponding Routing Key
∗/
@PostConstruct // CDI Annotation to initialize this in the post construct lifecycle method of this bean
public void bindEvents(){
bind(CargoRoutedEvent.class)
.toExchange("routed.exchange")
.withPublisherConfirms()
.withRoutingKey("routed.key");
}
}
Listing 4-33.RoutedEventBinder implementation class
因此,每次触发 Cargo Routed 事件时,它都会作为一个 AMQP 消息传递给具有指定路由键的相应交换。
同样的机制也适用于事件订阅。我们“ ”将”CDI 事件绑定到对应的 RabbitMQ 队列中,每次观察到一个 CDI 事件,它都会作为 AMQP 消息从对应的队列中传递出来。
本章的源代码有所有有界上下文的事件初始化器的完整实现(见包 com . practical DD . cargo tracker .<<bounded_context_name>> . infra structure . brokers . rabbit MQ)。</bounded_context_name>
图 4-44 展示了实现的类图。
图 4-44
事件绑定器实现
这完成了我们在 DDD 的货物跟踪系统的实现,它是一个微服务应用,使用 Eclipse MicroProfile 平台,由 Helidon MP 提供实现。
实施摘要
我们现在有了货物跟踪应用的完整的 DDD 实现,以及使用 Eclipse MicroProfile 中可用的相应规范实现的各种 DDD 工件。
实施总结如图 4-45 所示。
图 4-45
使用 Eclipse MicroProfile 的 DDD 工件实现概要
摘要
总结我们的章节
-
我们从建立 Eclipse MicroProfile 平台及其提供的各种功能的细节开始。
-
我们决定使用 Helidon MP 的 MicroProfile 平台的实现来帮助构建作为微服务应用的货物跟踪器。
-
我们深入研究了各种 DDD 工件的开发——首先是域模型,然后是使用 Eclipse MicroProfile 和 Helidon MP 上可用的技术的域模型服务。**************************
五、货物跟踪器:Spring 平台
快速回顾一下我们迄今为止的旅程
我们将货物跟踪确定为主要问题空间/核心领域,并将货物跟踪应用作为解决这一问题空间的解决方案。
我们确定了货物跟踪应用的各种子域/有界上下文。
我们详细描述了每个有界上下文的域模型,包括集合、实体、值对象和域规则的标识。
我们确定了有界环境中所需的支持领域服务。
我们在有限的上下文中确定了各种操作(命令、查询、事件和故事)。
我们使用 Jakarta EE 实现了一个整体版本的货物跟踪器,并使用 Eclipse MicroProfile 平台实现了一个微服务版本的货物跟踪器。
本章将详细介绍使用 Spring 平台的货物跟踪应用的第三个 DDD 实现。Cargo Tracker 应用将再次使用基于微服务的架构进行设计,和以前一样,我们将把 DDD 工件映射到 Spring 平台中可用的相应实现。
随着我们在实现过程中的进展,将会有与前面章节重复的地方。这是为了适应可能只对特定实现感兴趣而不是浏览所有实现的读者。
说了这么多,让我们首先浏览一下 Spring 平台的概况。
Spring 平台
Spring 平台( https://spring.io/
)最初是作为 Java EE 的替代品发布的,现在已经成为构建企业应用的主要 Java 框架。通过其项目组合提供的功能范围非常广泛,几乎涵盖了构建企业应用所需的每个方面。
与 Jakarta EE 或 Eclipse MicroProfile 不同,在 Jakarta EE 或 Eclipse micro profile 中,有一组规范和多个供应商提供这些规范的实现,Spring 平台提供了一个项目组合。
项目组合涵盖以下主要领域:
-
核心基础设施项目 提供一组基础项目来构建基于 Spring 的应用
-
云原生项目 提供了构建具有云原生能力的 Spring 应用的能力
-
数据管理项目 提供了在基于 Spring 的应用中管理任何类型数据的能力
平台内的各个项目如图 5-1 所示。
图 5-1
Spring 平台项目
正如所看到的,项目的广度是很大的,并且提供了广泛的能力。重申一下,本章的既定目标是利用基于微服务架构的 DDD 原则来实现货物跟踪应用。在这种情况下,我们将只使用可用项目 (Spring Boot、Spring 数据和 Spring 云流) 的子集来帮助我们实现目标。
简单回顾一下,微服务平台的要求如图 5-2 所示。
图 5-2
微服务平台要求
让我们简单地介绍一下这些项目的功能,并把它们映射到前面说明的需求。
Spring Boot:能力
Spring Boot 是任何基于 Spring 的微服务应用的基础。作为一个非常固执己见的平台,Spring Boot 使用统一的开发经验,帮助构建具有 REST、数据和消息传递功能的微服务。这是由 Spring Boot 在提供 REST、数据和消息传递功能的实际项目之上实现的抽象/依赖管理层来完成的。作为一名开发人员,您希望避免在构建微服务应用时管理依赖项和所需配置的麻烦。Spring Boot 通过提供初学者工具包为开发者抽象了所有这些。初学者工具包提供了所需的脚手架,使开发人员能够快速开始开发需要公开 API、处理数据和参与事件驱动架构的微服务。在我们的实现中,我们将依赖于 Spring Boot 提供的三个启动项目( spring-boot-starter-web、spring-boot-starter-data-jpa、 和spring-cloud-starter-stream-rabbit)。
我们将在实施过程中深入了解这些项目的细节。
从微服务需求映射的角度来看,图 5-3 所示的绿色方框是用 Spring Boot 实现的。
图 5-3
Spring Boot 提供的微服务平台组件
春云
Boot 提供了构建微服务应用的基础技术,而 Spring Cloud 则帮助实现基于 Spring Boot 的微服务应用所需的分布式系统模式。这些包括外部化配置、服务注册和发现、消息传递、分布式跟踪和 API 网关。此外,该项目还提供了与 AWS/GCP/Azure 等第三方云提供商进行原生集成的项目。
图 5-4
Spring Cloud 提供的微服务平台组件
-
从微服务需求映射的角度来看,如图 5-4 所示的橙色方框是用 Spring Cloud 实现的。
-
Spring 平台没有为使用基于编排的 sagas 的分布式事务管理提供任何现成的功能。我们将使用事件编排实现 分布式事务,并使用 Spring Boot 和 Spring Cloud Stream 定制实现 。
Spring 框架摘要
我们现在对 Spring 平台通过 Spring Boot 和 Spring Cloud 项目构建微服务应用有了一个大致的概念。
让我们利用这些技术来实现货物跟踪器。作为实现的一部分,可能会有相当多的重复,因为某些读者可能只对这个实现感兴趣。
具有 Spring Boot 的有界上下文
有界上下文是我们基于 Spring 的货物跟踪微服务应用的 DDD 实现的解决方案阶段的起点。在微服务架构风格中,每个有界上下文必须是一个 自包含的独立可部署单元 ,不直接依赖于我们的问题空间中的任何其他有界上下文。
将货物跟踪应用分割成多个微服务的模式将和以前一样,也就是说,我们将核心域分割成一组 业务能力/子域 和 解决方案,其中每一个都作为一个单独的有界上下文。
实现有界上下文包括将我们的 DDD 工件逻辑分组到一个可部署的工件中。货物跟踪应用中的每个有界上下文都将被构建成一个 Spring Boot 应用。Spring Boot 应用的结果工件是一个 自包含 fat JAR 文件 ,它包含所有需要的依赖项(例如,数据访问库、REST 库)和配置。fat JAR 文件还包含一个嵌入式 web 容器(在我们的例子中是 Tomcat)作为运行时。这确保了我们不需要任何外部应用服务器来运行我们的 fat JAR。Spring Boot 应用的剖析如图 5-5 所示。
图 5-5
剖析 Spring Boot 应用
从部署的角度来看,如图 5-6 所示,每个微服务都是一个独立的自包含可部署单元 (fat JAR 文件)。
微服务将需要一个 数据存储 来存储它们的状态。我们选择为每个服务模式采用 数据库, 也就是说,我们的每个微服务都将拥有自己独立的数据存储。就像我们的应用层有多种语言的技术选择一样,我们的数据存储也有多种语言的选择。我们可以选择一个普通的关系数据库(如 Oracle、MySQL、PostgreSQL),一个 NoSQL 数据库(如 MongoDB、Cassandra),甚至一个内存中的数据存储(如 Redis)。选择主要取决于微服务想要满足的可伸缩性需求和用例类型。对于我们的实现,我们决定选择 MySQL 作为数据存储。部署架构如图 5-6 所示。
图 5-6
我们 Spring Boot 微服务的部署架构
有界上下文:打包
要开始我们的打包,第一步是创建一个常规的 Spring Boot 应用。我们将使用 Spring Initializr 工具( https://start.spring.io/
),这是一个基于浏览器的工具,有助于轻松创建 Spring Boot 应用。图 5-7 展示了利用 Initializr 工具创建预订微服务。
图 5-7
Spring Initializr 工具用于搭建具有依赖关系的 Spring Boot 项目
我们已经用创建了这个项目
-
群–com . practical DD . cargo tracker
-
神器–booking ms
-
依赖——Spring Web Starter、Spring Data JPA、Spring Cloud Stream
单击生成项目图标。这将生成一个 ZIP 文件,其中包含预订 Spring Boot 应用以及所有可用的依赖项和配置。
Spring Boot 应用的主应用类用@ spring boot application注释进行了注释。它包含一个公共静态 void main 方法,并且是 Spring Boot 应用的入口点。
Booking Application类是我们的 Booking Spring Boot 应用中的主类。清单 5-1 显示了 BookingmsApplication 类:
package com.practicalddd.cargotracker.bookingms;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication //Main class marker annotation
public class BookingmsApplication {
public static void main(String[] args) {
SpringApplication.run(BookingmsApplication.class, args);
}
}
Listing 5-1.Bookingms Application class
构建项目将产生一个 jar 文件( bookingms.jar ),使用命令“Java-JAR booking ms . JAR”将它作为一个简单的 JAR 文件运行,这将打开我们的 Spring Boot 应用。
有界上下文:包结构
决定了打包方面之后,下一步是决定我们每个有界上下文的包结构,也就是说,将各种 DDD 微文件工件逻辑分组到一个可部署的工件中。逻辑分组包括识别一个包结构,我们在这个包结构中放置各种 DD MicroProfile 工件,以实现我们对有界上下文的整体解决方案。
图 5-8 显示了我们任何有界上下文的高级包结构。
图 5-8
有界上下文的包结构
让我们稍微扩展一下包的结构。
图 5-9 显示了一个绑定上下文 Spring Boot 应用包结构的例子,其中BookingmsApplication
是主要的 Spring Boot 应用类。
图 5-9
使用 Spring Boot 的预订有界上下文的包结构
让我们扩展一下包的结构。
接口
这个包将所有入站接口封装到由通信协议分类的有界上下文中。 接口 的主要用途是代表域模型协商协议(如 REST API(s)、WebSocket(s)、FTP(s)、自定义协议)。
作为一个例子,Booking Bounded Context 提供 REST APIs,用于发送 状态改变请求,即命令, 给它(例如,Book Cargo 命令,Assign Route to Cargo 命令)。同样,Booking Bounded Context 提供了 REST APIs,用于向其发送 状态检索请求,即查询, (例如,检索货物订舱明细,列出所有货物)。这被分组到“”包中。
**它还有事件处理程序,订阅由其他有界上下文生成的各种事件。所有事件处理程序都被分组到“ 【事件处理程序】” 包中。除了这两个包,接口包还包含了“ 变换 ”包。这用于将传入的 API 资源/事件数据转换为域模型所需的相应命令/查询模型。
由于我们需要支持 REST、事件和数据转换,包的结构如图 5-10 所示。
图 5-10
接口的封装结构
应用
应用服务充当有界上下文的域模型的外观。它们提供外观服务,将命令/查询发送到底层的域模型。作为命令/查询处理的一部分,它们也是我们向其他有界上下文发出出站调用的地方。
总而言之,应用服务
-
参与命令和查询调度
-
作为命令/查询处理的一部分,在必要时调用基础设施组件
-
为基础领域模型提供集中的关注点(例如,日志、安全性、度量)
-
对其他有界上下文进行标注
包装结构如图 5-11 所示。
图 5-11
应用服务的包结构
领域
这个包包含有界上下文的域模型。这是有界上下文的域模型的核心,它包含核心业务逻辑的实现。
我们的有界上下文的核心类如下:
-
总计
-
实体
-
价值对象
-
命令
-
事件
包装结构如图 5-12 所示。
图 5-12
我们的领域模型的包结构
基础设施
基础设施包有三个主要目的:
-
当有界上下文接收到与其状态相关的操作(状态的改变、状态的检索)时,它 需要一个底层知识库来处理操作;在我们的例子中,这个存储库是我们的 MySQL 数据库实例。基础设施包包含有界上下文与底层存储库通信所需的所有必要组件。作为我们实现的一部分,我们打算使用 JPA 或 JDBC 来实现这些组件。
-
当有界上下文需要传递状态变化事件时,它需要底层事件基础结构来发布状态变化事件。在我们的实现中,我们打算使用一个 消息代理作为底层事件基础设施 (RabbitMQ 可从 rabbitmq.com 下载)。基础设施包包含有界上下文与底层消息代理通信所需的所有必要组件。
-
我们在基础架构层中包括的最后一个方面是任何种类的特定于 Spring Boot 的配置。
包装结构如图 5-13 所示。
图 5-13
基础设施组件的包装结构
图 5-14 显示了我们任何有界上下文的整个包结构的完整摘要。
图 5-14
任何有界上下文的包结构
这就完成了我们的货物跟踪微服务应用的有界上下文的实现。我们的每个有界上下文都被实现为一个 Spring Boot 应用,一个胖罐子作为工件。有界的上下文被模块整齐地分组在一个包结构中,具有清晰分离的关注点。
让我们进入货物跟踪应用的实现。
货物跟踪实施
本章的下一节将详细介绍利用 DDD 和 Spring Boot/Spring Cloud 作为微服务应用的货物跟踪应用的实现。如前所述,其中一些部分是我们已经看到的内容的重复,但是再次浏览它将有助于强化 DDD 的概念。
图 5-15 显示了我们的各种 DDD 工件的逻辑分组的高级概述。正如所见,我们需要实现两组工件:
图 5-15
DDD 工件的逻辑分组
-
域模型 将包含我们的 核心域/业务逻辑
-
域模型服务 ,其中包含 对我们核心域模型 的支持服务
就域模型的实际实现而言,这转化为特定有界上下文/微服务的各种命令、查询和值对象。
就域模型服务的实际实现而言,这转化为 有界上下文/微服务的域模型所需的接口、应用服务和基础设施 。
回到我们的货物跟踪器应用,图 5-16 从各种受限上下文及其支持的操作方面说明了我们的微服务解决方案。如所见,这包含每个有界上下文将处理 的各种 命令、每个有界上下文将服务的 查询、 以及每个有界上下文将订阅/发布 的 事件。每个微服务都是一个独立的可部署工件,有自己的存储。
图 5-16
货物跟踪微服务解决方案
注意
某些代码实现将只包含摘要/片段,以帮助理解实现概念。本章的源代码包含这些概念的完整实现。
领域模型:实现
我们的领域模型是我们的有界上下文的中心特征,并且如前所述,有一组与之相关的工件。这些工件的实现是在 Spring Boot 提供的工具的帮助下完成的。
简单总结一下,我们需要实现的领域模型工件如下:
-
核心域模型——聚集、实体和值对象
-
域模型操作–命令、查询和事件
让我们逐一查看这些工件,看看 Spring Boot 为我们实现这些工件提供了哪些相应的工具。
核心领域模型:实现
任何有界上下文的核心域的实现都包括那些将清楚地表达有界上下文的业务意图的工件的识别。在高层次上,这包括聚合、实体和值对象的识别和实现。
聚合/实体/值对象
聚合是我们领域模型的核心。概括地说,我们在每个有界上下文中有四个聚合,如下图 5-17 所示。
图 5-17
我们有限的上下文/微服务中的聚合
聚合的实现包括以下几个方面
-
聚合类实现
-
通过业务属性丰富领域,最后
-
实现实体/值对象
聚合类实现
因为我们打算使用 MySQL 作为每个有界上下文的数据存储,所以我们打算使用 Java EE 规范中的 JPA (Java Persistence API ),它提供了一种定义和实现与底层 SQL 数据存储交互的实体/服务的标准方法。
JPA 集成:Spring 数据
Spring Boot 通过使用 Spring Data JPA 项目 ( https://spring.io/projects/spring-data-jpa
)提供了对 JPA 的支持,该项目提供了一种复杂而简单的机制来实现基于 JPA 的存储库。Spring Boot 提供了一个启动项目(Spring-boot-starter-Data-JPA),该项目自动为 Spring Data JPA 配置一组合理的默认设置(例如 Hibernate JPA 实现、Tomcat 连接池)。
当我们将 starter data JPA 项目配置为 Initializr 项目中的依赖项时,它的依赖项会自动添加。除此之外,我们还需要添加 MySQL Java 驱动程序库,以便连接到我们的 MySQL 数据库实例:
pom.xml
清单 5-2 显示了需要在 Spring Initializr 项目生成的 pom.xml 依赖文件 中进行的更改:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
application.properties
Listing 5-2.pom.xml dependency maintainance
除了依赖关系,我们还需要为每个 MySQL 实例配置连接属性。这是在我们的 Spring Boot 应用提供的application . properties文件中完成的。清单 5-3 展示了需要添加的配置属性。必要时,您需要用 MySQL 实例的详细信息替换这些值:
spring.datasource.url=jdbc:mysql://<<Machine-Name>>:<<Machine-Port>>/<<MySQL-Database-Instance-Name>>
spring.datasource.username=<<MySQL-Database-Instance-UserID>>
spring.datasource.password==<<MySQL-Database-Instance-Password>>
Listing 5-3.MySQL connection configuration
这些设置足以在我们的 Spring Boot 应用中设置和实现 JPA。如前所述,Spring Data JPA 项目配置了一组合理的缺省值,使我们能够以最小的努力开始。除非另有说明,否则我们所有有界上下文中的所有聚合都实现了相同的机制。
我们的每个根聚合类都被实现为一个 JPA 实体。JPA 没有提供特定的注释来将特定的类注释为根聚合,所以我们采用常规的 POJO,使用 JPA 提供的标准注释@实体 。以 Booking Bounded Context 为例,它将 Cargo 作为根聚合,清单 5-4 显示了 JPA 实体所需的最小代码:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
@Entity //JPA Entity Marker
public class Cargo {
}
Listing 5-4.Cargo root aggregate as a JPA Entity
每个 JPA 实体都需要一个标识符。对于我们的聚合标识符实现,我们选择从 MySQL 序列中为我们的货物聚合生成一个 【技术/代理标识符(主键) 。除了技术标识符,我们还选择拥有一个 业务密钥 。
业务键传达聚合标识符 clear 的业务意图,即新预订货物的预订标识符,并且是向域模型的外部消费者公开的键(稍后将详细介绍)。另一方面,技术关键字是集合标识符的纯内部表示,并且对于在集合及其依赖对象(参见下面的值对象/实体)之间的有界上下文 内维护关系 是有用的。
继续我们在 Booking Bounded 上下文中的 Cargo Aggregate 的例子,我们将技术/业务键添加到类实现中,直到现在。
清单 5-5 演示了这一点。“@ Id”注释标识我们的货物集合上的主键。没有特定的注释来标识业务键,所以我们只是使用 JPA 提供的“@ Embedded ”注释将其实现为常规的 POJO ( BookingId )和Embeddedit:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
@Entity
public class Cargo {
@Id //Identifier Annotation provided by JPA
@GeneratedValue(strategy = GenerationType.IDENTITY) // Rely on a MySQL generated sequence
private Long id;
@Embedded //Annotation which enables usage of Business Objects instead of primitive types
private BookingId bookingId; // Business Identifier
}
Listing 5-5.Identifier for the Cargo root aggregate
清单 5-6 显示了 BookingId 业务键类的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
import java.io.Serializable;
/**
* Business Key Identifier for the Cargo Aggregate
*/
@Embeddable
public class BookingId implements Serializable {
@Column(name="booking_id")
private String bookingId;
public BookingId(){}
public BookingId(String bookingId){this.bookingId = bookingId;}
public String getBookingId(){return this.bookingId;}
}
Listing 5-6.Business key implementation for the Cargo root aggregate
我们现在有了一个使用 JPA 的聚合(Cargo)的基本实现。除了处理活动绑定的上下文之外,其他聚合具有相同的实现机制。由于它是一个事件日志,我们决定只为聚合实现一个键,即活动 Id。
图 5-18 总结了我们所有聚合的基本实现。
图 5-18
我们聚合的基本实现
领域丰富性:业务属性
准备好基本的实现后,让我们继续讨论集合的核心部分——域丰富性。 任何有界语境的聚合应该能够清晰地表达有界语境的业务语言 。本质上,它在纯技术术语中的意思是我们的集合不应该是贫血的,也就是说,只包含 getter/setter 方法。
一个缺乏活力的集合违背了 DDD 的基本原则,因为它本质上意味着 在应用的多个层中表达的商业语言 ,从长远来看,这反过来又会导致一个不可维护的软件。
**那么我们如何实现一个域丰富的聚合呢?简而言之就是 业务属性和业务方式。 在这一节中,我们的重点将放在业务属性方面,同时我们将涵盖作为领域模型操作实现的一部分的业务方法部分。
聚合的业务属性捕获聚合的状态,作为使用业务术语而不是技术术语描述的属性。
让我们看一下我们的货物总量的例子。
将状态转换为业务概念,货物集合具有以下属性:
-
货物的始发地
-
货物的预订金额
-
路线说明 (始发地、目的地、目的地到达截止日期)
-
根据路线规格将货物分配到的 路线 。路线由多条 线路 组成,货物可能通过这些线路到达目的地
-
货物的交付进度 对照其指定的路线规格和路线。交付进度提供了关于 路线状态、运输状态、货物的当前航程、货物的最后已知位置、下一预期活动以及货物上发生的最后活动的细节。
图 5-19 显示了货物集合及其与相关对象的关系。请注意我们如何能够清楚地用 的纯业务术语表示货物总量。
图 5-19
货物集合及其从属关联
JPA 为我们提供了一组注释 (@Embedded,@ Embedded),帮助我们使用业务对象实现聚合类。
清单 5-7 显示了我们的货物集合的例子,其中所有的 依赖项都被建模为业务对象:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.*;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.*;
@Entity
public class Cargo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private BookingId bookingId; // Aggregate Identifier
@Embedded
private BookingAmount bookingAmount; //Booking Amount
@Embedded
private Location origin; //Origin Location of the Cargo
@Embedded
private RouteSpecification routeSpecification; //Route Specification of the Cargo
@Embedded
private CargoItinerary itinerary; //Itinerary Assigned to the Cargo
@Embedded
private Delivery delivery; // Checks the delivery progress of the cargo against the actual Route Specification and Itinerary
}
Listing 5-7.Cargo root aggregate dependencies as business objects
聚合的依赖类 建模为实体对象或值对象 。概括地说,有界上下文中的实体对象有自己的身份,但总是存在于根聚合中,也就是说,它们不能独立存在,并且在聚合的整个生命周期中从不改变。另一方面,值对象没有自己的身份,在聚合的任何实例中很容易被替换
继续我们的示例,在货物集合中,我们有以下内容:
-
【原点】 的货物作为一个 的实体对象 (位置)。这在货物集合实例中不能改变,因此被建模为实体对象。
-
预订金额 的货物, 路线规格 的货物, 的货物行程 分配给货物,而 交付的货物 作为价值对象。这些对象在任何货物集合实例中都是可替换的,因此被建模为值对象。
让我们浏览一下场景和基本原理,为什么我们将它们作为值对象而不是实体,因为这是一个 重要的领域建模决策 :
-
当一个新的货物被预订,我们将有 一个新的路线规格 、 一个空货物行程、 和 没有交货进度 。
-
当货物被分配一个行程单时, 空载货物行程单 被一个 已分配货物行程单替代。
-
当货物作为其行程的一部分通过多个港口时, 交付 进度在根集合内被更新和替换。
-
最后,如果客户选择更改货物的交货地点或交货截止日期, 路线规格更改 ,将分配一个 新的货物路线 ,重新计算 交货, 和 预订金额更改。
它们 都是可替换的,因此将 建模为值对象。 这是对聚合中的实体和值对象进行建模的 经验法则。
实现实体对象/值对象
使用 JPA 提供的" @ embedded "注释,将实体对象/值对象实现为 JPA 可嵌入对象。然后使用“ @Embedded ”注释将它们嵌入到聚合中。
清单 5-8 展示了嵌入聚合类的机制。
让我们看一下 Cargo Aggregate 的实体对象/值对象的实现。
清单 5-8 展示了 位置实体对象 。注意分组在 model.entities 下的包名( ):
package com.practicalddd.cargotracker.bookingms.domain.model.entities;
import javax.persistence.Column;
import javax.persistence.Embeddable;
/**
* Location Entity class represented by a unique 5-digit UN Location code.
*/
@Embeddable
public class Location {
@Column(name = "origin_id")
private String unLocCode;
public Location(){}
public Location(String unLocCode){this.unLocCode = unLocCode;}
public void setUnLocCode(String unLocCode){this.unLocCode = unLocCode;}
public String getUnLocCode(){return this.unLocCode;}
}
Listing 5-8.Location entity object
清单 5-9 展示了 预订金额/路线规格值对象 的示例。注意分组在 model.valueobjects 下的包名( ):
package com.practicalddd.cargotracker.bookingms.domain.model.valueobjects;
import javax.persistence.Column;
import javax.persistence.Embeddable;
/**
* Domain model representation of the Booking Amount for a new Cargo.
* Contains the Booking Amount of the Cargo
*/
@Embeddable
public class BookingAmount {
@Column(name = "booking_amount", unique = true, updatable= false)
private Integer bookingAmount;
public BookingAmount(){}
public BookingAmount(Integer bookingAmount){this.bookingAmount = bookingAmount;}
public void setBookingAmount(Integer bookingAmount){this.bookingAmount = bookingAmount;}
public Integer getBookingAmount(){return this.bookingAmount;}
}
Listing 5-9.Booking Amount value object implementation
清单 5-10 展示了 路线规范值对象 :
package com.practicalddd.cargotracker.bookingms.domain.model.valueobjects;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.Location;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.Date;
/**
* Route Specification of the Booked Cargo
*/
@Embeddable
public class RouteSpecification {
private static final long serialVersionUID = 1L;
@Embedded
@AttributeOverride(name = "unLocCode", column = @Column(name = "spec_origin_id"))
private Location origin;
@Embedded
@AttributeOverride(name = "unLocCode", column = @Column(name = "spec_destination_id"))
private Location destination;
@Temporal(TemporalType.DATE)
@Column(name = "spec_arrival_deadline")
@NotNull
private Date arrivalDeadline;
public RouteSpecification() { }
/**
* @param origin origin location
* @param destination destination location
* @param arrivalDeadline arrival deadline
*/
public RouteSpecification(Location origin, Location destination,
Date arrivalDeadline) {
this.origin = origin;
this.destination = destination;
this.arrivalDeadline = (Date) arrivalDeadline.clone();
}
public Location getOrigin() {
return origin;
}
public Location getDestination() {
return destination;
}
public Date getArrivalDeadline() {
return new Date(arrivalDeadline.getTime());
}
}
Listing 5-10.Route Specification value object implementation
其余的值对象( RouteSpecification、CargoItinerary 和 Delivery )使用“@ Embedded”注释以相同的方式实现,并使用“ @Embedded ”注释嵌入到货物聚合中。
注意
完整的实现请参考本章的源代码。
让我们看看其他聚合(处理活动、航行和跟踪)的简化类图。图 5-20 、 5-21 和 5-22 说明了这一点。
图 5-22
跟踪活动及其相关关联
图 5-21
航程及其从属关联
图 5-20
处理活动及其相关关联
这就完成了核心域模型的实现。接下来让我们看看域模型操作的实现。
注意
本书的源代码通过包分离展示了核心领域模型。您可以在 github.com/practicalddd.查看源代码,以便更清楚地了解域模型中的对象类型
领域模型操作
有界上下文中的域模型操作处理与有界上下文集合的状态相关联的任何种类的操作。其中包括 入站操作(命令/查询)和出站操作(事件)。
命令
命令负责在有界上下文中改变聚集的状态。
在有界环境中实现命令包括以下步骤:
-
命令的识别/执行
-
识别/实现处理命令的命令处理程序
命令的识别
命令的识别围绕着识别影响集合状态的任何操作。例如,预订命令有界上下文具有以下操作或命令:
-
预订货物
-
运送货物
这两种操作都导致有界环境内货物集合体的状态改变,因此被识别为命令。
命令的执行
一旦确定,就使用常规 POJOs 在 Spring Boot 实现中实现确定的命令。清单 5-11 展示了 BookCargoCommand 类对于 Book Cargo 命令的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.commands;
import java.util.Date;
/**
* Book Cargo Command class
*/
public class BookCargoCommand {
private String bookingId;
private int bookingAmount;
private String originLocation;
private String destLocation;
private Date destArrivalDeadline;
public BookCargoCommand(){}
public BookCargoCommand(int bookingAmount,
String originLocation, String destLocation, Date destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingId(String bookingId){this.bookingId = bookingId;}
public String getBookingId(){return this.bookingId;}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 5-11.BookCargoCommand class implementation
识别 命令处理程序
每个命令都有一个相应的命令处理程序。命令处理程序 的作用是处理输入的命令并设置集合的状态。命令处理程序是 域模型中唯一设置聚合状态的地方 。这是一个严格的规则,需要遵循它来帮助实现一个丰富的领域模型。
命令处理程序的实现
由于 Spring 框架不提供任何现成的功能来实现命令处理程序,我们的实现方法将只是 识别集合上的例程,这些例程可以被表示为命令处理程序 。对于我们的第一个命令 Book Cargo, 我们将聚合的构造函数标识为我们的命令处理程序;对于我们的第二个命令 Route Cargo, ,我们创建了一个新的例程“assignor Route()”,作为我们的命令处理程序。
清单 5-12 显示了货物集合构造器的代码片段。构造函数接受 BookCargoCommand 作为输入参数,并设置相应的聚集状态:
/**
* Constructor Command Handler for a new Cargo booking
*/
public Cargo(BookCargoCommand bookCargoCommand){
this.bookingId = new BookingId(bookCargoCommand.getBookingId());
this.routeSpecification = new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline()
);
this.origin = routeSpecification.getOrigin();
this.itinerary = CargoItinerary.EMPTY_ITINERARY; //Empty Itinerary since the Cargo has not been routed yet
this.bookingAmount = bookingAmount;
this.delivery = Delivery.derivedFrom(this.routeSpecification,
this.itinerary, LastCargoHandledEvent.EMPTY);
}
Listing 5-12.Command handler for the BookCargo command
清单 5-13 显示了assigno route()命令处理程序的代码片段。它接受RouteCargoCommand类作为输入,并设置聚合的状态:
/**
* Command Handler for the Route Cargo Command. Sets the state of the Aggregate and registers the
* Cargo routed event
* @param routeCargoCommand
*/
public void assignToRoute(RouteCargoCommand routeCargoCommand) {
this.itinerary = routeCargoCommand.getCargoItinerary();
// Handling consistency within the Cargo aggregate synchronously
this.delivery = delivery.updateOnRouting(this.routeSpecification,
this.itinerary);
}
Listing 5-13.Command handler for the route assignment command
总之, 命令处理程序 在管理有界上下文内的聚合状态中扮演着非常重要的角色。命令处理程序的实际调用是通过应用服务进行的,我们将在下面的章节中看到。
图 5-23 展示了我们的命令处理程序实现的类图。
图 5-23
命令处理程序实现的类图
这就完成了域模型中命令的实现。我们现在来看看如何实现查询。
问题
有界上下文中的查询负责 向外部消费者提供有界上下文的集合 的状态。
为了实现查询,我们利用 JPA 命名查询、 即可以在集合上定义的查询来以各种形式检索状态。清单 5-14 展示了来自货物集合的代码片段,该代码片段定义了需要可用的查询。在这种情况下,我们有三个查询—查找所有货物,通过货物的预订标识符查找货物,以及所有货物的最终预订标识符:
@NamedQueries({
@NamedQuery(name = "Cargo.findAll",
query = "Select c from Cargo c"),
@NamedQuery(name = "Cargo.findByBookingId",
query = "Select c from Cargo c where c.bookingId = :bookingId"),
@NamedQuery(name = "Cargo.findAllBookingIds",
query = "Select c.bookingId from Cargo c") })
public class Cargo{}
Listing 5-14.Named queries within the Cargo root aggregate
总之,查询处理程序扮演着在有限的上下文中呈现 聚合状态的角色。这些查询的实际调用和执行是通过应用服务和存储库类进行的,我们将在接下来的章节中看到。
这就完成了域模型中查询的实现。我们现在将看到如何实现事件。
域事件
有界上下文中的事件是 将有界上下文的聚集状态变化发布为事件 的任何操作。由于命令会改变聚合的状态,因此可以有把握地假设在有界上下文中的任何命令操作都会导致相应的事件。
域事件在微服务架构中扮演着核心角色,以健壮的方式实现它们至关重要。微服务架构的分布式本质要求通过 编排机制 到 使用事件,在基于微服务的应用的各种有界上下文之间保持状态和事务一致性 。
图 5-24 举例说明了在货物跟踪应用的各种有界上下文之间流动的事件。
图 5-24
微服务架构中的事件流
让我们用一个商业案例来解释这一点。
当货物被分配路线时,这意味着货物现在可以被跟踪,这需要向货物发出跟踪标识符。货物路线的分配是在预订有界环境中处理的,而跟踪标识符的发布是在跟踪有界环境中处理的。在整体式工作方式中,为货物分配路线和发布跟踪标识符的过程同时发生,因为 由于流程、运行时和数据存储 的共享模型,我们可以跨多个有界上下文维护相同的事务上下文。
然而,在微服务架构中,这是不可能实现的,因为它是 无共享架构 。当货物被分配一条路线时,Booking Bounded 上下文只负责确保货物集合的状态反映新的路线。跟踪有界上下文需要知道这种状态的变化,以便它能够相应地发布跟踪标识符,从而 完成业务用例 。这就是 域事件和 事件编排发挥重要作用的地方。如果货物绑定上下文可以引发货物集合已经被分配了路线的事件,则跟踪绑定上下文可以订阅该特定事件并发布跟踪标识符来完成该业务用例。 引发事件 和 将事件 交付给各种有界上下文以完成一个业务用例的机制是 事件编排模式。
实现健壮的事件驱动的编排架构有四个阶段:
-
注册 需要从有界上下文中提出的域事件。
-
从有界上下文中提出 需要发布的领域事件。
-
发布 从有界上下文中引发的事件。
-
订阅 其他有界上下文中已经发布的事件。
考虑到这种体系结构的复杂性,实施分为多个方面:
-
域事件的注册由聚合实现 。
-
事件的引发/发布由出站服务实现 。
-
订阅事件由接口/入站服务处理 。
由于我们正处于实现领域模型的阶段,所以我们将在这一节中涉及的唯一领域是聚合事件的注册。本章的后续部分将处理其他每个方面(出站服务将涵盖事件的引发/发布的实现,入站服务将涵盖事件订阅的实现)。
事件的登记
为了帮助实现这一点,我们将利用 Spring Data 提供的模板类“AbstractAggregateRoot”。这个模板类提供了注册发生的事件的能力。
让我们举一个例子来演示一下实现过程。清单 5-15 显示了扩展 AbstractAggregateRoot 模板类的 Cargo Aggregate 类:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
import org.springframework.data.domain.AbstractAggregateRoot;
@Entity
public class Cargo extends AbstractAggregateRoot<Cargo> {
}
Listing 5-15.AbstractAggregateRoot template class
下一步是当聚合的 状态改变 时, 实现注册的聚合事件 。正如我们之前所述和看到的 ,聚集上的命令操作改变状态 并且是 最有可能注册聚集事件 的地方。在 Cargo Aggregate 中,我们有两个命令操作:第一个是在预订新货物时,第二个是在货物发送时。聚合状态更改被放置在聚合的命令处理程序内,货物预订被放置在货物聚合的 构造器方法内, 和货物路由被放置在货物聚合的assignor route 方法内。 我们将使用 AbstractAggregateRoot 模板类提供的registerEvent()方法在这两个方法中实现聚合事件的注册和引发。
清单 5-16 展示了货物集合的 命令处理方法 中集合事件注册的实现。我们在聚合“ addDomainEvent() ”中添加了一个新方法,它是对“registerEvent()”的封装。它将一个 通用事件对象作为输入参数,该对象是需要注册的事件。 在构造函数和 assignToRoute()方法中,我们调用 addDomainEvent()方法,并注册相应的事件,即cargobookdevent和CargoRoutedEvent:
package com.practicalddd.cargotracker.bookingms.domain.model.aggregates;
import javax.persistence.*;
import org.springframework.data.domain.AbstractAggregateRoot;
@Entity
public class Cargo extends AbstractAggregateRoot<Cargo> {
/**
* Constructor - Used
for a new Cargo booking. Registers the Cargo Booked Event
* @param bookingId - Booking Identifier for the new Cargo
* @param routeSpecification - Route Specification for the new Cargo
*/
/**
* Constructor Command Handler for a new Cargo booking. Sets the state
* of the Aggregate and registers the Cargo Booked Event
*
*/
public Cargo(BookCargoCommand bookCargoCommand){
this.bookingId = new BookingId(bookCargoCommand.getBookingId());
this.routeSpecification = new RouteSpecification(
new Location(bookCargoCommand.getOriginLocation()),
new Location(bookCargoCommand.getDestLocation()),
bookCargoCommand.getDestArrivalDeadline()
);
this.origin = routeSpecification.getOrigin();
this.itinerary = CargoItinerary.EMPTY_ITINERARY; //Empty Itinerary since the Cargo has not been routed yet
this.bookingAmount = bookingAmount;
this.delivery = Delivery.derivedFrom(this.routeSpecification,
this.itinerary, LastCargoHandledEvent.EMPTY);
//Add this domain event which needs to be fired when the new cargo is saved
addDomainEvent(new
CargoBookedEvent(
new CargoBookedEventData(bookingId.getBookingId())));
}
/**
* Assigns route to the Cargo. Registers the Cargo Routed Event
* @param itinerary
*/
/**
* Command Handler
for the Route Cargo Command. Sets the state of the
* Aggregate and registers the Cargo routed event
* @param routeCargoCommand
*/
public void assignToRoute(RouteCargoCommand routeCargoCommand) {
this.itinerary = routeCargoCommand.getCargoItinerary();
// Handling consistency within the Cargo aggregate synchronously
this.delivery = delivery.updateOnRouting(this.routeSpecification,
this.itinerary);
//Add this domain event which needs to be fired when the new cargo is saved
addDomainEvent(new
CargoRoutedEvent(
new CargoRoutedEventData(bookingId.getBookingId())));
}
/**
* Method to register the event
* @param event
*/
public void addDomainEvent(Object event){
registerEvent(event);
}
}
Listing 5-16.Event registration within the Cargo root aggregate
清单 5-17 展示了cargobokedevent类的实现。封装事件数据的常规 POJO,即CargoBookedEventData:
/**
* Event Class
for the Cargo Booked Event. Wraps up the Cargo Booked Event Data
*/
public class CargoBookedEvent {
CargoBookedEventData cargoBookedEventData;
public CargoBookedEvent(CargoBookedEventData cargoBookedEventData){
this.cargoBookedEventData = cargoBookedEventData;
}
public CargoBookedEventData getCargoBookedEventData(){
return cargoBookedEventData;
}
}
Listing 5-17.CargoBookedEvent implementation class
清单 5-18 显示了 CargoBookedEventData 类的实现。这也是一个常规的 POJO,包含事件数据,在本例中只是预订 Id:
/**
* Event Data for the Cargo Booked Event
*/
public class CargoBookedEventData {
private String bookingId;
public CargoBookedEventData(String bookingId){
this.bookingId = bookingId;
}
public String getBookingId(){return this.bookingId;}
}
Listing 5-18.CargoBookedEventData implementation class
CargoRoutedEvent 和CargoRoutedEvent data的实现遵循与前面相同的方法。
图 5-25 展示了我们实现的类图。
图 5-25
聚合事件注册实现的类图
概括来说,聚合一个命令处理后的寄存器域事件。这些事件的注册总是在集合的命令处理程序方法中实现。
这就完成了域模型的实现。我们现在将继续为域模型实现域模型服务。
领域模型服务
使用域模型服务有两个主要原因。第一种是通过 明确定义的接口使有界上下文的状态 对外部方 可用。 二是 与外部方 交互,将有界上下文的状态持久化到 数据存储库 (数据库),将有界上下文的状态改变事件发布到外部 消息代理、 或 与其他有界上下文通信。
对于任何有界的上下文,有三种类型的域模型服务:
-
入站服务 其中我们实现了定义良好的接口,使外部各方能够与域模型进行交互
-
出站服务 在这里,我们实现了与外部存储库/其他有界上下文的所有交互
-
应用服务 ,它充当域模型与入站和出站服务之间的外观层
图 5-26 展示了域模型服务的实现。
图 5-26
域模型服务实现摘要
入站服务
入站服务(或六角形架构模式中表示的入站适配器)充当我们的核心域模型的最外层网关。如上所述,它涉及到定义良好的接口的实现,这些接口使外部消费者能够与核心域模型进行交互。
入站服务的类型 取决于我们需要向 公开的操作类型 启用域模型的外部消费者。
考虑到我们正在为货物跟踪应用实现微服务架构模式,我们提供两种类型的入站服务:
-
一个基于 REST 的 API 层,供外部消费者调用有界上下文上的操作( 命令/查询 )
-
基于 Spring Cloud Stream 的事件处理层,消费来自消息代理的事件并进行处理
应用接口
REST API 的职责是代表有界上下文接收来自外部消费者的 HTTP 请求。这个请求可能是命令或查询。REST API 层的职责是将其翻译成由有界上下文的域模型识别的命令/查询模型,并将其委托给应用服务层进行进一步处理。
回头看图 4-5 ,其中详细列出了各种受限上下文 的所有操作(例如,预订货物、为货物分配路线、处理货物、跟踪货物) ,所有这些操作都将有相应的 REST APIs 来接受并处理这些请求。
REST API 在 Spring Boot 的实现是通过利用 Spring Web MVC 项目提供的 REST 功能来实现的。我们添加到项目中的spring-boot-starter-web依赖项提供了构建 API 所需的功能。
让我们看一个使用 Spring Web 构建的 REST API 的例子。清单 5-19 描述了 CargoBookingController 类 ,它为我们的 Cargo Booking 命令 提供了一个 REST API:
-
REST API 可在网址“/ cargobooking ”获得。
-
它有一个 POST 方法,接受 BookCargoResource,这是 API 的输入有效负载。这个标注了“ @RequestBody ”。
-
它依赖于 CargoBookingCommandService,后者是一个应用服务,充当一个外观(参见下面的实现)。利用基于构造函数的依赖注入将这种依赖注入到 API 类中。
-
它使用汇编器实用程序类(BookCargoCommandDTOAssembler)将资源数据(BookCargoResource)转换为命令模型( BookCargoCommand )。
-
转换后,它将流程委托给 CargoBookingCommandService 进行进一步处理。
-
它向外部消费者返回一个响应,其中包含新预订货物的预订标识符。
package com.practicalddd.cargotracker.bookingms.interfaces.rest;
import com.practicalddd.cargotracker.bookingms.application.internal.commandservices.CargoBookingCommandService;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.dto.BookCargoResource;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.transform.BookCargoCommandDTOAssembler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Controller // This means that this class is a Controller
@RequestMapping("/cargobooking") // The URI of the API
public class CargoBookingController {
private CargoBookingCommandService cargoBookingCommandService; // Application Service Dependency
/**
* Provide the dependencies
* @param cargoBookingCommandService
*/
public CargoBookingController(CargoBookingCommandService cargoBookingCommandService){
this.cargoBookingCommandService = cargoBookingCommandService;
}
/**
* POST method to book a cargo
* @param bookCargoResource
*/
@PostMapping
@ResponseBody
public BookingId bookCargo(@RequestBody BookCargoResource bookCargoResource){
BookingId bookingId = cargoBookingCommandService.bookCargo(
BookCargoCommandDTOAssembler.toCommandFromDTO(bookCargoResource));
return bookingId;
}
}
Listing 5-19.CargoBookingController implementation class
清单 5-20 展示了book cargo resource类的实现:
package com.practicalddd.cargotracker.bookingms.interfaces.rest.dto;
import java.time.LocalDate;
/**
* Resource class for the Book Cargo Command API
*/
public class BookCargoResource {
private int bookingAmount;
private String originLocation;
private String destLocation;
private LocalDate destArrivalDeadline;
public BookCargoResource(){}
public BookCargoResource(int bookingAmount,
String originLocation, String destLocation, LocalDate destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 LocalDate getDestArrivalDeadline() { return destArrivalDeadline; }
public void setDestArrivalDeadline(LocalDate destArrivalDeadline) { this.destArrivalDeadline = destArrivalDeadline; }
}
Listing 5-20.CargoBookingResource implementation class
清单 5-21 展示了BookCargoCommandDTOAssembler类的实现:
package com.practicalddd.cargotracker.bookingms.interfaces.rest.transform;
import com.practicalddd.cargotracker.bookingms.domain.model.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.interfaces.rest.dto.BookCargoResource;
/**
* Assembler class to convert the Book Cargo Resource Data to the Book Cargo Model
*/
public class BookCargoCommandDTOAssembler {
/**
* Static method within the Assembler class
* @param bookCargoResource
* @return BookCargoCommand Model
*/
public static BookCargoCommand toCommandFromDTO(BookCargoResource bookCargoResource){
return new BookCargoCommand(
bookCargoResource.getBookingAmount(),
bookCargoResource.getOriginLocation(),
bookCargoResource.getDestLocation(),
java.sql.Date.valueOf(bookCargoResource.getDestArrivalDeadline()));
}
}
Listing 5-21.DTOAssembler implementation class
清单 5-22 展示了 BookCargoCommand 类的实现:
package com.practicalddd.cargotracker.bookingms.domain.model.commands;
import java.util.Date;
/**
* Book Cargo Command class
*/
public class BookCargoCommand {
private int bookingAmount;
private String originLocation;
private String destLocation;
private Date destArrivalDeadline;
public BookCargoCommand(){}
public BookCargoCommand(int bookingAmount,
String originLocation, String destLocation, Date destArrivalDeadline){
this.bookingAmount = bookingAmount;
this.originLocation = originLocation;
this.destLocation = destLocation;
this.destArrivalDeadline = destArrivalDeadline;
}
public void setBookingAmount(int bookingAmount){
this.bookingAmount = bookingAmount;
}
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 5-22.BookCargoCommand implementation class
图 5-27 展示了我们实现的类图。
图 5-27
REST API 实现的类图
我们所有的入站 REST API 实现都遵循相同的方法,如图 5-28 所示。
图 5-28
入站服务实施流程摘要
-
对命令/查询的入站请求到达 REST API。API 类是使用 Spring Web MVC 项目实现的,当我们将Spring-boot-starter-Web依赖项添加到项目中时,这个项目就会被配置。
-
REST API 类使用实用汇编器组件将资源数据格式转换为域模型所需的命令/查询数据格式。
-
命令/查询数据被发送到应用服务以供进一步处理。
事件处理程序
我们的有界上下文中存在的另一种类型的接口是事件处理程序。在有界上下文中,事件处理程序负责处理有界上下文感兴趣的事件。这些事件由应用中的其他有界上下文引发。这些“ 事件处理程序 ”是在订阅有界上下文中创建的,该上下文驻留在 入站/接口 层中。事件处理程序接收事件以及事件有效负载数据,并将它们作为常规命令操作进行处理。
事件处理程序的实现将利用 Spring Cloud Stream 提供的功能来完成。我们的消息代理将是 RabbitMQ,所以我们的实现将假设我们已经有了一个 RabbitMQ 实例并正在运行。我们不需要在 RabbitMQ 中创建任何特定的交换、目的地或队列。
我们将以跟踪有界上下文感兴趣的“ CargoRouted ”事件为例,该事件是预订有界上下文在处理 Route Cargo 命令后发布的:
-
第一步是 实现处理程序类 。handler 类被实现为一个常规的服务类,带有 "@Service "原型注释。我们使用“ @EnableBinding ”注释将服务类绑定到消息代理的通道连接。最后,我们在处理程序类中用带有目标详细信息的“@ StreamListener”注释标记事件处理程序方法。批注标记了接收发布到处理程序感兴趣的目标上的事件流的方法。
清单 5-23 展示了 CargoRoutedEventHandler 类的实现:
-
我们还需要实现代理配置,比如 代理连接细节和代理目标/目的地映射 。清单 5-24 展示了需要在 Spring Boot 应用的 application.properties 文件中实现的配置。代理配置的属性具有 RabbitMQ 在我们第一次安装它时设置的默认值:
package com.practicalddd.cargotracker.trackingms.interfaces.events;
import com.practicalddd.cargotracker.shareddomain.events.CargoRoutedEvent;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
import org.springframework.stereotype.Service;
/**
* Event Handler
for the Cargo Routed Event that the Tracking Bounded Context is interested in
*/
@Service
@EnableBinding(Sink.class) //Bind to the channel connection for the message broker
public class CargoRoutedEventHandler {
@StreamListener(target = Sink.INPUT) //Listen to the stream of messages on the destination
public void receiveEvent(CargoRoutedEvent cargoRoutedEvent) {
//Process the Event
}
}
Listing 5-23.CargoRoutedEvent handler implementation class
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.cloud.stream.bindings.input.destination=cargoRoutings
spring.cloud.stream.bindings.input.group=cargoRoutingsQueue
Listing 5-24.RabbitMQ configuration properties
目的地 配置有相同的值 ,该值在我们在 Booking Bounded 上下文中发布“CargoRouted”事件时使用(参见出站服务一节)。
图 5-29 展示了我们实现的类图。
图 5-29
我们的事件处理程序实现的类图
我们所有的事件处理器实现都遵循图 5-30 所示的相同方法。
图 5-30
事件处理程序实现的实现过程摘要
-
事件处理程序从消息代理接收入站事件。
-
事件处理程序使用实用汇编器组件将资源数据格式转换为域模型所需的命令数据格式。
-
命令数据被发送到应用服务以供进一步处理。
应用服务程序
应用服务在有界上下文中充当入站/出站服务和核心域模型之间的门面或端口。
在一个有界的上下文中,应用服务负责 接收来自入站服务的请求,并将它们委托给相应的服务, 即命令委托给 命令服务 ,查询委托给 查询服务。 作为 命令委托过程 的一部分,应用服务负责将聚合状态保存在底层数据存储中。作为查询委托过程的一部分,应用服务负责从底层数据存储中检索聚合状态。
作为这些职责的一部分,应用服务依靠 出站服务 来完成这些任务。出站服务提供连接到物理数据存储所需的必要基础架构组件。我们将分别深入探讨出站服务的实现( 参见出站服务 一节)。
图 5-31 说明了应用服务的职责。
图 5-31
应用服务的责任
应用服务:命令/查询委托
作为该职责的一部分,有界上下文中的应用服务接收处理命令/查询的请求。这些请求通常来自入站服务(API 层)。作为处理的一部分,应用服务首先利用域模型的 命令处理程序/查询处理程序 (参见域模型部分)来设置状态或查询状态。然后,它们利用出站服务来保存状态或执行对聚合状态的查询。
让我们先来看一个命令委托者应用服务类的例子,即 货物预订命令应用服务类 。这个类有两个例程——"book Cargo()"和"assignRouteToCargo()",分别处理 货物预订命令 和 路线货物命令:
-
Application services 类被实现为一个常规的 Spring 托管 Bean,带有一个“@ Service”标记注释,表明它是一个服务类。
-
通过 Spring 的构造函数依赖注入功能,为应用服务类提供了必要的依赖。在这种情况下,CargoBookingCommandApplicationService 类依赖于一个(cargo repository)。
*** 在这两个例程中,应用服务依赖于货物集合 (构造函数,assignor route)上定义的命令处理程序来设置其状态。
* 应用服务利用 CargoRepository outbound 服务来存储任一操作中货物的状态。**
**清单 5-25 演示了 货物订舱命令应用服务类 的实现:
package com.practicalddd.cargotracker.bookingms.application.internal.commandservices;
import com.practicalddd.cargotracker.bookingms.application.internal.outboundservices.acl.ExternalCargoRoutingService;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import com.practicalddd.cargotracker.bookingms.domain.model.commands.BookCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.model.commands.RouteCargoCommand;
import com.practicalddd.cargotracker.bookingms.domain.model.entities.Location;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.CargoItinerary;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.RouteSpecification;
import com.practicalddd.cargotracker.bookingms.infrastructure.repositories.CargoRepository;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Application Service class for the Cargo Booking Commands
*/
@Service
public class CargoBookingCommandService {
private CargoRepository cargoRepository;
private ExternalCargoRoutingService externalCargoRoutingService;
public CargoBookingCommandService(CargoRepository cargoRepository){
this.cargoRepository = cargoRepository;
this.externalCargoRoutingService = externalCargoRoutingService;
}
/**
* Service Command method to book a new Cargo
* @return BookingId of the Cargo
*/
public BookingId bookCargo(BookCargoCommand bookCargoCommand){
String random = UUID.randomUUID().toString().toUpperCase();
bookCargoCommand.setBookingId(random);
Cargo cargo = new Cargo(bookCargoCommand);
cargoRepository.save(cargo);
return new BookingId(random);
}
/**
* Service Command method to assign a route to a Cargo
* @param routeCargoCommand
*/
public void assignRouteToCargo(RouteCargoCommand routeCargoCommand){
Cargo cargo = cargoRepository.findByBookingId(routeCargoCommand.getCargoBookingId());
CargoItinerary cargoItinerary = externalCargoRoutingService.fetchRouteForSpecification(new RouteSpecification(
new Location(routeCargoCommand.getOriginLocation()),
new Location(routeCargoCommand.getDestinationLocation()),
routeCargoCommand.getArrivalDeadline()
));
routeCargoCommand.setCargoItinerary(cargoItinerary);
cargo.assignToRoute(routeCargoCommand);
cargoRepository.save(cargo);
}
}
Listing 5-25.CargoBookingCommand Application services class implementation
清单 5-26 展示了 货物预订查询应用服务类 的实现,它服务于与预订相关的所有查询:
package com.practicalddd.cargotracker.bookingms.application.internal.queryservices;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import com.practicalddd.cargotracker.bookingms.infrastructure.repositories.CargoRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Application Service which caters to all queries related to the Booking Bounded Context
*/
@Service
public class CargoBookingQueryService {
private CargoRepository cargoRepository; // Inject Dependencies
/**
* Find all Cargos
* @return List<Cargo>
*/
public List<Cargo> findAll(){
return cargoRepository.findAll();
}
/**
* List All Booking Identifiers
* @return List<BookingId>
*/
public List<BookingId> getAllBookingIds(){
return cargoRepository.findAllBookingIds();
}
/**
* Find a specific Cargo based on its Booking Id
* @param bookingId
* @return Cargo
*/
public Cargo find(String bookingId){
return cargoRepository.findByBookingId(bookingId);
}
}
Listing 5-26.CargoBookingQuery Application services implementation
图 5-32 说明了我们实现的类图。
图 5-32
我们的应用服务命令/查询委托的类图
我们所有负责命令/查询委托的应用服务实现都遵循相同的方法,如图 5-33 所示。
图 5-33
应用服务实施流程摘要
-
对命令/查询操作的请求通常来自入站服务层,到达有界上下文的应用服务。应用服务类 被实现为 Spring Managed bean,带有 @Service 标记注释,它们所有的 依赖项都通过构造函数注入。
-
应用服务依靠领域模型中定义的 命令处理程序/查询处理程序 来设置/查询聚合状态。
-
应用服务利用 出站服务 (例如,存储库)来保持集合的状态或在集合上执行查询。
出站服务
正如我们在前面的应用服务实现中看到的,在处理命令/查询期间,应用服务可能需要与 外部服务 进行通信,如下所示:
-
储存库 用于存储/检索有界上下文的状态
-
消息经纪人 对有界上下文的状态变化进行交流
-
其他有界语境
应用服务依靠 出站服务 来帮助进行这种通信。
出站服务提供与 这些外部服务 交互的能力。 外部服务可以是数据存储库 ,我们在其中存储有界上下文的聚合状态,它可以是 消息代理,我们在其中发布聚合状态, 或者它可以是与另一个有界上下文的 交互。
图 5-34 说明了出站服务的职责。作为操作(命令、查询、事件)的一部分,它们接收与外部服务通信的请求。它们使用基于外部服务类型的 API(持久性 API、REST APIs、代理 API)与它们进行交互。
图 5-34
出站服务
让我们看看这些出站服务类型的实现。
出站服务:存储库类
数据库访问的出站服务实现为存储库 "类。 存储库类是围绕一个特定的集合构建的,处理该集合的所有数据库操作,包括:
*** 新聚集及其关联的持久性
-
更新聚合及其关联
-
查询聚合及其关联
Spring Data JPA 帮助我们轻松实现 JPA 存储库类。让我们看一个仓库类的例子, 货物仓库类, ,它处理与 货物集合: 相关的所有数据库操作
-
货物存储库被实现为扩展 JpaRepository
接口的接口。 -
Spring Data JPA 自动实现货物集合所需的默认 CRUD 操作。
-
我们只是添加任何类型的定制查询所需的方法,这些方法被映射到在货物集合中定义的相应的命名查询。
清单 5-27 演示了 Cargo Repository 类的实现:
package com.practicalddd.cargotracker.bookingms.infrastructure.repositories;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.BookingId;
import com.practicalddd.cargotracker.bookingms.domain.model.aggregates.Cargo;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* Repository class for the Cargo Aggregate
*/
public interface CargoRepository extends JpaRepository<Cargo, Long> {
Cargo findByBookingId(String BookingId);
List<BookingId> findAllBookingIds();
List<Cargo> findAll();
}
Listing 5-27.CargoRepository JPA interface
图 5-35 说明了我们实现的类图。
图 5-35
出站服务–存储库实施
我们所有的存储库实现都遵循相同的方法。
出站服务:Rest API
使用 REST API 作为微服务之间的通信模式是一个很常见的需求。虽然我们已经将事件编排视为实现这一点的一种机制,但有时有界上下文之间的直接调用也可能是一种需求。
让我们通过一个例子来说明这一点。作为货物预订流程的一部分,我们需要根据路线规范为货物分配一个行程。生成最佳路线所需的数据作为维护船只运动、路线和时间表的路线约束上下文的一部分来维护。这需要预订受限上下文的预订服务向路由受限上下文的路由服务发出一个出站调用,路由服务提供一个 REST API 来根据货物的路线规范检索所有可能的路线。
如图 5-36 所示。
图 5-36
两个有界上下文之间的 HTTP 调用
然而,这确实对领域模型提出了挑战。预订有界上下文的货物集合将路线表示为“ ”对象,而路由有界上下文将路线表示为“ ”对象。因此,两个有界上下文之间的调用将需要在它们的域模型之间进行转换。
这种转换通常在反讹误层中完成,反讹误层充当两个有界上下文之间通信的桥梁。
如图 5-37 所示。
图 5-37
两个有界上下文之间的反腐败层
预订绑定上下文依赖于 Spring Web 提供的 Rest 模板功能来调用路由服务的 REST API。
让我们通过完整的实现来更好地理解这个概念:
- 第一步是实现路由服务 REST API。这是通过使用标准的 Spring Web 功能完成的,我们在前面的章节中已经实现了这些功能。清单 5-28 展示了路由服务 REST API 实现:
package com.practicalddd.cargotracker.routingms.interfaces.rest;
import com.practicalddd.cargotracker.TransitPath;
import com.practicalddd.cargotracker.routingms.application.internal.CargoRoutingService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Controller // This means that this class is a Controller
@RequestMapping("/cargorouting")
public class CargoRoutingController {
private CargoRoutingService cargoRoutingService; // Application Service Dependency
/**
* Provide the dependencies
* @param cargoRoutingService
*/
public CargoRoutingController(CargoRoutingService cargoRoutingService){
this.cargoRoutingService = cargoRoutingService;
}
/**
*
* @param originUnLocode
* @param destinationUnLocode
* @param deadline
* @return TransitPath - The optimal route for a Route Specification
*/
@GetMapping(path = "/optimalRoute")
@ResponseBody
public TransitPath findOptimalRoute(
@PathVariable("origin") String originUnLocode,
@PathVariable("destination") String destinationUnLocode,
@PathVariable("deadline") String deadline) {
TransitPath transitPath = cargoRoutingService.findOptimalRoute(originUnLocode,destinationUnLocode,deadline);
return transitPath;
}
}
Listing 5-28.CargoRoutingController implementation class
路由服务实现在“/ optimalRoute ”提供了一个 REST API。它接受一组规范——起始位置、目的位置和截止时间。然后,它使用货物路由应用服务类根据这些规范计算最佳路线。路由有界上下文中的域模型根据 运输路径(类似于路线) 和 运输边(类似于路线)来表示最优路由。
清单 5-29 展示了传输路径域模型类的实现:
import java.util.ArrayList;
import java.util.List;
/**
* Domain Model representation of the Transit Path
*/
public class TransitPath {
private List<TransitEdge> transitEdges;
public TransitPath() {
this.transitEdges = new ArrayList<>();
}
public TransitPath(List<TransitEdge> transitEdges) {
this.transitEdges = transitEdges;
}
public List<TransitEdge> getTransitEdges() {
return transitEdges;
}
public void setTransitEdges(List<TransitEdge> transitEdges) {
this.transitEdges = transitEdges;
}
@Override
public String toString() {
return "TransitPath{" + "transitEdges=" + transitEdges + '}';
}
}
Listing 5-29.TransitPath Domain model class implementation
清单 5-30 演示了 Transit Edge 域模型类的实现:
package com.practicalddd.cargotracker;
import java.io.Serializable;
import java.util.Date;
/**
* Represents an edge in a path through a graph, describing the route of a
* cargo.
*/
public class TransitEdge implements Serializable {
private String voyageNumber;
private String fromUnLocode;
private String toUnLocode;
private Date fromDate;
private Date toDate;
public TransitEdge() { }
public TransitEdge(String voyageNumber, String fromUnLocode,
String toUnLocode, Date fromDate, Date toDate) {
this.voyageNumber = voyageNumber;
this.fromUnLocode = fromUnLocode;
this.toUnLocode = toUnLocode;
this.fromDate = fromDate;
this.toDate = toDate;
}
public String getVoyageNumber() {
return voyageNumber;
}
public void setVoyageNumber(String voyageNumber) {
this.voyageNumber = voyageNumber;
}
public String getFromUnLocode() {
return fromUnLocode;
}
public void setFromUnLocode(String fromUnLocode) {
this.fromUnLocode = fromUnLocode;
}
public String getToUnLocode() {
return toUnLocode;
}
public void setToUnLocode(String toUnLocode) {
this.toUnLocode = toUnLocode;
}
public Date getFromDate() {
return fromDate;
}
public void setFromDate(Date fromDate) {
this.fromDate = fromDate;
}
public Date getToDate() {
return toDate;
}
public void setToDate(Date toDate) {
this.toDate = toDate;
}
@Override
public String toString() {
return "TransitEdge{" + "voyageNumber=" + voyageNumber
+ ", fromUnLocode=" + fromUnLocode + ", toUnLocode="
+ toUnLocode + ", fromDate=" + fromDate
+ ", toDate=" + toDate + '}';
}
}
Listing 5-30.TransitEdge Domain model class implementation
图 5-38 展示了实现的类图。
图 5-38
REST API 的类图
-
下一步是为我们的 Routing Rest 服务实现客户端实现。客户端是CargoBookingCommandService类,负责处理“ 给货物分配路线 ”命令。作为命令处理的一部分,这个服务类将需要调用路由服务 REST API 来获得基于货物路线规范的最佳路线。
CargoBookingCommandService 使用一个出站服务类–ExternalCargoRoutingService–来调用路由服务 REST API。ExternalCargoRoutingService类还将路由服务的 REST API 提供的数据转换成预订绑定上下文的域模型可识别的格式。
清单 5-31 演示了CargoBookingCommandService 中的方法“assignRouteToCargo”。 该服务类被注入了ExternalCargoRoutingService依赖项,该依赖项处理调用路由服务的 REST API 的请求,并返回cargo interary对象,该对象随后被分配给 cargo:
@ApplicationScoped
public class CargoBookingCommandService {
@Inject
private ExternalCargoRoutingService externalCargoRoutingService;
/**
* Service Command method
to assign a route to a Cargo
* @param routeCargoCommand
*/
@Transactional
public void assignRouteToCargo(RouteCargoCommand routeCargoCommand){
Cargo cargo = cargoRepository.find(new BookingId(routeCargoCommand.getCargoBookingId()));
CargoItinerary cargoItinerary = externalCargoRoutingService.fetchRouteForSpecification(new RouteSpecification(
new Location(routeCargoCommand.getOriginLocation()),
new Location(routeCargoCommand.getDestinationLocation()),
routeCargoCommand.getArrivalDeadline()
));
cargo.assignToRoute(cargoItinerary);
cargoRepository.store(cargo);
}
// All other implementations of Commands for the Booking Bounded Context
}
Listing 5-31.Dependencies for outbound services
清单 5-32 演示了 ExternalCargoRoutingService 出站服务类。这个类执行两件事:
-
它利用了 Spring Web 项目提供的 RestTemplate 类来帮助构建 Rest 客户端。
-
它还将路由服务的 Rest API 提供的数据(transi path,transi edge)转换为预订有界上下文的域模型(cargo internary/Leg)。
package com.practicalddd.cargotracker.bookingms.application.internal.outboundservices.acl;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.CargoItinerary;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.Leg;
import com.practicalddd.cargotracker.bookingms.domain.model.valueobjects.RouteSpecification;
import com.practicalddd.cargotracker.shareddomain.TransitEdge;
import com.practicalddd.cargotracker.shareddomain.TransitPath;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Anti Corruption Service Class
*/
@Service
public class ExternalCargoRoutingService {
/**
* The Booking Bounded
Context makes an external call to the Routing
* Service of the Routing Bounded Context to fetch the Optimal
* Itinerary for a Cargo based on the Route Specification
* @param routeSpecification
* @return
*/
public CargoItinerary fetchRouteForSpecification(RouteSpecification routeSpecification){
RestTemplate restTemplate = new RestTemplate();
Map<String,Object> params = new HashMap<>();
params.put("origin",routeSpecification.getOrigin().getUnLocCode());
params.put("destination",routeSpecification.getDestination().getUnLocCode());
params.put("arrivalDeadline",routeSpecification.getArrivalDeadline().toString());
TransitPath transitPath = restTemplate.getForObject("<<ROUTING_SERVICE_URL>>/cargorouting/",
TransitPath.class,params);
List<Leg> legs = new ArrayList<>(transitPath.getTransitEdges().size());
for (TransitEdge edge : transitPath.getTransitEdges()) {
legs.add(toLeg(edge));
}
return new CargoItinerary(legs);
}
/**
* Anti-corruption
layer conversion method from the routing service's
* domain model (TransitEdges) to the domain model recognized by the
* Booking Bounded Context (Legs)
* @param edge
* @return
*/
private Leg toLeg(TransitEdge edge) {
return new Leg(
edge.getVoyageNumber(),
edge.getFromUnLocode(),
edge.getToUnLocode(),
edge.getFromDate(),
edge.getToDate());
}
}
Listing 5-32.Outbound service implementation class
图 5-39 展示了实现的类图。
图 5-39
出站服务–REST API 实现
我们所有需要与其他有界上下文通信的出站服务实现都遵循相同的方法,如图 5-40 所示。
图 5-40
出站服务(HTTP)实施流程
-
应用服务类接收命令/查询/事件。
-
作为处理的一部分,如果它需要使用 REST 与另一个有界上下文的 API 进行交互,它会使用出站服务。
-
出站服务使用 RestTemplate 类 创建一个 Rest 客户端来调用有界上下文的 API。它还执行从该有界上下文的 API 提供的数据格式到当前有界上下文识别的数据模型的转换。
出站服务:消息代理
出站服务的最终职责是在命令处理期间引发和发布由聚合注册的域事件。
图 5-41 展示了一个有界上下文中事件流的整个机制。
图 5-41
有界上下文中的事件流机制
让我们回顾一下事件的顺序:
-
应用服务接收处理特定命令的请求(例如,预订货物、安排货物路线)。
-
应用服务将处理委托给集合命令处理器。
-
命令处理程序记录需要发布的事件(例如,货物预订、货物发送)。
-
应用服务利用出站服务的储存库来保持聚集状态。
-
存储库操作触发出站服务内的事件监听器 。事件监听器 收集所有需要发布的未决注册域事件 。
-
事件监听器将域事件发布到同一个事务内的外部消息代理(即 RabbitMQ) 。
事件监听器的实现将利用 Spring Cloud Stream 提供的功能来完成。我们的消息代理将是 RabbitMQ,所以我们的实现将假设我们已经有了一个 RabbitMQ 实例并正在运行。我们不需要在 RabbitMQ 中创建任何特定的交换、目的地或队列。
我们将继续我们的预订有界上下文的示例,其中我们需要在“ 预订货物命令 ”和“ 路线货物命令 ”的末尾发布“ 货物预订事件 ”和“ 货物路线事件 ”:
-
第一步是 实现事件源 。事件源包含事件输出通道的详细信息( 逻辑连接 )。
清单 5-33 展示了 CargoEventSource 的实现。 我们创建了两个输出消息通道( cargoBookingChannel,cargoRoutingChannel ):
-
下一步是 实现事件监听器。 事件源包含输出通道的细节( 逻辑连接 )为我们的事件。
清单 5-34 展示了CargoEventPublisherService 的实现。 这是由 Cargo Aggregate 注册并发布给消息代理的所有域事件的事件监听器。
Implementing the event listener involves the following steps:
-
事件监听器被实现为一个常规的 Spring 托管 bean,带有原型 @ 服务 注释。清单 5-34 演示了这个实现。
-
我们 使用 @EnableBinding 注释将 事件监听器绑定到我们在第一步中创建的事件源。
-
对于由 Cargo Aggregate 注册的每个域事件类型,我们在侦听器中有一个相应的处理例程,例如,CargoBookedEvent 将有一个 handleCargoBooked()例程,类似地,CargoRoutedEvent 将有一个 handleCargoRouted()例程。这些例程将注册的事件作为输入参数。
-
这些例程用@TransactionalEventListener 注释进行标记,以表明它应该是存储库操作的同一个事务的一部分。
-
最后,在例程中,我们将注册的事件发布到消息代理的相应通道。
-
package com.practicalddd.cargotracker.bookingms.infrastructure.brokers.rabbitmq;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
/**
* Interface depicting all output channels
*/
public interface CargoEventSource {
@Output("cargoBookingChannel")
MessageChannel cargoBooking();
@Output("cargoRoutingChannel")
MessageChannel cargoRouting();
}
Listing 5-33.Event source class implementation
- 除了代码实现,我们还需要实现代理配置,比如 代理连接细节和代理通道/交换映射 。清单 5-35 展示了需要在 Spring Boot 应用的 application.properties 文件中实现的配置。代理配置的属性具有 RabbitMQ 在我们第一次安装它时设置的默认值:
package com.practicalddd.cargotracker.bookingms.application.internal.outboundservices;
import com.practicalddd.cargotracker.bookingms.infrastructure.brokers.rabbitmq.CargoEventSource;
import com.practicalddd.cargotracker.shareddomain.events.CargoBookedEvent;
import com.practicalddd.cargotracker.shareddomain.events.CargoRoutedEvent;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* Transactional
Event Listener for all Cargo Aggregate Events
*/
@Service
@EnableBinding(CargoEventSource.class) //Bind to the Event Source
public class CargoEventPublisherService {
CargoEventSource cargoEventSource;
public CargoEventPublisherService(CargoEventSource cargoEventSource){
this.cargoEventSource = cargoEventSource;
}
@TransactionalEventListener //Attach it to the transaction of the repository operation
public void handleCargoBookedEvent(CargoBookedEvent cargoBookedEvent){
cargoEventSource.cargoBooking().send(MessageBuilder.withPayload(cargoBookedEvent).build()); //Publish the event
}
@TransactionalEventListener
public void handleCargoRoutedEvent(CargoRoutedEvent cargoRoutedEvent){
cargoEventSource.cargoRouting().send(MessageBuilder.withPayload(cargoRoutedEvent).build());
}
}
Listing 5-34.Event listener class implementation
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
spring.cloud.stream.bindings.cargoBookingChannel.destination=cargoBookings
spring.cloud.stream.bindings.cargoRoutingChannel.destination=cargoRoutings
Listing 5-35.RabbitMQ configuration details
所有需要发布域事件的出站服务都遵循前面列出的相同方法。
图 5-42 展示了我们实现的类图。
图 5-42
事件发布器实现的类图
这完成了出站服务、域模型服务和货物跟踪应用的实施,作为利用 DDD 原则和 Spring 平台的微服务应用。
实施摘要
我们现在有了一个完整的货物跟踪微服务应用的 DDD 实现,使用 Spring 平台中可用的相应项目实现了各种 DDD 工件。
实施总结如图 5-43 所示。
图 5-43
使用 Spring Boot 的 DDD 工件实施汇总
摘要
总结我们的章节
-
我们从建立关于 Spring 平台及其提供的各种功能的细节开始。
-
我们决定使用 Spring 平台完整产品组合中的一部分项目(Spring Boot、Spring Web、Spring Cloud Stream 和 Spring Data)来帮助构建 Cargo Tracker 作为微服务应用。
-
我们深入研究了各种 DDD 工件的开发——首先是域模型,然后是使用所选技术的域模型服务。********
六、货物跟踪器: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 框架提供的功能来实现领域模型服务,从而达到圆满的结果。***************************