在软件设计中平衡整洁的代码与实际的灵活性
简介
作为软件工程师们,我们的目标是在实现功能的同时保持代码的清晰易懂。我们依赖于标准和约定来构建易于维护的项目。
在BlaBlaCar,为了代码组织,我们通常采用六边形架构来提高代码库的灵活性。在实施这种方法时,我们发现各个团队在实施中存在差异。
本文旨在解释软件工程中的Hexagonal Architecture(端口和适配器模式)的核心概念,然后介绍BlaBlaCar(一家公司)的一些团队所采用的实用方案。
最后,我们来聊聊严格遵循架构规范与实际情况灵活性之间的平衡。
什么是六边形架构(Hexagonal Architecture)? 传统的做法在我早期的职业生涯中,作为后端软件工程师,我在分层架构下工作,并为现有的遗留代码库贡献了我的力量。
这种组织结构是有道理的,因为它遵循了Spring注解约定,比如@Controller
和@Service
。这种架构解决方案不仅易于学习,还方便理解和实现。
这种建筑设计对于简单的项目来说不错,特别适用。
然而,采用这种设计时,耦合往往特别紧密,特别是在服务/仓储/实体层之间。
由于这种架构没有在业务逻辑和技术逻辑之间设立严格的界限,服务层类通常会演变成超过1000行代码的多功能瑞士军刀式类。
扩展并维护这样一个项目可能会非常有挑战性。
六边形架构简介当我加入BlaBlaCar时,我注意到大多数项目都是围绕这三个主要模块组织的。
- 应用程序
- 范畴
- 基础架构
我以前从未遇到过这样的项目组织方式,因此不知道该去哪里找我需要的那些类。我常常会对自己的一些组织问题感到困惑。
- “仓库在哪儿?”
- “我该把这HTTP客户端放哪儿?”
- “我把这个Utils类放在这模块里会违反什么规定吗?”
所以我通常在这种情况下会用谷歌搜一下。
在网上搜索这些术语后,让我找到了三种基于这种组织形式的建筑模式:
- 六边架构(也称为端口与适配器架构)
- 洋葱架构
- 整洁架构
仅仅通过谷歌搜索和阅读这些概念的定义,要清楚地理解每个概念的区别并不容易。我们不如关注它们的相似之处。
- 专注于核心业务逻辑:这三种架构都强调将应用程序的核心业务逻辑与外部关注点分离。
- 分层和依赖规则:它们使用分层来组织代码,但关键在于这些层是如何相互作用的。在这三种架构中,依赖关系指向内部:核心业务逻辑位于中心,且不会受到外部变化的影响。
- 端口/适配器机制(六边形)及其变体:六边形架构明确使用端口和适配器的概念来连接应用程序与外部世界。洋葱架构和干净架构采用了类似的方法,不过使用了不同的术语。这意味着从实际角度来看,更换数据库或Web服务不会影响业务逻辑。
最后,这些标准主要在于术语和抽象概念上的不同,但背后的意图基本上是一致的。
为了保持简单易懂,接下来本文将聚焦于‘六边形架构’。
一个了解六边形架构的指南理解六边形架构有四个关键点,正确理解这些要点:
- 域输入端口被注入到应用程序适配器类中:简单来说,领域类被注入到控制器中。
- 领域只依赖接口,不依赖具体实现:为了实现松耦合,业务逻辑必须尽可能纯粹,避免受到技术因素的影响。这意味着领域层定义了接口,由应用层和基础设施层来实现这些接口,而不是从这些层导入代码。
- 每一层都有自己的数据对象,这些对象需要独立于其他层:如果我们需要在每一层中操作一个对象,我们需要创建3个独立的对象及其相应的映射器。例如,领域层不应该受到来自应用层的数据传输对象(DTO)的影响。应用层不应返回基础设施层的实体对象。基础设施层不应持久化领域层的领域对象。
- 域输出端口由基础设施适配器类实现:领域定义一个接口来描述所需的操作,而基础设施通过一个具体实现该操作的类来实现此接口。
作为支付团队的一员,我亲身遇到了在支付服务中实现六边形架构时遇到的挑战和复杂性。这种背景让我们能深入了解一个实际案例,突出关键概念及其在文章中的重要性。让我们看看这如何转化为支付相关的Java类。
代码会是这样:
在应用层,Controller 会从领域层导入服务接口(输入端)并使用该接口。
在领域层中,定义了服务接口和仓库接口,分别作为输入接口和输出接口。
一个服务适配器实现了服务接口的功能,并通过仓库接口执行相关操作。
注意,领域模型类没有导入其他层的任何内容,这一点值得注意。
在基础设施层中,Repository Adapter(仓库适配器)从领域中引入 Repository 接口并执行某些操作。
理论 VS 现实那只是理论上的。但在实际操作中,一些问题开始冒头,
- 复杂度映射:应用层和基础设施层需要映射类,将
Payment
转换为PaymentDTO
,以及将Payment
转换为PaymentEntity
。
这可能会迅速演变成一种映射地狱,尤其是在基础架构层内有许多不同对象(如一个软件频繁进行大量 HTTP 调用)或者软件主要在应用层和基础设施层之间进行传递处理逻辑时。 - 简化:我们可以直接将
PaymentService
实现从域注入到PaymentController
中,而不用提供PaymentServicePort
输入端口,这不会违背核心原则。虽然这在一定程度上偏离了传统的六边形架构,即消除了应用层与域之间的接口,但它保持了整体的松耦合和职责分离目标。
面对现代向更小、更聚焦代码库的趋势,这样的设计对于轻量的应用来说可能会显得过于复杂。
当需求比较简单时,比如一个简单的 CRUD 应用,分层式架构可能就足够应付,而不必增加额外复杂性。
然而,随着项目复杂性的增加,特别是当项目团队成员增多时,并且项目与多个外部系统相连接,采用像六边形架构这样的更加结构化的方案变得更为合理。
话说回来,将纯粹的六边形架构应用于轻量级应用往往会导致过多的样板代码,这可能会影响软件的可读性和可维护性。
此外,把现有的项目改造成符合六边形架构要求的项目的转换和重构成本相当高。
虽然为一个大型服务(例如一个单体服务)遵循这样的纯粹和学术性设计是有道理的,但对于像微服务这样的小型服务而言,这样做则更有争议性。
一个整体式系统在一个公司内通常会有一个较长的生命周期(通常超过5年)。能够完全更换数据库技术和相关的基础设施代码很有帮助(例如,从Oracle迁移到MongoDB这样的迁移就是一个比较现实的例子)。
但是为了未来可能更换数据库技术而设计一个 3000 行代码左右的微服务,可能有些过度设计了。
如果项目很小的话,重构成本通常不会很高。而且,微服务被替换的可能性比它自己改变数据库技术的可能性要高。
实用的做法考虑到前一节中提到的关注点,让我们探索这种设计解决方案的实际方法。我们将详细分享我们的支付团队重构的经历,使代码库更贴近六边形架构的原则,但实际操作中的开销比理论要求的要少。
层次通常,当遵循六边形架构原则时,我们会通过创建独立的 Maven 模块或 Gradle 子项目,将 Java 应用程序按应用、领域和基础设施层进行划分。
在我们支付团队里,项目已经被分成三个主要部分(应用层/领域层/基础设施层)。
由于这种方法更轻便且设置简单,我们决定继续使用它,选择采用包级别的分离,而不是模块级别的分离。
不过,这些包里的内容有时乱得不成样子,没有遵循包名所代表的原则。例如:
- 一些处理支付的业务逻辑类被放在应用包内,而不是放在领域包内。
- 一些调用其他微服务的API客户端代码位于应用包内,而不是基础设施包内。
- 一些持久化相关的逻辑位于应用包内,而不是基础设施包内。
我们花了一些时间审查了我们的代码库,并进行了一些包重构,以便将所有类放到正确的模块中,遵守应用层、领域层和基础设施层的分离。
输出口学术上的六边形架构原则要求我们在领域层声明一个输出端口(Output Port)的接口,这意味着描述基础设施操作的接口,并在基础架构层提供该接口的具体实现。
比如说:
领域中有 PaymentRepositoryPort
这个接口,基础设施中则有一个实现了这个接口的 PaymentRepositoryAdapter
。
从这里开始,没有将基础设施类导入到领域层(领域层)的导入声明。领域保持独立和纯粹。不过,是基础设施类导入了领域的一个接口。这样我们就实现了控制反转。
我们的代码库已经实现了这样的逻辑:我们总是定义了一个 Storage 接口,并由一个具体的 Storage 实现来实现该接口。有时我们不得不进行一些轻量的包重构工作,因为接口和实现要么都在相应的领域模块中,要么都在相应的基础架构模块中。但总体而言,这一部分实现起来相对容易。
然而我们并没有采用端口适配器命名,为了避免大规模重构代码库。我们最终还是使用了 IPaymentStorage
接口和 PaymentStorage
具体实现类(这种命名约定有些争议)。
从零开始一个新项目时,可以更清晰地遵循端口/适配器约定,特别是在输出端口方面,使代码中跨层边界的地方更加明显。
业务领域层和基础架构层的分界线
输入端根据学术上的六边形架构原则,我们需要在领域层有一个输入接口,这意味着领域层中需要有一个描述业务逻辑的接口定义,并且领域层中还需要具体的实现。
比如说:
- 在领域中有一个
PaymentServicePort
- 同时在领域中还有一个
PaymentServiceAdapter
,它实现了PaymentServicePort
随后,应用层导入了领域的接口。
我们的代码库没有遵循这一原则。而且,由于这里没有做到控制反转,接口的重要性在这个情况下不那么明显:无论是哪种情况,应用层都会从领域层导入某些内容,要么是接口,要么是具体的实现。鉴于好处并不明显,我们采取了务实的做法,决定不设置输入端口:应用层直接导入领域层的具体服务实现。
领域层与应用程序层之间的界限
数据项我们方法中最具争议的一点可能是没有为每一层单独设立对象。
六边形架构的原则期望我们做到:
- DTO(数据传输对象)用于应用层
- 域模型对象用于域层
- 在基础设施层用于持久存储的实体对象,或作为API通信的DTO
理论上讲,当你跨越一层的边界时,映射器会根据你进入的层来转换对象。
比如说,当领域层要求基础架构层保存一个的 Payment
对象时,基础架构会使用映射将其转换为的 PaymentEntity
对象。
在支付团队里,当进行DTO和领域对象之间的映射时,无论是从应用层到领域层,还是从领域层到基础设施层,我们并没有专门用于持久化的实体。
这与我们使用的持久化技术有些关联。映射由基础设施层中的 Mapper 类来完成。该 Mapper 描述了要执行的 SQL 操作,并将领域模型的值直接映射到数据库中的列。
这种方法有些争议,因为它会导致领域与数据库之间的耦合过紧,从而减少映射代码。
实用 vs 学究总之,所谓的“实用型”六边形架构与“学术型”相比,如下所示:
实用主义优于学术的好处- 更简洁的实现方式,更低的复杂度,尤其是对于小型项目,
- 在实践中具有更大的灵活性,无论是添加新功能还是重构代码逻辑时需要进行的修改较少。
- 没有明确的端口或适配器名称,因此减少了代码的明确性和清晰度,在浏览代码时,不容易看出我们正在跨越层的边界。
- 缺乏输入端口,这会使测试变得更加困难。
- 没有按层划分的Java模块,而是使用Java包。使用模块可以更好地定义边界和封装,而使用Java包则没有这种效果。
- 业务逻辑对象在基础设施层用于持久化,因此在持久化方面,我们没有在领域层和基础设施层之间设置映射器,导致这两层之间耦合更紧密。
我们可以从这些列表中看到,我们为了简化而牺牲了几项基本原则。
不过,我们可以选择我们想要的那个,不必如此极端。
经验总结在BlaBlaCar,许多团队已经在某种程度上实现了六角架构。
根据实现的“学术性”程度,增加新功能可能会比较麻烦,但与外部服务和第三方提供商的依赖减少是很明显且令人欣慰的。
强制遵守各层之间的边界可能比较有挑战,但例如ArchUnit这样的工具可以通过检测不符合规定的导入语句等功能帮助解决这一问题。
对于仅作为中转的微服务而言,六边形架构可能不是最佳选择,因为开发人员需要花费大量精力进行映射,而这种映射对服务中的实体对象价值不大。
结束语:我们团队认为所谓的“实用的”六边形架构(Hexagonal Architecture)对于微服务架构项目是一个实用且有价值的架构方案。
这是在“学术”六边形架构的纯粹性和分层架构的简单性之间做出的权衡。可以说,既简单又整洁。
这一解决方案尝试在过度设计和高度耦合之间找到正确的平衡。
但和往常一样,没有一蹴而就的方法:是否遵循某种架构模式的选择必须根据项目的使用和规模来决定,而且非常重要的是,这个标准必须被你的团队成员接受和理解。
共同学习,写下你的评论
评论加载中...
作者其他优质文章