距离 Jeffrey Palermo发布他关于洋葱架构(Onion Architecture)系列的第一篇博客已经接近15年了。在那篇博文中,他总结了一些实际上是Alistair Cockburn提出的六边形架构(Hexagonal Architecture)方法的延续的想法。尽管我一直认为这两种代码组织方法并不一定构成“架构”,但我始终发现它们对于形成一个关于如何构建代码基础结构的心智模型非常有帮助。多年来,我见过很多团队试图遵循这些模型,并在实践中遇到了一些误解和问题。在本博客文章中,我想总结其中的一些发现,并提出一种对洋葱架构的更加精炼的看法。

洋葱架构(Onion Architecture)

让我们首先简要回顾一下这种方法的原始思想。代码被组织成同心圆,以领域为中心。代码库的这部分应该包含所有基本的领域抽象。然后,这个核心被一个应用层包围,顾名思义,其中包含特定于应用的代码。杰弗里的原始提议甚至将该层分为“领域服务”和“应用服务”,这通常在团队内引发很多关于哪些代码属于哪个层的讨论。现在,我将坚持使用简化的“应用”层。它的目的是将用例或工作单元中的应用逻辑暴露给形成一致范围的其他代码。

洋葱架构(简化版)

onion-architecture-green.jpeg

然后,这个环被基础设施(infrastructure)环包围,其中包含将应用程序特定接口转换为特定技术的代码。在暴露方面,这可能是渲染网页的控制器或生成适用于特定类型API(例如JSON)的表示。此外,连接器到数据库或消息代理也位于该环中。通过这种方式,Jeffrey抽象了六边形架构中Alistair引入的不同类型适配器(驱动与被驱动)。

谜题的最后一部分是定义环之间的依赖方向:外部环依赖于内部环,为其依赖者提供要调用(与应用程序服务一起工作的控制器controller)或实现(应用程序环暴露的存储库Repository接口的特定数据库实现)的接口。虽然这种方法的严格解释表明环可能只依赖于下一个内部环,但在实践中,通常允许跳过环,因为强制执行严格的分层将导致样板代码

问题

现在我们对这种方法有了共同的理解,让我们来看看其中的挑战。

洋葱架构(Onion Architecture)和六边形架构(Hexagonal Architecture)的一个核心问题是,它们将领域视为一个单一的、不透明的块。这可能是这些架构起源时间的证明,它们都非常关注将领域代码与基础设施代码分离。当时,在企业中混合这两者导致了显著的代码质量问题,主要是由于不分离这两个方面的代码将无法测试。

然而,早在2000年代初发布的Eric Evans的《领域驱动设计》中就已经指出,而且在过去几年中这一点变得更加明显:代码库中质量问题的主要原因是与业务不一致,并存在反映这一点的功能分离。这引发了一个问题,即是否应该遵循一种基本上忽视主要挑战的架构方法来构建你的代码库。

另一个稍微更技术性的问题是与基础设施环中的抽象提升相比,与六边形架构相比。在该环中,既有API/网页适配器,又有数据库/消息代理适配器,这表明它们的实现方式有某种统一性。这通常体现在通过将不同模型映射到彼此来解耦与目标基础设施的关系。

在数据库方面,领域模型被映射到持久化模型,后者又被映射到特定于数据库的结构。在某种情况下,这是一种有效的方法,即目标数据存储由不同的应用程序共享,并且开发一种应用程序的团队暴露于对该存储进行更改的情况。这在2000年代中期是相当普遍的情况。如今,应用程序通常拥有自己的数据存储,我们可以选择更简单的持久化方法。在欲将不同模型之间进行映射的需求上,我们常常优先考虑将模型保持简单,并使模型的变更立即反映在衍生的其他模型上。例如,我们希望在领域模型中重命名聚合中的属性时,数据库迁移(database migration)中的反映也能立即重命名列名,以避免以后的认知负担。

在基础设施的暴露方面,这是完全不同的讨论,因为我们不能任意更改目标模型。我们可能甚至不知道所有的API使用者,并且更希望尽可能地保护它们免受我们内部的更改。能够区分这两种情况有助于避免大量样板代码,特别是在持久化实现的设计中。当然,在洋葱架构中,没有什么阻止我们进行这种区分,但是将所有基础设施放入同一个环中暗示了一种可能具有误导性的统一性。

最后,来综合考虑一下技术挑战,将所有基础设施适配器分配给同一个逻辑桶(bucket)将使它们不受环之间定义的受限依赖方向的约束。Repository实现可能依赖于Controller,而这种架构方法却没有捕捉到这一点。

领域结构化

正如我之前所描述的,构建易于维护的软件系统的一个基本挑战是功能架构要支持我们为应用程序构建的业务需求。为了让事情简单起见,让我们现在使用术语“领域”来表示这个功能架构中的个体元素。如果你遵循领域驱动设计(Domain-Driven Design),这将映射到一个有界上下文(Bounded Context)或模块。如果我们需要处理多个领域,那么使用洋葱架构将会是什么样子?这对领域之间的交互意味着什么?

洋葱架构 - 多个领域

onion-architecture-multiple-domains.jpeg

一个想法是将功能架构仅应用于我们安排的领域核心,如上所示。我们可以将领域驱动设计应用于我们的整体领域,识别其中的不同部分,并定义它们之间允许的依赖方向。

虽然这肯定比以前要好,但我们所有的应用程序和基础设施代码仍然是一个不透明的整体,缺乏我们现在在架构核心已经建立的结构。如果我们扩展这个想法,并为每个其他环进行类似的结构化练习,最终我们将在每个领域中得到一个洋葱架构。

洋葱架构 - 每个领域一个洋葱

onion-architecture-multiple.jpeg

这是朝着正确方向迈出的一步。每个洋葱架构都是自包含的,专注于一个领域,并拥有它们自己的应用程序接口和技术适配器。然而,现在个别领域之间的交互必须通过基础设施来建立。如果我们决定将洋葱映射到单独的应用程序,比如在微服务架构中,这可能是一个不错的选择。

根据领域的粒度水平,这可能不是我们将逻辑架构投射到“物理”世界中的最优方案,它还会引入许多复杂性和交互成本。为了在系统之间交换一个简单的领域事件,我们必须准备相应的基础设施(例如消息代理),并在发送方对事件进行序列化,在接收方对其进行反序列化。

总结而言,原始洋葱架构中对领域缺乏关注显然是有问题的,解决这个问题的方法要么不令人满意,要么引入了复杂性并促使采用特定的部署方式。是时候将其提升到一个新的层次了。

切片洋葱架构

我对洋葱架构提出的根本增强是削减洋葱的边缘。虽然一开始可能听起来很琐碎,但这个想法对于概念的定义准确性和在实际应用中的适用性有着重要的影响。

切片洋葱架构

soa.jpeg

首先,通过削减洋葱的边缘,重新建立了将应用程序概念映射到外部客户端(通过API)的代码和将其映射到内部基础设施(例如数据库和消息代理)的代码之间的概念分离。我们还默认地确定了每个部分在默认情况下彼此完全不交互。应用层仍然保护着每个独立的领域。

这样一来,我们可以按照原始想法将其暴露给基础设施,同时在其两侧开放,以便与类似结构的其他实体(见下文)和测试之间进行低摩擦的交互。在原始的洋葱架构方法中,它们位于基础设施层,这在我看来有些奇怪。当然,它们是应用层的客户端。但是,基础设施层中的适配器也需要进行“外部”集成测试,这将导致另一个环绕基础设施环的环,而且并不太符合整体的想法。通过我们优化后的方法,测试可以从侧面连接到代码,并根据它们主要交互的部分来调整不同类型的测试。它们可以专注于与应用层暴露的API直接进行的细粒度测试,或者倾向于通过基础设施适配器采用更全面的测试方法。

从根本上讲,切片洋葱架构仍然是作为独立系统部署的合适选择。话虽如此,削减的关键好处在于我们可以安排多个这样的系统相互交互,也适用于其他部署安排。

切片洋葱架构 - 多个切片洋葱

soa-multiple.jpeg

例如,我们可以将一组洋葱放置在一个单一的部署单元中,如上图中的浅绿色框所示。这看起来非常类似于我们每个领域一个洋葱的方法。暴露应用层两侧的核心好处在于,我们可以利用运行时环境提供的功能,让各个切片洋葱彼此交互。可以通过发布进程内事件(下图中六边形中的灯泡)或调用API(圆圈中的bean)来实现。

在Spring应用程序的上下文中,可以使用其应用事件机制或直接引用另一个切片洋葱暴露的Spring bean来实现。我通常建议优先选择异步的、基于事件的切片洋葱之间的交互,因为这自然地将强一致性范围限制在一个洋葱内,从而强调它们的自包含性。此外,不引用外部洋葱暴露的Spring bean允许测试仅在测试洋葱内运行。

切片洋葱架构 - 交互模式

soa-interaction.jpeg

模块

一旦我们讨论了个别切片洋葱之间的交互,我们就可以将架构抽象提升到模块层面,因为模块的内部洋葱结构实际上不再重要,而变成了一个实现细节。模块通过暴露事件或API来实现内部交互,并通过基础设施适配器将每个模块连接到外部世界。因此,它们在整个应用程序安排中形成了自包含的元素。这实际上是模块化应用程序架构的核心。

在模块化架构中,不同的模块拥有各自的功能和责任,彼此之间可以通过定义的事件或API进行交互。每个模块对外暴露的接口和适配器确保了模块的独立性,使其可以作为一个功能完整的单元进行开发和维护。

模块化架构将复杂的应用程序拆分成多个模块,每个模块负责特定的功能或业务领域。通过模块化的设计,我们可以实现应用程序的高内聚性和低耦合性,从而提高应用程序的可维护性和可扩展性。

总的来说,模块化应用程序架构在每个模块内部使用切片洋葱架构来实现自包含性,并通过定义的接口和适配器实现模块之间的交互。这种架构设计能够有效地支持复杂的应用程序,并为应用程序开发团队带来更好的开发体验。

切片洋葱架构 - 模块

soa-modules.jpeg

模块化架构是将应用程序架构演进的绝佳起点,因为它们内部天然地具有低成本的分隔点,这在我们必须重组整体架构时非常有帮助。首先,我们可以在各个模块之间轻松地移动代码,因为内部交互不会暴露给第三方。我们的集成开发环境(IDE)中的重构工具成为有力的助手。

此外,这些分隔点在以后的阶段可以用于拆分系统,以满足实际的组织或技术需求。特别是,仅通过(异步)事件进行交互的模块可以通过将代码移动到新项目中,从而提升为另一个可部署的模块。之前已在内部发布的事件可以被外部化为某种消息基础设施,并且原始应用程序可以通过在监听模块上部署相应的基础设施适配器来集成新的安排。

切片洋葱架构 - 拆分

soa-modules-split.jpeg

话虽如此,我们最初采用的完全模块化的安排可能已经足够保证我们系统的可演进性,实际上我们可能永远不会真正进行拆分。

总结

切片洋葱架构通过更加强调领域及其功能结构,增强了原始架构的理念。垂直切片洋葱使得这一理念可以应用于更广泛的部署选项,并在模块化应用程序安排的背景下形成了一个可演进的架构的基石。

原文链接