【软件架构】DDD,六边形,洋葱的,干净的,CQRS,我怎么把它们放在一起的
这篇文章是软件架构编年史的一部分,一系列关于软件架构的文章。在这些文章中,我写了我对软件架构的了解,我如何看待它,以及我如何使用这些知识。如果您阅读了本系列以前的文章,那么本文的内容可能更有意义。
大学毕业后,我从事了高中教师的职业,直到几年前,我决定放弃它,成为一名全职软件开发人员。
从那以后,我总是觉得我需要找回失去的时间,尽可能多地、尽可能快地学习。因此,我有点沉迷于试验、阅读和写作,特别关注软件设计和体系结构。这就是我写这些帖子的原因,来帮助我学习。
在我的上一篇文章中,我写了很多我学过的概念和原则,以及我是如何推理的。但我认为这些只是拼图的一部分。
今天的帖子是关于我如何将所有这些部分组合在一起的,我似乎应该给它起个名字,我称它为显式架构(Explicit Architecture)。此外,这些概念都“通过了它们的考验”,并被用于高要求平台上的生产代码中。一个是SaaS的e-com平台,在全球拥有数千个网络商店,另一个是市场,在两个国家都有一个消息总线,每个月处理超过2000万条消息。
- 系统的基本模块
- 工具
- 将工具和交付机制连接到应用程序核心
- 端口
- 主适配器或驱动适配器
- 辅助或被驱动适配器
- 控制反转
- 应用程序的核心组织
- 应用程序层
- 领域层
- 域服务
- 域模型
- 组件
- 解耦的组件
- 触发逻辑在其他组件
- 从其他组件获取数据
- 组件之间共享的数据存储
- 每个组件隔离数据存储
- 控制流
系统的基本模块
我首先回顾一下EBI和端口及适配器架构。它们都明确区分了哪些代码是应用程序内部的,哪些是外部的,以及哪些用于连接内部和外部代码。
此外,端口和适配器体系结构明确标识了系统中的三个基本代码块:
- 是什么使得运行一个用户界面成为可能,不管它是什么类型的用户界面;
- 系统业务逻辑,或应用程序核心,由用户界面使用,以实际使事情发生;
- 基础构架代码,它将我们的应用核心与数据库、搜索引擎或第三方api等工具连接起来。
应用程序核心是我们真正应该关心的。是代码允许我们的代码做它应该做的事情,是我们的应用程序。它可能使用多个用户界面(渐进式web应用程序、移动应用程序、CLI、API等),但是实际执行工作的代码是相同的,并且位于应用程序内核中,不管什么UI触发它,都应该是一样的。
可以想象,典型的应用程序流从用户界面中的代码开始,通过应用程序核心到基础设施代码,然后返回到应用程序核心,最后向用户界面交付响应。
工具
远离系统中最重要的代码(应用程序核心),我们拥有应用程序使用的工具,例如数据库引擎、搜索引擎、Web服务器或CLI控制台(尽管后两个也是交付机制)。
虽然将CLI控制台与数据库引擎放在同一个“bucket”中可能感觉有些奇怪,尽管它们有不同类型的用途,但它们实际上是应用程序使用的工具。关键的区别在于,虽然CLI控制台和web服务器用于告诉应用程序执行某些操作,但是数据库引擎是由应用程序执行某些操作的。这是一个非常相关的区别,因为它对我们如何构建将这些工具与应用程序核心连接起来的代码有很强的影响。
将工具和传送机制连接到应用程序核心
将工具连接到应用程序核心的代码单元称为适配器(端口和适配器体系结构)。适配器是那些有效地实现代码的适配器,这些代码将允许业务逻辑与特定的工具通信,反之亦然。
告诉我们的应用程序做某事的适配器称为主适配器或驱动适配器,而由我们的应用程序告诉我们做某事的适配器称为辅助适配器或驱动适配器。
端口
然而,这些适配器不是随机创建的。创建它们是为了将特定的入口点安装到应用程序核心(一个端口)。端口只不过是工具如何使用应用程序内核或应用程序内核如何使用它的规范。在大多数语言及其最简单的形式中,这个规范,即端口,将是一个接口,但它实际上可能由几个接口和dto组成。
需要注意的是,端口(接口)属于业务逻辑内部,而适配器属于业务逻辑外部。要使此模式正常工作,最重要的是创建适合应用程序核心需求的端口,而不是简单地模仿工具api。
主适配器或驱动适配器
主适配器或驱动适配器围绕一个端口,并使用它来告诉应用程序核心要做什么。它们将来自交付机制的任何东西转换为应用程序核心中的方法调用。
换句话说,我们的驱动适配器是控制器或控制台命令,它们在构造函数中注入一些对象,这些对象的类实现控制器或控制台命令所需的接口(端口)。
在更具体的示例中,端口可以是控制器所需的服务接口或存储库接口。然后将服务、存储库或查询的具体实现注入并在控制器中使用。
或者,端口可以是命令总线或查询总线接口。在这种情况下,将命令或查询总线的具体实现注入控制器,然后控制器构造命令或查询并将其传递给相关总线。
辅助或被驱动适配器
与围绕端口的被驱动适配器不同,驱动适配器实现一个端口和一个接口,然后将其注入到应用程序核心中,无论哪里需要端口(类型暗示)。
例如,假设我们有一个需要持久化数据的简单应用程序。所以我们创建一个持久性接口,满足其需要,用一个方法来保存数组的数据和方法来删除表中的一行的ID。从那时起,无论应用程序需要保存或删除数据,我们需要在其构造函数实现持久化的对象我们定义的接口。
现在我们创建一个特定于MySQL的适配器来实现这个接口。它将具有保存数组和删除表中的一行的方法,并且我们将在需要持久性接口的地方注入它。
如果在某个时候我们决定改变数据库供应商,比如PostgreSQL或MongoDB,我们只需要创建一个新的适配器来实现PostgreSQL特定的持久化接口,并注入新的适配器而不是旧的。
控制反转
关于此模式需要注意的一个特征是,适配器依赖于特定的工具和特定的端口(通过实现接口)。但是我们的业务逻辑只依赖于端口(接口),它被设计成适合业务逻辑需求,所以它不依赖于特定的适配器或工具。
这意味着依赖的方向是朝向中心的,这是建筑层面的控制原则的倒置。
尽管如此,创建端口是为了满足应用程序的核心需求,而不是简单地模仿工具api,这一点非常重要。
应用程序的核心组织
Onion架构采用DDD层,并将它们合并到端口和适配器架构中。这些层旨在为业务逻辑、端口和适配器的内部“六边形”带来一些组织,就像端口和适配器一样,依赖关系的方向是向中心的。
应用程序层
用例是可以由应用程序中的一个或多个用户接口在应用程序核心中触发的流程。例如,在CMS中,我们可以有普通用户使用的实际应用程序UI、CMS管理员使用的另一个独立UI、另一个CLI UI和web API。这些ui(应用程序)可以触发特定于其中一个或由其中几个重用的用例。
用例在应用层中定义,这是DDD提供的第一层,由Onion Architecture使用。
这一层包含作为第一类公民的应用程序服务(及其接口),但它也包含端口和适配器接口(端口),其中包括ORM接口、搜索引擎接口、消息传递接口等等。在我们使用命令总线和/或查询总线的情况下,这一层是命令和查询各自的处理程序所在的地方。
应用程序服务和/或命令处理程序包含展开用例(业务流程)的逻辑。一般来说,他们的职责是:
- 使用存储库查找一个或多个实体;
- 告诉那些实体去做一些域逻辑;
- 并使用存储库再次持久化实体,有效地保存数据更改。
命令处理程序可以用两种不同的方式使用:
- 它们可以包含执行用例的实际逻辑;
- 它们可以在我们的体系结构中用作简单的连接块,接收命令并简单地触发存在于应用程序服务中的逻辑。
使用哪种方法取决于上下文,例如:
我们是否已经准备好了应用程序服务并正在添加命令总线?
命令总线是否允许指定任何类/方法作为处理程序,或者它们是否需要扩展或实现现有的类或接口?
这一层还包含应用程序事件的触发,这些事件表示用例的一些结果。这些事件触发的逻辑是用例的副作用,比如发送电子邮件、通知第三方API、发送推送通知,甚至启动属于应用程序不同组件的另一个用例。
领域层
再往里,我们有域层。这个层中的对象包含数据和操作数据的逻辑,这是特定于域本身的,它独立于触发逻辑的业务流程,它们是独立的,完全不知道应用层。
域服务
如前所述,应用服务的作用是:
- 使用存储库查找一个或多个实体;
- 告诉那些实体去做一些域逻辑;
- 并使用存储库再次持久化实体,有效地保存数据更改。
然而,有时我们会遇到一些涉及不同实体的域逻辑,不管它们是否属于同一类型,我们觉得域逻辑不属于实体本身,我们觉得那个逻辑不是它们的直接责任。
因此,我们的第一反应可能是将逻辑放在实体之外的应用程序服务中。然而,这意味着该域逻辑将不能在其他用例中重用:域逻辑应该远离应用程序层!
解决方案是创建一个域服务,它的角色是接收一组实体并在其上执行一些业务逻辑。域服务属于域层,因此它对应用层中的类一无所知,比如应用程序服务或存储库。另一方面,它可以使用其他域服务,当然还有域模型对象。
域模型
在最中心的是域模型,它不依赖于它之外的任何东西,它包含表示域内某些内容的业务对象。这些对象的示例首先是实体,但也包括值对象、枚举和域模型中使用的任何对象。
域模型也是域事件“活动”的地方。当特定的一组数据发生更改时,将触发这些事件,并将这些更改随身携带。换句话说,当一个实体发生更改时,将触发一个域事件,它将携带更改后的属性新值。例如,这些事件非常适合用于事件源。
组件
到目前为止,我们一直在基于层隔离代码,但这是细粒度的代码隔离。粗粒度的代码隔离至少是同样重要的,它是根据子域和有界上下文来隔离代码的,遵循Robert C. Martin在尖叫声架构中表达的思想。这通常被称为“按功能包”或“按组件包”,而不是“按层包”,Simon Brown在他的博客“按组件包和体系结构对齐测试”中对此做了很好的解释:
我是“按组件打包”方法的倡导者,并且根据Simon Brown关于按组件打包的图表,我将无耻地将其更改为以下内容:
这些代码部分与前面描述的层是交叉的,它们是我们的应用程序的组件。组件的示例可以是身份验证、授权、计费、用户、审查或帐户,但它们始终与域相关。像授权和/或身份验证这样的有界上下文应该被视为外部工具,我们为其创建适配器并隐藏在某种端口之后。
解耦的组件
就像细粒度的代码单元(类、接口、特征、混合等)一样,粗粒度的代码单元(组件)也受益于低耦合和高内聚。
为了解耦类,我们使用依赖注入,将依赖注入到类中而不是在类中实例化,依赖倒置,使类依赖于抽象(接口和/或抽象类)而不是具体类。这意味着子类不知道它将要使用的具体类,它没有引用它所依赖的类的完全限定类名。
同样,完全解耦的组件意味着一个组件不直接知道任何其他组件。换句话说,它没有引用来自另一个组件的任何细粒度代码单元,甚至没有接口!这意味着依赖注入和依赖倒置不足以解耦组件,我们需要某种架构结构。我们可能需要事件、共享内核、最终一致性,甚至发现服务!
在其他组件触发逻辑
当我们的一个组件(组件B)需要在另一个组件(组件A)中发生其他事情时执行某个操作时,我们不能简单地从组件A直接调用组件B中的类/方法,因为这样A就会被耦合到B。
然而,我们可以使用事件分派器来分派一个应用程序事件,该应用程序事件将被交付给监听它的任何组件,包括B,而B中的事件侦听器将触发所需的操作。这意味着组件A将依赖于事件分配器,但它将与B解耦。
然而,如果事件本身“存在”于A中,这意味着B知道A的存在,它与A是耦合的。这意味着组件都依赖于共享内核,但是它们之间是解耦的。共享内核将包含应用程序和域事件之类的功能,但它也可以包含规范对象,以及任何需要共享的内容,请记住,共享内核的任何更改都将影响到应用程序的所有组件,因此共享内核应该尽可能少。此外,如果我们有一个多语言系统,假设是一个微服务生态系统,其中它们是用不同的语言编写的,那么共享内核需要是语言无关的,以便所有组件都可以理解它,无论它们是用什么语言编写的。例如,它将包含事件描述,而不是包含事件类的共享内核。名称、属性、甚至方法(尽管这些在JSON之类的不可知语言中可能更有用),这样所有组件/微服务都可以解释它,甚至自动生成它们自己的具体实现。请在我的后续文章中阅读更多相关内容:不仅仅是同心圆层。
这种方法既适用于单片应用程序,也适用于像微服务生态系统这样的分布式应用程序。然而,当事件只能异步交付时,对于需要立即在其他组件中执行触发逻辑的上下文,这种方法是不够的!组件将需要一个直接的HTTP调用组件b。在这种情况下,解耦的组件,我们需要发现服务,将要求它应该发送请求来启动所需的行动,或者使请求发现服务代理的相关服务,最终将响应返回给请求者。此方法将把组件耦合到发现服务,但将使它们彼此解耦。
从其他组件获取数据
在我看来,一个组件不允许改变它不“拥有”的数据,但是它可以查询和使用任何数据。
组件之间共享的数据存储
当一个组件需要使用属于另一个组件的数据时,假设一个账单组件需要使用属于accounts组件的客户端名称,账单组件将包含一个查询对象,该对象将查询该数据的数据存储。这仅仅意味着账单组件可以知道任何数据集,但是它必须通过查询的方式将不“拥有”的数据作为只读数据使用。
每个组件隔离数据存储
在本例中,应用了相同的模式,但是我们在数据存储级别上更加复杂。组件拥有自己的数据存储意味着每个数据存储包含:
- 它拥有的一组数据,并且是唯一允许更改的数据,使其成为唯一的真理来源;
- 一组数据是其他组件数据的副本,它不能自己更改这些数据,但是组件功能需要它,并且需要在所有者组件中发生更改时对其进行更新。
每个组件将从其他组件创建所需数据的本地副本,以便在需要时使用。当拥有该组件的组件中的数据发生更改时,该所有者组件将触发承载数据更改的域事件。持有该数据副本的组件将侦听该域事件,并相应地更新其本地副本。
控制流
正如我上面所说的,控制流当然是从用户到应用程序核心,再到基础设施工具,最后回到应用程序核心,最后回到用户。但是类到底是如何组合在一起的呢?哪些取决于哪些?我们如何组合它们?
在Bob叔叔关于干净架构的文章中,我将尝试用UMLish图来解释控制流……
没有命令/查询总线
在我们不使用命令总线的情况下,控制器将依赖于应用程序服务或查询对象。
[编辑- 2017-11-18]我完全错过了我用来从查询返回数据的DTO,所以我现在添加了它。感谢MorphineAdministered公司为我指出了这一点。
在上面的图中我们使用应用程序的接口服务,尽管我们可能认为这不是真正需要从应用程序服务是我们应用程序代码的一部分,我们不会想交换另一个实现,尽管我们可能完全重构它。
查询对象将包含一个优化的查询,该查询将简单地返回一些原始数据以显示给用户。该数据将以DTO的形式返回,并注入到ViewModel中。这个视图模型可能有一些视图逻辑,它将被用来填充一个视图。
另一方面,应用程序服务将包含用例逻辑,当我们希望在系统中执行某些操作时,而不是简单地查看某些数据时,将触发该逻辑。应用程序服务依赖于存储库,存储库将返回包含需要触发的逻辑的实体。它还可能依赖于域服务来协调多个实体中的域流程,但情况并非如此。
在展开用例之后,应用程序服务可能希望通知整个系统该用例已经发生,在这种情况下,它还将依赖于事件分派器来触发事件。
值得注意的是,我们在持久性引擎和存储库上都放置了接口。虽然看起来有些多余,但它们有不同的用途:
- 持久性接口是ORM上的一个抽象层,因此我们可以交换正在使用的ORM,而不需要更改应用程序的核心。
- repository接口是对持久性引擎本身的抽象。假设我们想从MySQL切换到MongoDB。持久性接口可以是相同的,如果我们想继续使用相同的ORM,那么即使是持久性适配器也可以保持不变。但是,查询语言是完全不同的,所以我们可以创建使用相同持久性机制的新存储库,实现相同的存储库接口,但是使用MongoDB查询语言而不是SQL构建查询。
使用命令/查询总线
在我们的应用程序使用命令/查询总线的情况下,除了控制器现在依赖于总线和命令或查询外,关系图几乎保持不变。它将实例化命令或查询,并将其传递给总线,总线将找到适当的处理程序来接收和处理命令。
在下面的关系图中,命令处理程序然后使用应用程序服务。然而,这并不总是需要的,事实上在大多数情况下,处理程序将包含用例的所有逻辑。如果需要在另一个处理程序中重用相同的逻辑,则只需要将逻辑从处理程序提取到单独的应用程序服务中。
[编辑- 2017-11-18]我完全错过了我用来从查询返回数据的DTO,所以我现在添加了它。感谢MorphineAdministered公司为我指出了这一点。
您可能已经注意到,总线与命令、查询和处理程序之间没有依赖关系。这是因为,为了提供良好的解耦,它们实际上应该彼此不了解。总线知道什么处理程序应该处理什么命令或查询的方式应该通过简单的配置来设置。
如您所见,在这两种情况下,跨越应用程序核心边界的所有箭头和依赖项都指向内部。如前所述,这是端口和适配器体系结构、Onion体系结构和Clean体系结构的基本规则。
结论
一如既往,我们的目标是拥有一个松散耦合和高内聚的代码库,这样修改起来就容易、快速和安全。
计划是没有价值的,但计划就是一切。
艾森豪威尔
这个信息图是一个概念图。了解和理解所有这些概念将帮助我们规划一个健康的架构,一个健康的应用程序。
然而:
地图不是领土。
阿尔弗雷德Korzybski
这意味着这些只是指导方针!应用程序是我们需要应用知识的领域、现实和具体用例,这就是定义实际体系结构的内容!
我们需要理解所有这些模式,但是为了解耦和内聚,我们还需要思考并准确地理解我们的应用程序需要什么,我们应该走多远。这个决策可以依赖于许多因素,从项目功能需求开始,但是也可以包括诸如构建应用程序的时间框架、应用程序的生命周期、开发团队的经验等因素。
就是这样,这就是我理解这一切的方式。这就是我在脑海里给它找的合理解释。
我在后续的文章中进一步扩展了这些想法:不仅仅是同心圆层。
但是,我们如何在代码库中显式地实现这一切呢?这是我下一篇文章的主题:如何在代码中反映体系结构和域。
讨论:请加入知识星球【首席架构师圈】
- 410 次浏览