天天看点

软件架构(14)-Explicit Architecture 集大成架构

作者:架构师狂飙

这篇文章是软件架构编年史的一部分,这是一系列关于软件架构的文章。在其中,我写下了我对软件架构的了解、我对它的看法以及我如何使用这些知识。如果您阅读了本系列的前几篇文章,这篇文章的内容可能会更有意义。

今天的帖子是关于我如何将所有这些许多概念和原则组合在一起的,我似乎应该给它起一个名字,我称之为Explicit Architecture。此外,这些概念都“通过了他们的战斗试验”,并在高要求平台上用于生产代码。一个是 SaaS 电子商务平台,在全球拥有数千家网店,另一个是市场,在 2 个国家/地区使用每月处理超过 2000 万条消息的消息总线。

  • 系统的基本模块
  • 工具
  • 将工具和交付机制连接到应用程序核心
  • 端口
  • 主要或驱动适配器辅助或驱动适配器控制反转
  • 应用核心组织应用层领域层领域服务领域模型
  • 成分解耦组件其他组件中的触发逻辑从其他组件获取数据组件间共享数据存储每个组件的数据存储分离
  • 控制流程

系统的基本模块

我首先回顾EBI和端口与适配器架构。它们都对应用程序的内部代码、外部代码以及用于连接内部和外部代码的代码进行了明确的区分。

此外,端口和适配器架构明确标识了系统中的三个基本代码块:

  • 是什么使得运行用户界面成为可能,无论它是什么类型的用户界面;
  • 系统业务逻辑,或应用程序核心,用户界面使用它来实际使事情发生;
  • 基础设施代码,将我们的应用程序核心连接到数据库、搜索引擎或第三方 API 等工具。
软件架构(14)-Explicit Architecture 集大成架构

应用核心才是我们真正应该关心的。它是允许我们的代码做它应该做的事情的代码,它是我们的应用程序。它可能使用多个用户界面(渐进式 Web 应用程序、移动、CLI、API 等),但实际执行工作的代码是相同的并且位于应用程序核心中,触发它的 UI 应该无关紧要。

可以想象,典型的应用程序流程是从用户界面中的代码,通过应用程序核心到基础架构代码,再回到应用程序核心,最后向用户界面提供响应。

软件架构(14)-Explicit Architecture 集大成架构

工具

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

软件架构(14)-Explicit Architecture 集大成架构

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

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

将工具连接到应用程序核心的代码单元称为适配器(Ports & Adapters Architecture)。适配器是那些有效地实现代码的适配器,这些代码将允许业务逻辑与特定工具进行通信,反之亦然。

告诉我们的应用程序做某事的适配器称为Primary 或 Driving Adapters,而我们的应用程序告诉我们做某事的适配器称为Secondary 或 Driven Adapters。

端口

然而,这些Adapters并不是随机创建的。创建它们是为了适应应用程序核心的一个非常具体的入口点,一个Port。端口无非是工具如何使用应用程序核心,或者应用程序核心如何使用它的规范。在大多数语言中,以其最简单的形式,此规范(端口)将是一个接口,但它实际上可能由多个接口和 DTO 组成。

重要的是要注意端口(接口)属于业务逻辑内部,而适配器属于外部。为了让这种模式正常工作,最重要的是创建端口以满足应用程序核心的需求,而不是简单地模仿工具 API。

主要或驱动适配器

Primary 或Driver Adapters一个 Port并使用它来告诉 Application Core 要做什么。它们将来自交付机制的任何内容转换为 Application Core 中的方法调用。

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

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

或者,端口可以是命令总线或查询总线接口。在这种情况下,Command 或 Query Bus 的具体实现被注入到 Controller 中,然后 Controller 构造一个 Command 或 Query 并将其传递给相关的 Bus。

辅助或驱动适配器

与环绕端口的驱动适配器不同,驱动适配器一个端口,一个接口,然后在需要端口(类型提示)的任何地方注入到应用程序核心中。

软件架构(14)-Explicit Architecture 集大成架构

例如,假设我们有一个需要持久化数据的简单应用程序。所以我们创建一个满足其需求的持久化接口,有一个方法来保存一个数据数组和一个方法来通过它的ID删除表中的一行。从那时起,无论我们的应用程序在哪里需要保存或删除数据,我们都需要在其构造函数中有一个对象来实现我们定义的持久化接口。

现在我们创建一个特定于 MySQL 的适配器,它将实现该接口。它将具有保存数组和删除表中一行的方法,我们将在需要持久化接口的地方注入它。

如果在某个时候我们决定更改数据库供应商,比如说 PostgreSQL 或 MongoDB,我们只需要创建一个实现持久化接口并且特定于 PostgreSQL 的新适配器,并注入新适配器而不是旧适配器。

控制反转

关于此模式需要注意的一个特征是适配器依赖于特定工具和特定端口(通过实现接口)。但是我们的业务逻辑只依赖于端口(接口),它是为了适应业务逻辑的需要而设计的,所以它并不依赖于特定的适配器或工具。

软件架构(14)-Explicit Architecture 集大成架构

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

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

应用核心组织

Onion Architecture选择 DDD 层并将它们合并到Ports & Adapters Architecture中。这些层旨在为业务逻辑带来一些组织,端口和适配器“六边形”的内部,就像在端口和适配器中一样,依赖项方向朝向中心。

应用层

用例是可以在我们的应用程序核心中由我们应用程序中的一个或多个用户界面触发的流程。例如,在 CMS 中,我们可以拥有普通用户使用的实际应用程序 UI、CMS 管理员的另一个独立 UI、另一个 CLI UI 和一个 Web API。这些 UI(应用程序)可能会触发特定于其中一个或由其中几个重用的用例。

用例在应用层中定义,应用层是 DDD 提供的第一层,由洋葱架构使用。

软件架构(14)-Explicit Architecture 集大成架构

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

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

  1. 使用存储库查找一个或多个实体;
  2. 告诉那些实体做一些领域逻辑;
  3. 并使用存储库再次持久化实体,有效地保存数据更改。

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

  1. 它们可以包含执行用例的实际逻辑;
  2. 它们可以用作我们架构中的连接件,接收命令并简单地触发应用程序服务中存在的逻辑。

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

  • 我们是否已经有了应用服务并正在添加命令总线?
  • 命令总线是否允许指定任何类/方法作为处理程序,或者它们是否需要扩展或实现现有的类或接口?

该层还包含应用程序事件的触发,它表示用例的某些结果。这些事件触发的逻辑是用例的副作用,例如发送电子邮件、通知第三方 API、发送推送通知,甚至启动属于应用程序不同组件的另一个用例。

领域层

再往里,我们有领域层。该层中的对象包含数据和操作该数据的逻辑,这是特定于域本身的,它独立于触发该逻辑的业务流程,它们是独立的并且完全不知道应用层。

软件架构(14)-Explicit Architecture 集大成架构

领域服务

正如我上面提到的,应用服务的作用是:

  1. 使用存储库查找一个或多个实体;
  2. 告诉那些实体做一些领域逻辑;
  3. 并使用存储库再次持久化实体,有效地保存数据更改。

然而,有时我们会遇到一些涉及不同实体的领域逻辑,无论是否属于同一类型,我们都觉得那个领域逻辑不属于实体本身,我们觉得那个逻辑不是他们的直接责任。

所以我们的第一反应可能是将该逻辑放在实体之外,放在应用程序服务中。然而,这意味着该领域逻辑将无法在其他用例中重用:领域逻辑应该远离应用层!

解决方案是创建一个域服务,它的作用是接收一组实体并对它们执行一些业务逻辑。领域服务属于领域层,因此它对应用层中的类一无所知,例如应用服务或存储库。另一方面,它可以使用其他领域服务,当然还有领域模型对象。

领域模型

在最中心,不依赖于它之外的任何东西,是领域模型,它包含代表领域中某些事物的业务对象。这些对象的例子首先是实体,还有值对象、枚举和域模型中使用的任何对象。

领域模型也是领域事件“存在”的地方。当一组特定的数据发生变化时会触发这些事件,并且它们会携带这些变化。换句话说,当一个实体发生变化时,会触发一个领域事件,它会携带变化的属性新值。例如,这些事件非常适合用于事件溯源。

成分

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

软件架构(14)-Explicit Architecture 集大成架构

我是“ Package by component ”方法的拥护者,根据关于Package by component 的Simon Brown 图,我会无耻地将其更改为以下内容:

软件架构(14)-Explicit Architecture 集大成架构

这些代码段横切到前面描述的层,它们是我们应用程序的组件。组件的例子可以是身份验证,授权,Billing、User、Review 或 Account,但它们始终与域相关。授权和/或身份验证等限界上下文应被视为外部工具,我们为其创建适配器并隐藏在某种端口后面。

软件架构(14)-Explicit Architecture 集大成架构

解耦组件

就像细粒度的代码单元(类、接口、特征、混合等)一样,粗粒度的代码单元(组件)也受益于低耦合和高内聚。

为了解耦类,我们使用依赖注入,通过将依赖注入到类中而不是在类内部实例化它们,以及依赖倒置,通过使类依赖于抽象(接口和/或抽象类)而不是具体类。这意味着依赖类不知道它要使用的具体类,它没有引用它所依赖的类的完全限定类名。

同样,具有完全解耦的组件意味着一个组件不直接了解任何其他组件。换句话说,它没有引用来自另一个组件的任何细粒度代码单元,甚至没有接口!这意味着依赖注入和依赖倒置不足以解耦组件,我们将需要某种架构构造。我们可能需要事件、共享内核、最终一致性,甚至是发现服务!

软件架构(14)-Explicit Architecture 集大成架构

其他组件中的触发逻辑

当我们的一个组件(组件 B)需要在另一个组件(组件 A)发生其他事情时做某事时,我们不能简单地从组件 A 直接调用组件 B 中的类/方法,因为 A 将被耦合到 B。

然而,我们可以让 A 使用事件调度器来调度一个应用程序事件,该事件将传递给任何监听它的组件,包括 B,B 中的事件监听器将触发所需的操作。这意味着组件 A 将依赖于事件分发器,但它将与 B 解耦。

然而,如果事件本身“存在于”A 中,这意味着 B 知道 A 的存在,它与 A 耦合。为了消除这种依赖性,我们可以创建一个库,其中包含一组应用程序核心功能,这些功能将在它们之间共享所有组件,共享内核. 这意味着组件都将依赖于共享内核,但它们将彼此解耦。共享内核将包含应用程序和领域事件等功能,但它也可以包含规范对象,以及任何有意义的共享,请记住它应该尽可能小,因为对共享内核的任何更改都会影响所有组件应用程序。此外,如果我们有一个多语言系统,比方说一个用不同语言编写的微服务生态系统,共享内核需要与语言无关,以便所有组件都能理解它,无论它们是用什么语言编写的. 例如,共享内核不包含事件类,而是包含事件描述(即名称、属性、甚至可能是方法,尽管这些在规范对象中更有用)在像 JSON 这样的不可知语言中,这样所有组件/微服务都可以解释它,甚至可能自动生成它们自己的具体实现。在我的后续帖子中阅读更多相关信息:多同心层数。

软件架构(14)-Explicit Architecture 集大成架构

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

从其他组件获取数据

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

组件间共享数据存储

当一个组件需要使用属于另一个组件的数据时,比方说计费组件需要使用属于帐户组件的客户名称,计费组件将包含一个查询对象,该查询对象将查询该数据的数据存储。这仅仅意味着计费组件可以了解任何数据集,但它必须通过查询将它不“拥有”的数据作为只读数据使用。

每个组件的数据存储分离

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

  • 它拥有的一组数据,并且是唯一允许更改的数据,使其成为唯一的真实来源;
  • 一组数据,它是其他组件数据的副本,它不能自行更改,但组件功能需要它,并且只要它在所有者组件中发生更改就需要更新。

每个组件将从其他组件创建它需要的数据的本地副本,以在需要时使用。当拥有它的组件中的数据发生变化时,该所有者组件将触发一个携带数据变化的领域事件。持有该数据副本的组件将监听该领域事件,并相应地更新其本地副本。

控制流程

正如我上面所说,控制流当然是从用户到应用程序核心,再到基础设施工具,再回到应用程序核心,最后回到用户。但是类究竟是如何组合在一起的呢?哪些取决于哪些?我们如何组成它们?

继 Bob 大叔之后,在他关于 Clean Architecture 的文章中,我将尝试用 UMLish 图来解释控制流……

没有命令/查询总线

在我们不使用命令总线的情况下,控制器将依赖于应用程序服务或查询对象。

我完全错过了我用来从查询返回数据的 DTO,所以我现在添加了它。感谢MorphineAdministered,他为我指出了这一点。

软件架构(14)-Explicit Architecture 集大成架构

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

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

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

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

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

  • 持久性接口是 ORM 之上的抽象层,因此我们可以在不更改应用程序核心的情况下交换正在使用的 ORM。
  • 存储库接口是对持久性引擎本身的抽象。假设我们想从 MySQL 切换到 MongoDB。持久化接口可以是相同的,而且,如果我们想继续使用相同的 ORM,甚至持久化适配器也将保持不变。然而,查询语言完全不同,所以我们可以创建新的存储库,它们使用相同的持久化机制,实现相同的存储库接口,但使用 MongoDB 查询语言而不是 SQL 来构建查询。

使用命令/查询总线

在我们的应用程序使用命令/查询总线的情况下,图表几乎保持不变,除了控制器现在依赖于总线和命令或查询。它将实例化命令或查询,并将其传递给总线,总线将找到合适的处理程序来接收和处理命令。

在下图中,命令处理程序然后使用应用程序服务。然而,这并不总是需要的,事实上在大多数情况下,处理程序将包含用例的所有逻辑。如果我们需要在另一个处理程序中重用相同的逻辑,我们只需要将逻辑从处理程序提取到一个单独的应用程序服务中。

我完全错过了我用来从查询返回数据的 DTO,所以我现在添加了它。感谢MorphineAdministered,他为我指出了这一点。

软件架构(14)-Explicit Architecture 集大成架构

您可能已经注意到总线和命令、查询或处理程序之间没有依赖关系。这是因为它们实际上应该不知道彼此,以便提供良好的解耦。总线知道什么处理程序应该处理什么命令或查询的方式应该仅通过配置来设置。

如您所见,在这两种情况下,跨越应用程序核心边界的所有箭头、依赖项都指向内部。如前所述,这是端口和适配器架构、洋葱架构和清洁架构的基本规则。

软件架构(14)-Explicit Architecture 集大成架构

结论

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

计划毫无价值,但计划就是一切。

艾森豪威尔

此信息图是一张概念图。了解和理解所有这些概念将帮助我们规划健康的架构、健康的应用程序。

尽管如此:

地图不是领土。

阿尔弗雷德·科尔兹布斯基

这意味着这些只是指南!应用程序是领域,现实,我们需要应用知识的具体用例,这将定义实际架构的外观!

继续阅读