
本文是
《软件体系结构编年史》(关于软件体系结构的一系列文章)的一部分。 在这些文章中,我写了关于软件架构的知识,对软件架构的看法以及如何使用知识。 如果您阅读本系列的前几篇文章,那么本文的内容可能更有意义。
大学毕业后,我开始担任高中老师,但几年前我辞职,去了专职软件开发人员。
从那时起,我一直觉得我需要恢复“迷路”的时间,并尽快找到尽可能多的东西。 因此,我开始参与一些实验,进行了大量的读写工作,特别关注软件的设计和体系结构。 这就是为什么我写这些文章来帮助自己学习的原因。
在上一篇文章中,我谈到了我学到的许多概念和原则,并简要介绍了我如何推理它们。 但我认为它们只是一个大难题的片段。
本文是关于如何将所有这些片段放在一起的。 我想我应该给他们起个名字,所以我称他们为
显式架构 。 此外,所有这些概念都
经过“考验”,并在高度可靠的平台上用于生产。 其中一个是SaaS电子商务平台,在全球拥有数千家在线商店,另一个是在两个国家/地区运营的交易平台,其消息总线每月处理超过2000万条消息。
系统的基本模块
让我们从
回顾EBI和
端口与适配器体系结构开始。 它们都清楚地分开了应用程序的内部和外部代码,以及用于连接内部和外部代码的适配器。
另外,
Ports&Adapters体系结构显式定义了系统中的三个基本代码块:
- 这样,无论类型如何,您都可以运行用户界面 。
- 系统业务逻辑或应用程序核心 。 UI使用它进行真实交易。
- 将我们的应用程序的核心与数据库,搜索引擎或第三方API之类的工具连接的基础结构代码。

应用程序的核心是最重要的考虑因素。 此代码使您可以在系统中执行实际操作,这就是我们的应用程序。 多个用户界面(渐进式Web应用程序,移动应用程序,CLI,API等)可以使用它,所有操作都在一个内核上运行。
可以想象,典型的执行流程从UI中的代码到应用程序核心,再到基础结构代码,再回到应用程序核心,最后将响应传递到UI。

工具
远离最重要的内核代码,仍然有应用程序使用的工具。 例如,数据库引擎,搜索引擎,Web服务器和CLI控制台(尽管后两者也是交付机制)。

将CLI控制台与DBMS放在相同的主题部分似乎很奇怪,因为它们的目的不同。 但实际上,两者都是应用程序使用的工具。 关键区别在于CLI控制台和Web服务器
告诉应用程序执行某些操作 ,相反,DBMS内核
从应用程序接收命令 。 这是非常重要的区别,因为它极大地影响了我们编写将这些工具连接到应用程序核心的代码的方式。
将工具和交付机制连接到应用程序核心
将工具连接到应用程序核心的代码块称为适配器(
Ports&Adapters体系结构 )。 它们允许业务逻辑与特定工具进行交互,反之亦然。
指示应用程序执行某些
操作的适配器称为
主适配器或控制适配器 ,而指示应用程序执行某些操作的
适配器称为
辅助适配器或托管适配器 。
港口
但是,这些
适配器不是偶然创建的,而是与应用程序核心
port中的特定入口点相对应的。 端口不过是该工具如何使用应用程序核心或反之
的规范 。 在大多数语言中,以最简单的形式,此端口将是一个接口,但实际上它可以由多个接口和DTO组成。
重要的是要注意,
端口(接口)在业务逻辑内部 ,而适配器在外部。 为了使该模板正常工作,根据应用程序核心的需求创建端口,而不仅仅是模仿工具API,这非常重要。
主适配器或控制适配器
主适配器或控制适配器
环绕端口,并使用它来告诉应用程序内核该怎么做。
他们将所有数据从传递机制转换为应用程序核心中的方法调用。
换句话说,我们的控制适配器是控制器或控制台命令,它们与某些对象一起嵌入其构造函数中,这些对象的类实现了控制器或控制台命令所需的接口(端口)。
在更具体的示例中,端口可以是控制器所需的服务接口或存储库接口。 然后,在控制器中实现并使用服务,存储库或请求的特定实现。
另外,该端口可以是命令总线或查询总线接口。 在这种情况下,将命令或请求总线的特定实现输入到控制器中,然后控制器会创建命令或请求并将其传递到相应的总线。
辅助或托管适配器
与环绕端口的控制适配器不同,
托管适配器实现端口,接口,然后进入需要端口(带有类型)的应用程序核心。

例如,我们有一个本地应用程序需要保存数据。 我们使用
保存数据数组的方法和通过表ID
删除表中
的行的方法创建一个持久性接口。 从现在开始,无论应用程序需要保存或删除数据的什么地方,我们都将在构造函数中要求一个对象,该对象实现我们定义的持久性接口。
现在创建一个将实现此接口的特定于MySQL的适配器。 它将提供保存数组和删除表中行的方法,并且我们将在需要持久性接口的任何地方引入它。
如果某个时候我们决定将数据库提供程序更改为例如PostgreSQL或MongoDB,我们只需要创建一个新适配器来实现特定于PostgreSQL的持久性接口,并引入一个新适配器即可代替旧适配器。
控制反转
该模板的一个特征是适配器依赖于特定的工具和特定的端口(通过实现接口)。 但是,我们的业务逻辑仅取决于端口(接口),该端口旨在满足业务逻辑的需求,而不依赖于特定的适配器或工具。

这意味着依赖关系是指向中心的,也就是说,
控制原理在体系结构级别上是
反向的 。
同样,尽管
必须根据应用程序核心的需求创建端口,而不仅仅是模仿工具API 。
应用程序核心的组织
Onion体系结构拾取DDD层,并将其合并到
端口和适配器体系结构中 。 这些级别旨在为端口和适配器的“六边形”内部的业务逻辑带来一些顺序。 和以前一样,依存关系的方向朝中心。
应用层(Application Layer)
用例是可以由一个或多个用户界面在内核中启动的进程。 例如,CMS可能具有一个用于普通用户的UI,另一个用于CMS管理员的独立UI,另一个CLI和Web API。 这些UI(应用程序)可以触发独特或常见的用例。
用例在应用程序级别(DDD的第一级和Onion体系结构)中定义。

该层包含作为一类对象的应用程序服务(及其接口),还包含端口和适配器接口(端口),其中包括ORM接口,搜索引擎接口,消息传递接口等。在此级别上,命令总线和/或请求总线是相应的命令和请求处理程序。
应用程序服务和/或命令处理程序包含用例(业务流程)的部署逻辑。 通常,它们的作用如下:
- 使用存储库搜索一个或多个实体;
- 要求这些实体执行一些领域逻辑;
- 并使用存储重新保存实体,从而有效地保存数据更改。
可以通过两种方式使用命令处理程序:
- 它们可能包含执行用例的逻辑。
- 它们可以用作我们体系结构中连接的简单部分,它们可以接收命令并仅调用应用程序服务中存在的逻辑。
使用哪种方法取决于上下文,例如:
- 我们已经有应用程序服务,现在正在添加命令总线吗?
- 命令总线是否允许您将类/方法指定为处理程序,还是需要扩展或实现现有的类或接口?
此层还包含触发
应用程序事件 ,这些
事件代表用例的某些结果。 这些事件触发的逻辑是用例的副作用,例如发送电子邮件,通知第三方API,发送推送通知,甚至启动属于该应用程序另一个组件的另一个用例。
域级别
在内部更进一步是一个域级别。 此级别的对象包含数据和用于管理此数据的逻辑,这些逻辑特定于域本身,并且独立于触发此逻辑的业务流程。 它们是独立的,并且完全不了解应用程序级别。

域服务
如上所述,应用程序服务的作用是:
- 使用存储库搜索一个或多个实体;
- 要求这些实体执行一些领域逻辑;
- 并使用存储重新保存实体,从而有效地保存数据更改。
但是有时我们会遇到一些领域逻辑,其中包括相同或不同类型的各种实体,并且该领域逻辑不属于这些实体本身,也就是说,逻辑不是它们的直接责任。
因此,我们的第一个反应可能是将此逻辑放在应用程序服务中的实体之外。 但是,这意味着在其他情况下,域逻辑将不会被重用:域逻辑必须保持在应用程序级别之外!
解决方案是创建一个域服务,其作用是获得一组实体并在它们上执行一些业务逻辑。 域服务属于域级别,因此对应用程序级别的类一无所知,例如应用程序服务或存储库。 另一方面,它可以使用其他域服务,当然也可以使用域模型对象。
领域模型
领域模型是最核心的。 它不依赖于此圈子之外的任何事物,并且包含代表域中事物的业务对象。 此类对象的示例首先是实体,以及值对象,枚举和域模型中使用的任何对象。
域事件也存在于域模型中。 当特定数据集更改时,将触发这些事件,其中包含更改后的属性的新值。 这些事件是理想的,例如,在事件源模块中使用。
组成部分
到目前为止,我们已经在层中隔离了代码,但是这太详细了。 以更一般的眼光看图片同样重要。 我们正在谈论根据Robert Martin在
尖叫的体系结构中表达的想法将代码划分为子域和
相关的上下文 (也就是说,体系结构应“尖叫”有关应用程序本身,而不是有关其使用的框架-大约。 跨]。 他们谈论按功能或组件而不是按层组织程序包,Simon Brown在他的博客上的
“组件程序包和体系结构测试”一文中对此进行了很好的解释:

我是组织组件包的支持者,并且希望无耻地更改Simon Brown的图,如下所示:

代码的这些部分是前面描述的所有层的交叉部分,它们是我们应用程序的
组件 。 组件示例包括计费,用户,验证或帐户,但它们始终与域关联。 有限的上下文(例如授权和/或身份验证)应被视为我们为其创建适配器并隐藏在端口后面的外部工具。

组件断开
就像在细粒度的代码单元(类,接口,特征,mixin等)中一样,大型单元(组件)也从弱耦合和紧密连接中受益。
为了分离类,我们使用依赖注入,将依赖引入类中,而不是在类内部创建依赖,并且反转依赖,使类依赖于抽象(接口和/或抽象类)而不是特定的类。 这意味着从属类对将要使用的特定类一无所知,也没有引用其所依赖类的全名。
同样,在完全断开连接的组件中,每个组件对其他任何组件都不了解。 换句话说,它没有链接到来自另一个组件的任何细粒度代码块,甚至没有链接! 这意味着依赖注入和依赖反转不足以分离组件,我们将需要某种架构构造。 可能需要事件,公共核心,最终的一致性,甚至发现服务!

其他组件中的触发逻辑
当我们的一个组件(组件B)必须在另一组件(组件A)中发生其他事情时做某事时,我们不能仅仅从组件A直接调用组件B的类/方法,因为那么A将连接到B。
但是,我们可以使用事件管理器调度应用程序事件,该事件将传递给侦听该事件的任何组件,包括B,并且B中的事件侦听器将触发所需的操作。 这意味着组件A将取决于事件管理器,但将与组件B分开。
但是,如果事件本身在A中“存在”,则意味着B知道A的存在并与其关联。 为了消除这种依赖性,我们可以创建一个库,其中包含应用程序核心的一组功能,这些功能将由所有组件(一个
公共核心)共享。 这意味着这两个组件将依赖于公共核心,但将彼此分离。 通用内核包含应用程序和域事件等功能,但也可以包含规范对象和任何有意义的共享对象。 同时,它的大小应该最小,因为公共内核中的任何更改都会影响所有应用程序组件。 此外,如果我们有一个多语言系统,例如一个用不同语言编写的微服务生态系统,则公共核心不应依赖于该语言,以便所有组件都能理解它。 例如,它将使用诸如JSON之类的通用语言而不是带有事件类的通用内核来包含事件的描述(即名称,属性,甚至方法,尽管它们在规范对象中会更有用),以便所有组件/微服务都可以解释它,并且甚至可以自动生成自己的特定实现。
这种方法适用于单片和分布式应用程序,例如微服务生态系统。 但是,如果事件只能异步传递,那么对于其他组件中的触发逻辑应立即起作用的情况,此方法还不够! 在这里,组件A将需要直接对组件B进行HTTP调用。在这种情况下,要断开组件的连接,我们需要发现服务。 组件A将询问她将请求发送到何处以启动所需的操作。 或者,向发现服务发出请求,发现服务会将其转发到适当的服务,并最终将响应返回给请求者。
此方法将组件与发现服务关联,但不将它们彼此关联。从其他组件检索数据
如我所见,该组件不允许修改它不是“拥有”的数据,但是它可以请求和使用任何数据。组件的共享数据存储
如果组件必须使用属于另一个组件的数据(例如,计费组件必须使用属于帐户组件的客户的名称),则它包含对数据存储的请求对象。也就是说,计费组件可以知道任何数据集,但必须使用其他国家/地区的只读数据。组件的独立数据存储
在这种情况下,将应用相同的模板,但是数据存储级别变得更加复杂。组件具有自己的数据仓库意味着每个数据仓库包含:- 组件拥有并可以更改的一组数据,使其成为事实的唯一来源;
- 数据集是其他组件数据的副本,它本身无法更改,但是对于组件功能而言是必需的。每当所有者组件中发生更改时,都应更新此数据。
每个组件将从其他组件创建其所需数据的本地副本,并将在需要时使用。当数据在其所属的组件中发生更改时,此所有者组件将触发一个域事件,该事件会携带数据更改。包含此数据副本的组件将侦听此域事件,并相应地更新其本地副本。控制流程
如前所述,控制流程从用户到应用程序核心,再到基础结构工具,再到应用程序核心,再回到用户。但是,各类如何一起工作呢?谁取决于谁?我们如何组成它们?像鲍勃叔叔一样,在我的“清洁架构”文章中,我将尝试解释UMLish模式管理的流程...没有命令/请求总线
如果不使用命令总线,则控制器将依赖于应用程序服务或Query对象。[Supplement 11/18/2017]我完全跳过了DTO,该DTO用于从请求中返回数据,因此现在添加了它。感谢MorphineAdministered,它指示了一个空格。 在上图中,我们将接口用于应用程序服务,尽管我们可以说并不需要,因为应用程序服务是我们应用程序代码的一部分。但是,尽管我们可以进行完整的重构,但我们不想更改实现。
Query对象包含一个优化的查询,该查询仅返回一些原始数据,这些原始数据将显示给用户。此数据返回到DTO,该DTO嵌入在ViewModel中。该ViewModel可能具有某种View逻辑,并将用于填充View。另一方面,应用程序服务包含用例逻辑,该逻辑在我们要在系统上执行某些操作时触发,而不仅仅是查看某些数据。应用程序服务取决于存储库,这些存储库返回包含需要启动的逻辑的实体。它也可能取决于域服务,以跨多个实体协调域过程,但这是一种罕见的情况。解析用例后,应用程序服务可以通知整个系统发生了用例,然后将依赖于事件分发程序来触发事件。有趣的是,我们在持久性引擎和存储库上都托管接口。这似乎是多余的,但是它们有不同的用途:- Persistence接口是ORM的抽象层,因此我们可以交换ORM而无需更改应用程序核心。
- persistence-. , MySQL MongoDB. persistence- , ORM, . , , , , , , MongoDB SQL.
C /
如果我们的应用程序使用命令/请求总线,则该图几乎保持不变,只是控制器现在依赖于总线以及命令或请求。在此创建命令或请求的实例并将其传递到总线,总线将找到用于接收和处理命令的适当处理程序。在下图中,命令处理程序使用应用程序服务。但这并不总是必需的,因为在大多数情况下,处理程序将包含用例的所有逻辑。如果我们需要在另一个处理程序中重用相同的逻辑,我们要做的就是将处理程序中的逻辑提取到单独的应用程序服务中。[Supplement 11/18/2017]我完全跳过了DTO,该DTO用于从请求中返回数据,因此现在添加了它。谢谢啦MorphineAdministered,它指示一个空格。 您可能已经注意到,总线,命令,请求和处理程序之间没有依赖关系。实际上,他们不需要彼此了解即可确保良好的分离。将总线定向到特定处理器以处理命令或请求的方法以简单的配置进行配置。在这两种情况下,所有箭头(越过应用程序内核边界的依赖项)都指向内。如前所述,这是端口和适配器,洋葱和Clean体系结构的基本规则。

结论
与往常一样,目标是获得具有高度连接性的断开连接的代码库,您可以在其中轻松,快速且安全地进行任何更改。计划没有用,但计划就是一切。- 艾森豪威尔
此信息图表是一个概念图。了解和理解所有这些概念可帮助您规划健康的体系结构和可行的应用程序。但是:地图不是领土。- 阿尔弗雷德·科奇布斯基
换句话说,这些只是建议!应用程序是一个领域,一个现实,一个特定的用例,我们需要在其中应用我们的知识,它确定了真正的体系结构是什么样子!我们必须理解所有这些模式,但也始终需要思考和理解我们的应用程序需要什么,为了分离和连接我们可以走多远。这个决定取决于许多因素,从项目的功能要求到应用程序开发的时间,其使用寿命,开发团队的经验等等。这就是我自己想象的一切。在下一篇文章中将更详细地讨论这些想法:“不仅仅是同心层。”