DDD与Hexagonal, Onion, Clean, CQRS 实现地表最强架构

Java有货

共 6659字,需浏览 14分钟

 · 2022-01-15


DDD, Hexagonal, Onion, Clean, CQRS 整合



系统的基本模块



我首先回顾了EBIPorts & Adapters架构(如果不了解可以点击进行查看)。它们都显式分离了应用程序内部的代码、外部的代码以及用于连接内部和外部代码的内容。

此外Ports & Adapters架构明确标识了系统中的三个基本代码块:

  • 是什么使运行用户界面成为可能,无论它是什么类型的用户界面;

  • 系统业务逻辑,或应用程序核心,由用户界面用来实际实现事情;

  • 基础架构代码,将我们的应用程序核心连接到数据库、搜索引擎或第三方 API 等工具。

应用程序核心是我们真正应该关心的。正是代码允许我们的代码做它应该做的事情,它是我们的应用程序。它可能使用多个用户界面(渐进式Web应用程序,移动,CLI,API等),但实际执行工作的代码是相同的,并且位于应用程序核心中,什么UI触发它并不重要。
可以想象,典型的应用程序流从用户界面中的代码,通过应用程序核心到基础结构代码,再回到应用程序核心,最后向用户界面提供响应。

工具


远离我们系统中最重要的代码,即应用程序核心,我们有应用程序使用的工具,例如,数据库引擎,搜索引擎,Web服务器或CLI控制台(尽管最后两个也是交付机制)

虽然将 CLI 控制台放在与数据库引擎相同的"存储桶"中可能会感觉很奇怪,尽管它们具有不同类型的用途,但它们实际上是应用程序使用的工具。关键的区别在于,虽然 CLI 控制台和 Web 服务器用于指示我们的应用程序执行某些操作,但数据库引擎由我们的应用程序指示执行某些操作。这是一个非常相关的区别,因为它对我们构建将这些工具与应用程序核心连接起来的代码有很大的影响。


将工具和交付机制连接到应用程序核心



将工具连接到应用程序核心的代码单元称为适配器(端口和适配器体系结构)。适配器是有效实现代码的适配器,这些代码将允许业务逻辑与特定工具进行通信,反之亦然。
指示应用程序执行某些操作的适配器称为主适配器或驱动适配器,而应用程序指示执行某些操作的适配器称为辅助或驱动适配器
但是,这些适配器*不是随机创建的。创建它们是为了将非常具体的入口点适合应用程序核心,即 端口*。端口只不过是工具如何使用应用程序核心或应用程序核心如何使用它的规范*。在大多数语言中,以最简单的形式,这个规范,端口,将是一个接口,但它实际上可能由几个接口和DTO组成。


请务必注意,端口(接口)属于业务逻辑内部,而适配器属于外部。要使此模式正常工作,创建端口以满足应用程序核心需求,而不仅仅是模仿工具 API,这一点至关重要。


主适配器或驱动程序适配器环绕端口,并使用它来告诉应用程序核心要执行的操作。它们将来自传递机制的任何内容转换为应用程序核心中的方法调用。

换句话说,我们的驱动适配器是控制器或控制台命令,它们在其构造函数中注入了某个对象,这些对象的类实现了控制器或控制台命令所需的接口(Port)。


在更具体的示例中,Port 可以是服务接口,也可以是控制器所需的存储库接口。然后,服务、存储库或查询的具体实现被注入并在控制器中使用。


或者,端口可以是命令总线或查询总线接口。在这种情况下,命令或查询总线的具体实现被注入到控制器中,然后控制器构造一个命令或查询并将其传递给相关的总线。
与环绕端口的驱动程序适配器不同,驱动适配器实现端口,接口,然后注入应用程序核心,无论需要端口的位置(类型提示)。

例如,假设我们有一个需要持久化数据的朴素应用程序。因此,我们创建了一个满足其需求的持久性接口,其中包含一个保存数据数组的方法和一个按 ID删除表中行的方法。从那时起,无论我们的应用程序需要保存或删除数据,我们都需要在其构造函数中实现我们定义的持久性接口的对象。
现在,我们创建了一个特定于MySQL的适配器,它将实现该接口。它将具有保存数组和删除表中的行的方法,我们将在需要持久性接口的任何地方注入它。
如果在某些时候我们决定更改数据库供应商,比如PostgreSQL或MongoDB,我们只需要创建一个实现持久性接口并特定于PostgreSQL的新适配器,并注入新适配器而不是旧适配器。
关于此模式需要注意的一个特征是适配器依赖于特定工具和特定端口(通过实现接口)。但是我们的业务逻辑仅依赖于端口(接口),该端口(接口)旨在满足业务逻辑需求,因此它不依赖于特定的适配器或工具。

这意味着依赖关系的方向是朝向中心,这是架构级别的控制原则的反转

不过,同样重要的是,创建端口是为了满足应用程序核心需求,而不是简单地模仿工具 API。

应用程序核心组织



Onion Architecture 拾取DDD层,并将它们合并到端口和适配器架构中。这些层旨在为业务逻辑,端口和适配器的内部"六边形"带来一些组织,就像在端口和适配器中一样,依赖方向是朝向中心。
用例是可以在应用程序核心中由应用程序中的一个或多个用户界面触发的进程。例如,在 CMS 中,我们可以拥有普通用户使用的实际应用程序 UI、CMS 管理员的另一个独立 UI、另一个 CLI UI 和 Web API。这些 UI(应用程序)可能会触发特定于其中一个或由其中几个重用的用例。
用例在应用层中定义,这是由 DDD 提供并由 Onion 架构使用的第一层。

该层包含作为一等公民的应用程序服务(及其接口),但它也包含端口和适配器接口(端口),其中包括ORM接口,搜索引擎接口,消息传递接口等。在我们使用命令总线和/或查询总线的情况下,此层是命令和查询的相应处理程序所属的位置。

应用程序服务和/或命令处理程序包含展开用例和业务流程的逻辑。通常,他们的作用是:

  1. 使用存储库查找一个或多个实体;

  2. 告诉这些实体做一些领域逻辑;

  3. 并使用存储库再次保留实体,从而有效地保存数据更改。

命令处理程序可以通过两种不同的方式使用:

  1. 它们可以包含执行用例的实际逻辑;

  2. 它们可以用作我们架构中的简单连接部分,接收命令并简单地触发应用程序服务中存在的逻辑。

使用哪种方法取决于上下文,例如:

  • 我们是否已经拥有应用程序服务,并且现在正在添加命令总线?

  • 命令总线是否允许将任何类/方法指定为处理程序,或者它们是否需要扩展或实现现有的类或接口?

该层还包含应用程序事件的触发,这些事件表示用例的一些结果。这些事件触发的逻辑是用例的副作用,例如发送电子邮件、通知第三方 API、发送推送通知,甚至启动属于应用程序不同组件的另一个用例。
再往内,我们有域层。此层中的对象包含数据和操作该数据的逻辑,这些数据特定于 Domain 本身,并且独立于触发该逻辑的业务流程,它们是独立的,并且完全不知道应用程序层。

域名服务

正如我上面提到的,应用程序服务的角色是:

  1. 使用存储库查找一个或多个实体;

  2. 告诉这些实体做一些领域逻辑;

  3. 并使用存储库再次保留实体,从而有效地保存数据更改。

然而,有时我们会遇到一些涉及不同实体的领域逻辑,无论类型与否相同,我们觉得领域逻辑不属于实体本身,我们觉得这种逻辑不是他们的直接责任。
因此,我们的第一反应可能是将该逻辑放在实体外部,即应用程序服务中。但是,这意味着域逻辑在其他用例中将不可重用:域逻辑应远离应用层!
解决方案是创建一个域服务,其角色是接收一组实体并对其执行一些业务逻辑。域服务属于域层,因此它对应用程序层中的类(如应用程序服务或存储库)一无所知。另一方面,它可以使用其他域服务,当然还有域模型对象。

域模型

在最中心,依赖于它之外的任何东西,是域模型,它包含表示域中某些内容的业务对象。这些对象的示例首先是实体,还包括值对象、枚举和域模型中使用的任何对象。
域模型也是域事件"生存"的位置。当一组特定的数据发生更改时,将触发这些事件,并且它们会随身携带这些更改。换句话说,当实体发生更改时,将触发域事件,并且它携带更改的属性新值。例如,这些事件非常适合在事件溯源中使用。

组件



到目前为止,我们一直在根据层来隔离代码,但这是细粒度的代码隔离。代码的粗粒度分离至少同样重要,它是关于根据子域和有界上下文来分离代码,遵循罗伯特·C·马丁(Robert C. Martin)在尖叫的架构中表达的想法。这通常被称为"逐个功能打包"或"逐个组件打包",而不是"逐层打包",Simon Brown在他的博客文章"逐个组件和架构对齐的测试"中对此进行了很好的解释:


这些代码部分与前面描述的层交叉切割,它们是我们应用程序的组件。组件的示例可以是~~身份验证、授权、计费、~~用户、审阅或帐户,但它们始终与域相关。像授权和/或身份验证这样的有界上下文应该被视为外部工具,我们为其创建适配器并隐藏在某种端口后面。

解耦组件

就像细粒度的代码单元(类、接口、特性、mixins 等)一样,粗粒度的代码单元(组件)也受益于低耦合和高内聚性。
为了解耦类,我们利用依赖注入,通过将依赖项注入到类中而不是在类内实例化它们,以及依赖关系反转,使类依赖于抽象(接口和/或抽象类)而不是具体的类。这意味着依赖类对它将要使用的具体类一无所知,它没有对它所依赖的类的完全限定类名的引用。
同样,拥有完全解耦的组件意味着一个组件对任何其他组件没有直接了解。换句话说,它没有引用来自另一个组件的任何细粒度代码单元,甚至没有接口!这意味着依赖注入和依赖关系反转不足以解耦组件,我们将需要某种架构结构。我们可能需要事件、共享内核、最终一致性,甚至是发现服务!

触发其他组件中的逻辑

当我们的一个组件(组件 B)需要在另一个组件(组件 A)中发生其他操作时执行某些操作时,我们不能简单地从组件 A 直接调用组件 B 中的类/方法,因为这样 A 就会耦合到 B。
但是,我们可以让 A 使用事件调度程序来调度应用程序事件,该事件将传递到侦听它的任何组件(包括 B),并且 B 中的事件侦听器将触发所需的操作。这意味着组件 A 将依赖于事件调度程序,但它将与 B 分离。
然而,如果事件本身"存在于"A中,这意味着B知道A的存在,它被耦合到A。要删除此依赖项,我们可以创建一个库,其中包含一组应用程序核心功能,这些功能将在所有组件之间共享,共享内核。这意味着这些组件都将依赖于共享内核,但它们将彼此分离。共享内核将包含应用程序和域事件等功能,但它也可以包含规范对象以及任何有意义的共享对象,请记住,它应该尽可能少,因为对共享内核的任何更改都会影响应用程序的所有组件。此外,如果我们有一个多语言系统,比如说一个用不同语言编写的微服务生态系统,共享内核需要与语言无关,以便所有组件都可以理解它,无论它们用什么语言编写。例如,它将以不可知语言(如 JSON)包含事件描述(即名称、属性,甚至可能方法,尽管这些在规范对象中更有用),而不是包含 Event 类的共享内核,以便所有组件/微服务都可以解释它,甚至可能自动生成自己的具体实现。在我的后续帖子中阅读更多相关信息:不仅仅是同心图层。

种方法既适用于整体式应用程序,也适用于分布式应用程序,如微服务生态系统。但是,当事件只能异步传递时,对于需要立即执行其他组件中的触发逻辑的上下文,这种方法是不够的!组件 A 需要对组件 B 进行直接 HTTP 调用。在这种情况下,要使组件解耦,我们将需要一个发现服务,A 将询问它应该将请求发送到何处以触发所需的操作,或者向发现服务发出请求,该发现服务可以将其代理到相关服务并最终将响应返回给请求者。此方法会将组件耦合到发现服务,但会使它们彼此分离。


从其他组件获取数据

在我看来,组件不允许更改它不"拥有"的数据,但是它可以查询和使用任何数据。

在组件之间共享数据存储

当一个组件需要使用属于另一个组件的数据时,假设一个计费组件需要使用属于该帐户组件的客户端名称,则计费组件将包含一个查询对象,该对象将查询数据存储中的该数据。这仅仅意味着计费组件可以知道任何数据集,但它必须通过查询的方式将其不"拥有"的数据用作只读数据。

每个组件隔离的数据存储

在这种情况下,相同的模式适用,但我们在数据存储级别具有更大的复杂性。拥有具有自己的数据存储的组件意味着每个数据存储都包含:

它拥有的一组数据,是唯一允许更改的数据,使其成为唯一的事实来源;

一组数据,它是其他组件数据的副本,它不能自行更改,但组件功能需要这些数据,并且每当它在所有者组件中发生更改时都需要更新。

每个组件都将从其他组件创建所需数据的本地副本,以便在需要时使用。当拥有它的组件中的数据发生更改时,该所有者组件将触发承载数据更改的域事件。保存该数据副本的组件将侦听该域事件,并相应地更新其本地副本。


控制流程



正如我上面所说,控制流当然是从用户到应用程序核心,再到基础设施工具,再回到应用程序核心,最后回到用户。但是类究竟是如何组合在一起的呢?哪些取决于哪些?我们如何组合它们?
继鲍勃叔叔之后,在他关于清洁架构的文章中,我将尝试用 UMLish 图表来解释控制流程……


没有命令/查询总线

在我们不使用命令总线的情况下,控制器将依赖于应用程序服务或查询对象。
[编辑 – 2017-11-18 ] 我完全错过了用于从查询中返回数据的 DTO,所以我现在添加了它。Tkx 给MorphineAdministered,他为我指出了这一点


在上图中,我们为应用程序服务使用了一个接口,尽管我们可能会争辩说它并不是真正需要的,因为应用程序服务是我们应用程序代码的一部分,我们不希望将它换成另一个实现,尽管我们可能会对其进行重构完全。

Query 对象将包含一个优化的查询,该查询将简单地返回一些要显示给用户的原始数据。该数据将在 DTO 中返回,该 DTO 将被注入到 ViewModel 中。ThisViewModel 中可能有一些视图逻辑,它将用于填充视图。

另一方面,应用程序服务将包含用例逻辑,当我们想在系统中做某事时我们将触发的逻辑,而不是简单地查看一些数据。应用程序服务依赖于将返回包含需要触发的逻辑的实体的存储库。它还可能依赖于域服务来协调多个实体中的域进程,但情况几乎不是这样。

在展开用例之后,应用程序服务可能想要通知整个系统该用例已经发生,在这种情况下,它还将依赖事件调度程序来触发事件。

有趣的是,我们在持久性引擎和存储库上都放置了接口。虽然看起来多余,但它们有不同的用途:

  • 持久性接口是 ORM 之上的一个抽象层,因此我们可以在不更改应用程序核心的情况下交换正在使用的 ORM。

  • 存储库接口是对持久性引擎本身的抽象。假设我们想从 MySQL 切换到 MongoDB。持久化接口可以相同,如果我们想继续使用相同的 ORM,即使持久化适配器也将保持不变。但是,查询语言完全不同,因此我们可以创建使用相同持久性机制的新存储库,实现相同的存储库接口,但使用 MongoDB 查询语言而不是 SQL 构建查询。

使用命令/查询总线

在我们的应用程序使用命令/查询总线的情况下,图表几乎保持不变,只是控制器现在依赖于总线和命令或查询。它将实例化命令或查询,并将其传递给总线,总线将找到适当的处理程序来接收和处理命令。
在下图中,命令处理程序然后使用应用程序服务。但是,这并不总是需要,事实上在大多数情况下,处理程序将包含用例的所有逻辑。如果我们需要在另一个处理程序中重用相同的逻辑,我们只需要从处理程序中提取逻辑到一个单独的应用程序服务中。

[编辑 – 2017-11-18 ] 我完全错过了用于从查询中返回数据的 DTO,所以我现在添加了它。Tkx 给MorphineAdministered,他为我指出了这一点

你可能已经注意到总线和命令、查询和处理程序之间没有依赖关系。这是因为它们实际上应该不知道彼此以提供良好的解耦。Bus 知道哪个 Handler 应该处理什么 Command 或 Query 的方式应该仅通过配置进行设置。
如您所见,在这两种情况下,跨越应用程序核心边界的所有箭头(即依赖项)都指向内部。如前所述,这是端口和适配器架构、洋葱架构和清洁架构的基本规则。

结论



与往常一样,目标是拥有一个松散耦合和高内聚的代码库,以便轻松、快速且安全地进行更改。



添加小编微信:372787553,备注:进群 
 带您进入Java技术交流群


浏览 8
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报