Hexagonal Architecture于2005年由Alistair Cockburn撰写,是一个具有许多优势的软件架构,自2015年以来又重新引起了人们的兴趣。
六角架构的初衷是:
允许应用程序同样由用户,程序,自动化测试或批处理脚本驱动,并与最终的运行时设备和数据库隔离开发和测试。
为了探索通过自动化测试引导应用程序的好处,或者从数据库中单独开发和测试,我们建议您阅读我们最近发布的测试金字塔上的这一系列博客文章:实践中的测试金字塔。
承诺非常有吸引力,它还有另一个有益效果:它允许隔离应用程序的核心业务,并自动测试其行为,而不依赖于其他任何事情。这可能是该架构引起域驱动设计(DDD)从业者关注的原因。但要小心,DDD和六边形结构是两个相当不同的概念,它们可以相互加强,但不一定一起使用。但这是另一个时间的主题!
最后,这种架构设置起来并不复杂。它基于一些简单的规则和原则。让我们探索这些原则,看看它们在实践中的含义。
- 六角架构原理
- 细节:内部和外部的代码如何组织?
- 细节:运行时
- 细节:右侧的依赖性反转
- 细节:为什么左边有借口?
- 六角形结构测试
- 更进一步
- 参考
六角架构学原理
六边形体系结构基于三个原则和技术:
- 明确区分应用程序,域和基础结构
- 依赖关系从应用程序和基础结构到域
- 我们使用端口和适配器隔离边界
词汇说明:在本文的其余部分中,将使用“应用程序”,“域”和“基础结构”等字样。这些词不是来自原始文章,而是来自领域驱动设计从业者频繁使用六边形体系结构。作为参考,原始文章的文字在下面的部分中说明。
原则:单独的应用程序,域和基础结构
第一个原则是明确地将代码分成三个大的形式化区域。
左侧,应用程序端
这是用户或外部程序与应用程序交互的一面。它包含允许这些交互的代码。通常,您的用户界面代码,API的HTTP路由,以及使用您的应用程序的程序的JSON序列化都在这里。
这是我们找到驱动领域的演员的一面。
注意:Alistair Cockburn谈论应用程序方面的左侧或用户侧。
领域,在中心
这是我们想要从左侧和右侧隔离的部分。它包含所有关注和实现业务逻辑的代码。业务词汇和纯粹的业务逻辑,与解决您的应用程序的具体问题,使其丰富和具体的所有内容相关联,处于中心位置。理想情况下,不知道如何编码的领域专家可以阅读本部分中的一段代码并指出您的不一致(真实的故事,这些都可能发生在您身上!)。
注意:Alistair Cockburn谈论域的中心或业务逻辑。
右侧,基础设施方面
在这里,我们可以找到您的应用程序需要什么,它驱动的工作。它包含必要的基础结构详细信息,例如与数据库交互的代码,调用文件系统或处理对您所依赖的其他应用程序的HTTP调用的代码。
这是我们找到由领域管理的演员的一面。
注意:Alistair Cockburn谈论基础设施方面的右侧或服务器端。
以下原则将允许在应用程序,域和基础结构之间实现这种逻辑分离。
为什么这很重要?
这种分离的第一个重要特征是它将问题分开。在任何时候,您都可以选择专注于单个逻辑,几乎独立于其他两个逻辑:应用程序逻辑,业务逻辑或基础架构逻辑。它们在不混合的情况下更容易理解,并且每个逻辑的约束对其他逻辑的影响较小。
另一个特点是我们将业务逻辑放在代码的最前端。它可以在目录或模块中隔离,以使其对所有开发人员都明确。它可以在不承担程序其余部分的认知负荷的情况下进行定义,改进和测试。这很重要,因为最终,开发人员对生产中的业务有了解。
最后,在自动化测试方面(我们将在下面看到),我们将以合理的努力成功进行测试:
- 整个域单独,
- 在Infrastructure端独立地集成Application和Domain
- 在应用程序端独立地集成域和基础架构
插图:应用程序的一个小例子
为了更具体地说明这些原则,我们将使用Alistair期间在“Hexagon”事件中使用的小例子,该事件由Thomas Pierrain(@tpierrain)和Alistair Cockburn(@TotherAlistair)于2017年提出。注意:您可以在文章末尾找到视频和事件代码。
这个小应用程序的目的是提供一个命令行程序,将诗歌写入控制台的标准输出。
此应用程序的预期输出示例:
$ ./printPoem Here is some poem: I want to sleep Swat the files Softly, please. -- Masaoka Shiki (1867 - 1902) Type enter to exit...
为了正确地说明三个区域(应用程序,域,基础设施),此应用程序将在外部系统中搜索诗歌:文件。我们还可以将此应用程序连接到数据库,原则是相同的。
在这种情况下,我们如何应用这第一个原则,即分成三个区域?如何在左侧(什么驱动它),在中心(核心业务)和右侧(什么是驱动)分发?
应用方面
从用户的角度来看,程序是作为控制台应用程序呈现的。因此,控制台的概念将位于应用程序端的左侧。通过控制台,用户将驱动领域。
基础设施方面
从技术上讲,在我们的例子中,诗歌存储在一个文件中。这个文件的概念将在基础设施方面的右侧找到。该企业将通过试用这一右侧来提出其诗歌的要求,具体由PoetryLibraryFileAdapter实施。
在这里,如上所述,我们可以轻松地交换我们的诗歌来源(文件,数据库,网络服务......)。因此,作为文件的源的实际实现是技术细节(也称为技术实现细节)。
领域方面
在这种情况下,我们的核心业务是对用户有价值的东西,就是阅读诗歌的概念。我们可以使用PoetryReader类在代码中实现这个概念
应用→域交互
从业务角度来看,请求是来自控制台应用程序还是其他应用程序无关紧要,这是我们希望能够抽象的技术细节。这恰恰是最初的意图之一:“由用户和测试一起驱动”。因此,域中没有控制台的概念。然而,我们的应用程序允许从用户的角度(=它提供的服务)来请求诗歌。我们将在域中找到这一概念(由IRequestVerses实现),这将允许应用程序端与域进行交互。
域→基础设施互动
同样,从域的角度来看,诗歌是来自文件还是数据库并不重要,我们希望能够独立于外部系统测试我们的应用程序。域中没有文件概念。要运作,领域仍然需要得到诗歌。我们发现这个概念是以IObtainPoems界面的形式在Domain中获取诗歌。正是这种获取诗歌的概念将允许域与基础设施方面进行交互。
注意:从这里,当您阅读图表时,您可以开始观察显示类之间关系的箭头。实线箭头表示调用或组合交互。没有填充的箭头表示继承关系(如在UML中)。但不需要立即分析所有内容,我们稍后会详细探讨。
注意:名称IRequestVerses和IObtainPoems代表许多接口,我们将按照原则来讨论它们。对于轶事,使用“i”启动接口名称的约定不再流行,但Thomas Pierrain将接口名称作为第一人称单数的句子读取。IRequestVerses写道:例如我请求经文。我喜欢这个主意。
原则:依赖进入内部
这是实现目标的基本原则。我们已经开始在先前的原则中看到这一点。
Principle: Dependencies go to the Domain
程序可以通过控制台和测试来控制,域中没有控制台的概念。域不依赖于应用程序端,应用程序端依赖于域。应用程序端(ConsoleAdapter)依赖于诗请求的概念,IRequestVerses(它定义了用户方面的通用“诗请求”机制)。
同样,程序可以独立于其外部系统进行测试,Domain不依赖于Infrastructure方面,相反,它是依赖于Domain的Infrastructure方面,通过获取诗歌的概念,IObtainPoems。从技术上讲,基础结构方面的类将继承Domain中定义的接口并实现它,我们将在下面详细讨论它以讨论依赖性反转。
内在和外在
如果我们将依赖关系(<<>)视为箭头,那么这个原则将中心域定义为内部,将其他所有内容定义为外部(见图)。当我们讨论六边形结构时,我们经常发现内部和外部的这些概念。它甚至可以成为记忆和传播的基本点:依赖关系进入内部。
换句话说,一切都取决于域,域不依赖于任何东西。Alistair Cockburn坚持内部和外部的这种划分,这比应用和基础设施之间的差异更能解决最初的问题。
原理:边界与接口隔离
总而言之,应用程序代码通过业务代码中定义的接口(此处为IRequestVerses)来驱动业务代码。业务代码通过业务代码(IObtainPoems)中定义的接口驱动基础架构。这些接口充当内部和外部之间的显式绝缘体。
隐喻:端口和适配器
六边形体系结构使用端口和适配器的比喻来表示内部和外部之间的交互。图像是域定义了端口,如果它们遵循端口定义的规范,则可以在其上交换连接所有类型的适配器。
例如,我们可以设想Domain的一个端口,我们将在单元测试期间连接硬编码数据源,或者在集成测试中连接真实数据库。只需在Infrastructure端编写相应的实现和适配器,Domain就不会受到此更改的影响。
由业务代码定义的这些接口隔离并允许与外部世界的交互,这些接口是Ports&Adapters隐喻的端口。注意:如前所述,端口由业务定义,因此它们位于内部。
另一方面,适配器表示外部代码在端口与应用程序代码或基础结构的其余部分之间形成粘合剂。这里,适配器分别是ConsoleAdapter和PoetryLibraryFileAdapter。这些适配器在外面。
另一个隐喻:六角形
正如我们在上图中看到的那样,另一个为这个架构命名的比喻是六边形。为什么是六边形?主要原因是它是一个易于绘制的形状,为图表上的多个端口和适配器留出了空间。事实证明,即使六边形最终是轶事,六边形体系结构的表达也比端口和适配器模式更受欢迎。可能是因为听起来更好?
理论部分已经结束,没有其他原则:对于其他一切我们完全自由。
细节:内部和外部的代码如何组织?
除了上面提到的原则,我们完全可以自由地按照我们的意愿在每个区域内组织代码。
关于业务代码,内部,一个好主意是选择根据业务逻辑组织其模块(或目录)。
要避免的一个组织是按类型对类进行分组。例如“ports”目录,或“repositories”目录(如果使用此模式)或“services”目录。在您的业务代码中考虑100%的业务,包括组织您的模块或目录!理想的情况是能够打开目录或业务逻辑模块,并立即了解您的程序解决的业务问题;而不是只看到“存储库”,“服务”或其他“经理”目录。
另请参阅此主题:
https://medium.com/@msandin/strategies-for-organizing-code-2c9d690b6f33
https://martinfowler.com/bliki/PresentationDomainDataLayering.html
细节:运行时
您如何实例化所有这些以满足运行时依赖性?如果您使用依赖注入框架,则可能不需要问自己这个问题。但我认为要理解六边形体系结构,看看应用程序启动时会发生什么是很有趣的。要做到这一点,至少在本文的时候不要使用依赖注入框架。
例如,如果我们手动实例化一切,我们将如何编写应用程序的入口点:
class Program { static void Main(string[] args) { // 1. Instantiate right-side adapter ("go outside the hexagon") IObtainPoems fileAdapter = new PoetryLibraryFileAdapter(@".\Peoms.txt"); // 2. Instantiate the hexagon IRequestVerses poetryReader = new PoetryReader(fileAdapter); // 3. Instantiate the left-side adapter ("I want ask/to go inside") var consoleAdapter = new ConsoleAdapter(poetryReader); System.Console.WriteLine("Here is some..."); consoleAdapter.Ask(); System.Console.WriteLine("Type enter to exit..."); System.Console.ReadLine(); } }
实例化顺序通常是从右到左:
- 首先我们实例化Infrastructure端,这里是fileAdapter,它将读取文件。
- 我们实例化将由应用程序驱动的Domain类,poetryReader在其中通过注入将fileAdapter注入构造函数。
- 安装Application端,consoleAdapter将驱动poetryReader并写入控制台。这里poetryReader通过注入构造函数注入consoleAdapter。
我们说内部不应该依赖于外部。那么为什么我们将来自Infrastructure的代码fileAdapter注入poetryReader,这是来自Domain的代码?
我们可以这样做,因为通过查看模式和代码,除了是PoetryLibraryFileAdapter(基础结构方面)之外,fileAdapter也是继承的IObtainPoems实例。
在实践中,PoetryReader不依赖于PoetryLibraryFileAdapter,而是依赖于IObtainPoems,它在域中定义良好。您可以通过查看其构造函数的签名来检查它。
public PoetryReader(IObtainPoems poetryLibrary) { this.poetryLibrary = poetryLibrary; }
PoetryLibraryFileAdapter和PoetryReader是弱耦合的。
细节:右侧的依赖性反转
fileAdapter依赖于业务的定义(依赖于继承),但在运行时poetryReader可以在实践中控制fileAdapter的实例是依赖倒置的经典案例。
实际上,如果没有IObtainPoems接口,业务代码将依赖于其定义的基础结构,我们希望避免:
该接口允许反转此依赖关系的方向:
除了使业务独立于外部系统之外,右侧的此接口还允许满足着名的 SOLID或Dependency Inversion Principle原则。这个原则说:
- 高级模块不应该依赖于低级模块。两者都必须依赖于抽象。
- 抽象不应该依赖于细节。细节必须取决于抽象。
如果我们没有接口,我们将拥有一个依赖于低级模块(Infrastructure)的高级模块(Domain)。
注意:对于左侧和业务代码之间的交互,依赖性自然是正确的方向。
交互实现的这种差异与应用程序域和域 - 基础架构关系之间的差异有关。提醒:应用程序端驱动域,而基础架构端由域驱动。
细节:为什么左边有接口?
由于Application和Domain之间的依赖关系已经在正确的方向上,因此IRequestVerses接口的作用不是反转依赖关系。
但是,它仍然有兴趣:明确限制应用程序代码和域代码之间的耦合表面。
实际上,PoetryReader类可以有除IRequestVerses接口之外的其他方法。ConsoleAdapter不了解这一点很重要。
它与另一个SOLID原则 - 接口隔离原则一致。
客户不应该被迫依赖他们不使用的方法。
但是,一旦你理解了意图,如果左侧的端口只有一个方法,并且它的实现只有一个方法,如我们的例子,接口真的是必要的吗?在动态语言中,最终将通过duck typing?
我们可以回答一个问题:您的团队对此有何看法?每个人都清楚隔离目标,甚至不需要界面来触发对话吗?这取决于你完全决定。
六角形结构测试
该软件架构的一个重要优点是它有助于测试自动化,这是其原始意图的一部分。
如何从Application端替换一些代码?
在一般情况下,左侧代码的作用可以由测试框架直接扮演。实际上,测试代码可以直接驱动业务逻辑代码。
注意:该图说明了集成测试,因为没有替换正确的部分。它也可以替换,见下文。
如何替换基础设施方面的一些代码?
右边的代码必须由业务驱动。通常,如果要编写单元测试,可以使用模拟或任何其他形式的测试双重替换它,具体取决于您要测试的内容。
达到了目标!
允许应用程序由用户,程序,自动化测试或批处理脚本驱动,并与其可能的执行系统和数据库隔离开发和测试。
小心!这并不妨碍您测试应用程序和基础结构代码,任何值得测试的代码。在这个主题上,我再次向您推荐实践测试金字塔系列。
事实上,通过组合我们替换或不替换的内容,我们可以看到,通过这种架构,我们可以测试我们想要的东西:
- 整个域单独,
- 在Infrastructure端独立地集成Application和Domain
- 在应用程序端独立地集成域和基础结构
更进一步
作为一个团队谈论它,谁已经知道如何在家里做?
继续,在现实生活中尝试代码。例如,一个小型的个人项目,或与您的团队的小项目。什么对你来说很容易,有什么困难?
以下是您在实施过程中可能遇到的一些其他问题:
- 端口只能有一个方法,或者组合多个方法。你的情况有什么意义?
- 即使它很好地遵循依赖性原则,代码也不一定分成三个显式模块或目录或包或命名空间。正如在Thomas Pierrain的代码中一样,我已经多次将其视为包/命名空间,只有域和基础结构。在示例中,内部排列在名称空间HexagonalThis.Domain中,外部分组在名称空间HexagonalThis.Infra中。
快速提醒:没有银弹。六边形体系结构是复杂性和功能之间的良好折衷,也是发现我们所解决的主题的一种非常好的方式。但它只是其中一种解决方案。对于简单的情况,它可能太复杂,而对于复杂的情况,它可能太简单了。还有其他软件架构值得探索。例如,Clean Architecture在形式化和绝缘方面更进一步(带有额外的SOLID)。或者在不同但兼容的轴上,CQRS可以更好地分离读数和写作。
参考
“Hexagone”中Alistair活动的视频就在这里。这个博客来自这个事件的代码是Thomas Pierrain的github。
要了解有关“应用程序”,“域”和“基础结构”的更多信息,请阅读“域驱动设计:隔离域”的第4章。注意:这本书没有提到六角形架构。