洋葱/六边形架构中的过度工程 – Victor


Clean ArchitectureOnion ArchitectureHexagonal Architecture(又名端口和适配器)已成为当今后端系统设计的规范。

有影响力的人在推广这些架构时并没有过分强调它们是(过于)复杂、详尽无遗的蓝图,需要针对手头的实际问题进行简化。“按照书本”应用这些架构可能会导致过度工程,从而导致无用的开发工作和风险,并可能阻碍明天在不同轴上进行深入的战略重构。

本文的目的是指出可以对这些软件架构进行简化的常见位置,并解释所涉及的权衡。

纵观软件架构的历史,许多有影响力的人都强调要让外围的集成问题远离处理应用程序主要复杂性的中心域区域。这与领域驱动设计的兴起达到了顶峰,它强调保持你的领域的灵活性,不断寻找方法来深化它,提炼它,重构它,以更好的方式来建模你的问题。

上述3种架构风格可以被称为 "同心架构",因为它们不是以层为单位组织代码,而是以环为单位,围绕领域同心地组织。注意:在这篇文章中,层和环这两个词是可以互换使用的。

本文中的务实的变化可能会帮助你简化应用程序的设计,但每一个都需要整个团队理解代码气味和设计价值(内聚力、耦合、DRY、OOP、将核心逻辑保持在一个不可知的域中)。你打算采取的任何行动,都要从小处着手,不断权衡简化与代码库中引入的异质性。

1、无用的接口
在我的职业生涯中,我听到过以下不同的想法(都是错误的)。

  • 领域实体必须只通过它们实现的接口来使用。
  • 每个层都必须只暴露和消费接口。(在分层架构中)
  • 应用层必须实现输入端口接口。(在六边形架构中)

除了多余的接口文件必须与它们的实现签名的变化保持同步外,在这样的项目中浏览代码既神秘(是否有另一种实现?)又令人沮丧(在一个方法上按下ctrl键可能会把你扔进一个接口而不是方法定义)。

但为什么首先要创建更多的接口呢?

对接口的过度使用可能源于一个非常古老的设计原则:"依赖于抽象,而不是实现"。对这一原则的第一层理解是,使用更多的接口和抽象类,以便在运行时通过多态性换取不同的实现。第二个层次的理解要强大得多,但与接口关键字无关:它说的是将问题分解成子问题(抽象),在此基础上建立一个更简单、更干净的解决方案(例如TCP/IP栈)。但是,在这次讨论中,让我们把重点放在前者上。

使用接口可以在运行时换上不同的实现:

  • 同一契约的其他实现(又称策略设计模式)
  • 一系列的 "过滤器"(又称责任链模式),例如网络/安全过滤器
  • 测试假象(几乎被现代嘲讽框架所消灭)。MyRepoFake实现了IMyRepo
  • 丰富的实现变化(装饰器模式)。Collections.unmodifiableList()
  • 对具体实现的代理(注意:基于JVM的语言不需要接口来代理具体的类)。

以上都是使用多态性来增加设计灵活性的方法。

然而,在实际使用之前引入这些模式是一个错误。换句话说,要警惕 "为了以防万一,我们在那里引入一个接口 "这样的想法,因为这正是推测性通用性代码气味的定义。
提前设计也许在几十年前更有意义,当时编辑代码需要大量的精力。但从那时起,工具已经有了很大的发展。使用现代IDE(如IntelliJ)的重构工具,你可以从现有的类中提取一个接口,并在几秒钟内将整个代码库中的类引用替换成新的接口,而且几乎没有任何风险。有了这样的工具,我们应该鼓励自己只在真正需要的时候提取接口。

事实上,容忍一个只有一个实现的接口的唯一理由是,当这个接口驻扎在一个与它的实现不同的编译单元中时。

  • 在你的客户使用的库中,例如my-api-client.jar。
  • 内环中的接口,外环中的实现=依赖反转原则

依赖反转原则(任何同心结构的标志)允许内环(如领域)调用外环(如基础设施)的方法,但不与它的实现相联系。例如,领域环内的服务可以通过调用在领域环内声明但在外部基础设施环内实现的接口方法来从另一个系统获取信息。
换句话说,Domain能够调用Infrastructure基础设施层,但却看不到实际调用的方法实现。调用的方向(Domain→Infra)和代码依赖的方向(Infra→Domain)是相反的,因此它被称为 "依赖反转"。

这里还有一个我曾经听到的支持接口的(悲剧性的)论点。
- 我们需要接口来明确我们的类的公共契约!但是,看一看我们的类的公共契约有什么错呢?
- 但是从类的结构上看公共方法有什么不对呢?我回答说
- 是的......但是那个类里有50多个公共方法,所以我们喜欢把它们列在一个单独的文件里,很好地分组。
- 我:(无语)...
- 哦,还有,这个实现类有超过2000行的代码。

我想很明显,那个无用的接口并不是他们真正的问题......

总而言之:

一个接口值得存在,当且仅当:

  •  
  • 它在项目中拥有一个以上的实现,或者
  • 它被用来实现依赖反转以保护一个内环,或
  • 它被打包在一个客户库中

如果一个接口不符合上述任何一个参数,请考虑销毁它(例如通过使用IntelliJ的 "Inline "重构)。

但让我们回到开头:

域实体的接口--是错误的!上面的三个原因都不适用:

  1. 实体的替代实现是荒谬的
  2. 没有任何代码比你的领域更珍贵(Dep Inversion against Domain?)
  3. 在你的API中暴露实体是一个非常危险的举动

六角/六边形架构中的输入端口接口--是错误的!:

  • 它们只有一个实现(应用程序本身)。
  • API控制器不需要被保护(它是一个外环)。
  • 如果你直接暴露了输入端口接口,那就不再是经典的六边形架构了。

严格的层级
这种方法的支持者指出:

  • "每个层/环应该只调用紧接着的一个层"

对于这个论点,让我们假设有4个层/环:
  • 控制器
  • 应用服务
  • 域服务
  • 存储器

如果我们执行严格层,控制器(1)只能与应用服务(2)→域服务(3)→存储库(4)调用方向。

换句话说,应用服务(2)不允许直接与存储库(4)对话;调用必须总是通过域服务(3)。

这样的决定导致了这样的模板方法:

class CustomerService {
  ..
  public Customer findById(Long id) {
    return customerRepo.findById(id);
  }
}

上面的代码几乎是一个被称为 "中间人 "的代码气味的教科书式的例子,因为这个方法代表了 "没有抽象的间接性"--它没有给它所委托的方法(customerRepo.findById)增加任何新的语义(抽象性)。上面的方法并没有提高代码的清晰度,相反,它只是在调用链中增加了一个额外的 "跳"。

如今严格层的(少数)支持者认为,这个规则需要采取更少的决策(对中层团队来说是可取的),并且减少了上层的耦合。然而,应用服务的一个关键角色是协调用例,所以通常会对所涉及的部分进行高耦合。换句话说,应用服务的一个设计目标是主持协调逻辑,以使低层组件彼此之间的耦合度降低。

严格层的替代方案被称为 "宽松层",它允许跳过层,只要调用的方向是一致的。例如,应用服务(2)可以自由地直接调用存储库(4),而在其他时候,如果有任何逻辑需要推送到DS,它可以先通过领域服务(3)来调用。这可以导致更少的模板,但确实需要一种重构的习惯,以不断地将越来越复杂的逻辑提取到领域服务中。

2、单行的REST控制器方法
十多年来,我们都认为:

  • REST控制器层的责任是处理HTTP问题,然后将所有的逻辑委托给应用服务。

的确,这在10-20年前是非常合理的。当在服务器端生成HTML网页时(想想.jsp + Struts2),这需要在控制器中进行相当多的仪式。但是今天,如果他们通过HTTP对话,我们的应用程序和微服务通常只暴露一个REST API,将所有的屏幕逻辑推到前端/移动应用程序。此外,我们今天使用的框架(如Spring)发展得非常好,如果小心使用,可以将控制器的责任减少到只是一系列的注释。

今天,REST控制器的HTTP相关职责可以总结为:

  • 将HTTP请求映射到方法上--通过注解(@GetMapping)。
  • 授权用户操作--通过注解(@Secured, @PreAuthorized)。
  • 验证请求的有效载荷--通过注解(@Validated)。
  • 读取/写入HTTP请求/响应标头--最好通过网络过滤器完成
  • 设置响应状态代码--最好在一个全局异常处理程序中完成(@RestControllerAdvice)
  • 处理文件上传/下载--不再是花哨的了,但这是唯一真正丑陋的HTTP相关的东西了

上面提供的例子来自Spring框架(Java中使用最广泛的框架),但在其他Java框架和其他具有网络能力的语言中,几乎所有的功能都有对应的例子。

因此,除非你正在上传一些文件或做一些其他的HTTP功夫(为什么?!),否则在你的REST控制器中不应该再有与HTTP相关的逻辑了--框架灭了它。如果我们假设数据转换(Dto Domain)不是发生在控制器中,而是发生在下一层,例如应用层中,那么REST控制器的方法将是单行的,将每个调用委托给下一层中具有类似名称的方法。

@RestController
class WhiteController {
  ..
  @GetMapping("/white")
  public WhiteDto getWhite() {
    return whiteService.getWhite();
  }
}

哦,不!这又是我们之前看到的 "中间人 "代码的味道--模板代码。

如果你读到的内容与你的设置相符,你可以考虑将你的控制器与下一层(如应用服务)合并:

在开发REST API时,将控制器与应用服务合并。


是的,我的意思是将第一层逻辑中的方法注释为HTTP端点(@GetMapping...),不管是应用服务还是 "服务"(在我们的例子中,在WhiteService)。

我知道,这违背了我们在不久前才遵循的一些非常古老的习惯。可能你在这个领域呆得越久,就越觉得这个决定很奇怪。但时代已经变了。在一个暴露于REST API的系统中,事情可以(也应该)更加简化。

在我主持的研讨会上,我经常遇到这样的架构:允许REST控制器包含逻辑:映射、更复杂的验证、协调逻辑,甚至是一些商业逻辑。这是一个同等的解决方案,在技术上,应用服务与它前面的控制器被 "向上 "合并了。两者都同样好:(a)让控制器做一些应用逻辑,或者(b)将应用服务暴露为REST API。

 但有一个陷阱:不要在REST API组件中积累复杂的业务规则。相反,要不断地寻找有凝聚力的领域逻辑,将其转移到领域模型(例如在一个实体中)或领域服务中。

最后一点:如果你出于文档的目的大量注释你的REST端点方法(OpenAPI的东西),你可以考虑提取一个接口并将所有的REST注释转移到它。然后,应用服务将实现该接口并接管该元数据。

3、模拟完整的测试
单元测试是王道。专业的开发人员会对他们的代码进行彻底的单元测试。因此。

每一层都应该通过模拟下面的层进行测试

当架构规定了严格的层或允许单行的REST控制器方法(即使数据转换是在控制器中进行的),一个严谨的团队会问一个明显的问题:那些愚蠢的单行方法是否应该进行单元测试?如何测试?用mocks来测试这些愚蠢的方法会导致测试代码比被测试代码大5倍。更糟的是,这感觉毫无用处--在这样的方法中发生错误的可能性有多大?

人们可能会感到沮丧("测试太烂了!"),或者更糟的是,放松他们的测试严格性("我们不要测试这一个")。

实际上,你所面临的是来自你的测试的诚实反馈--你的系统被过度设计了。一个经验丰富的TDD实践者会很快接受这个想法,但其他人会很难接受它。"当测试是困难的,生产设计可以被改进"。

为了完整起见,我想在上面的经典语句中再加一条建议:

当测试很困难时,生产设计可以改进,或者你的测试太细了。

如果你的集成测试完全覆盖了该方法,你真的需要对其进行孤立的单元测试吗?阅读关于微服务的蜂巢式测试,探讨单元测试只是一种必要的邪恶的想法。在测试微服务时,从外到内进行测试:从集成测试开始>然后是单元测试(以覆盖角落的情况)。


4、独立的应用DTO与REST DTO的对比
在学习Bob叔叔的Clean Architecture时,很多人的印象是,REST Endpoints暴露的数据结构应该与Application ring暴露的对象(我们称之为 "ApplicationDto")是不同的结构。也就是说,有一套额外的类,从一个转化为另一个。通常很快就会意识到这个决定的巨大代价,大多数工程师都放弃了。然而,其他人则坚持,他们后来添加到数据结构中的每一个字段都必须现在添加到3个数据结构中:一个在作为JSON发送/接收的Dto中,一个在ApplicationDto对象中,一个在持久化数据的领域实体中。

不要这样做!

应该:
将REST API DTOs传播到应用层

是的,应用服务会更难处理API模型,但这也是保持其轻量的一个原因,剥离了沉重的领域复杂性。

但是否有合理的理由将ApplicationDto与REST API DTO分开?

是的,有的。如果同一个用例通过2个或更多的渠道被暴露出来:例如通过REST、Kafka、gRPC、WSDL、RMI、RSock、服务器端HTML(例如Vaadin...)等。对于这些用例,让你的应用服务通过其公共方法说出自己的数据结构(语言)是有意义的。然后,REST控制器将它们转换为/从REST API DTO,而(例如)gRPC端点将转换为/从它自己的protobuf对象。面对这种情况,一个务实的工程师可能只在需要的几个用例中应用这种技术。


5、将持久性与领域模型分开
我们终于达到了围绕清洁/洋葱架构的最激烈的争论之一。

我应该允许持久性问题污染我的领域模型吗?

换句话说,我是否应该用@Entity来注释我的领域模型,并让我的ORM框架保存/获取我的领域模型对象的实例?

如果你对上述问题的回答是否定的,那么你就需要。

  • 在域外创建所有域实体的副本,例如 CustomerModel (Domain) vs CustomerEntity (Persistence)
  • 为域中的存储库创建接口,只检索/保存域实体,例如 customerRepo.save(CustomerModel)
  • 通过将领域实体转换为/转换为ORM实体来实现基础设施中的存储库接口。

简而言之:痛苦!错误!挫折!这是一个最昂贵的问题。
这是最昂贵的决定之一,因为它有效地将CRUD操作的代码增加了4倍或更多。

我遇到过走这条路并付出上述代价的团队,但他们都在1-2年后对自己的决定感到后悔,只有少数例外。

但是,是什么让受人尊敬的技术领导和架构师做出如此昂贵的决定?

使用ORM的危险是什么?

使用像ORM这样强大的框架从来不是免费的。

以下是使用ORM的主要隐患:

  • 神奇的功能:自动冲刷脏的变化,写在后面,懒加载,事务传播,PK分配,合并(),orphanRemoval,...
  • 非显而易见的性能问题,可能会对你造成伤害。N+1查询、懒惰加载、获取无用的数据量、天真的OOP建模......。
  • 以数据库为中心的建模思维:从表和外键的角度思考,使你更难在更高的抽象层次上思考你的领域,找到更好的方法来重塑它(领域提炼)。

忽视以上几点是不谨慎的。毕竟,它们涉及到你的应用程序中最深刻、最宝贵的部分:领域模型。

所以,这里是你可以做的事情:

1) 神奇的功能,学习或避免。在我的JPA研讨会上,听众中的惊讶程度一直让我感到担忧--人们在没有驾驶执照的情况下驾驶着法拉利。毕竟,Hibernate/JPA的功能要比Spring框架中的功能复杂得多。但不知何故,每个人都认为他们知道JPA。直到魔术师用一个黑暗的错误袭击了你。

你也可以阻止一些神奇的功能,例如,从Repo中分离实体和不保持事务开放,但这种方法通常会产生性能影响和/或令人不愉快的角落案例。因此,最好确保你的整个团队都能掌握JPA。

2) 尽早监控性能:有一些商业工具可以帮助经验不足的团队发现与ORM使用相关的典型性能问题,但防止这些问题的最简单方法(同时也是学习)是用Glowroot、p6spy或任何其他方式记录执行的JDBC语句等工具来关注生成的SQL语句。

3) 以面向对象的方式建立你的领域。即使你正在使用增量脚本(你应该这样做!),也要时刻注意模型重构的想法,并通过让你的ORM在探索时生成模式来尝试它们。面向对象的推理总是能够提供比从表、列和外键方面考虑要好得多的领域洞察力。

总而言之,我的经验告诉我,从长远来看,对一个团队来说,学习ORM要比逃避它更便宜、更容易。所以,在你的领域中允许使用ORM,但要学会它!

6、应用层与基础设施层解耦
在最初的洋葱架构中,应用层与基础设施层是解耦的,目的是让应用服务逻辑与第三方API分离。每次我们想从应用层获得第三方API的一些数据时,我们需要在应用层创建新的数据对象+新的接口,然后在基础设施中实现它(我们前面提到的依赖反转原则)。这对解耦来说是一个相当高的代价。

但是,由于我们大部分的业务规则都是在领域层实现的,应用层就只能协调用例,而不需要自己做很多逻辑。因此,与解耦的成本相比,在应用层操纵第三方DTO的风险是可以容忍的。

将应用与基础设施层合并="务实的洋葱"

将应用程序与基础设施层合并,允许从ApplicationServices自由访问第三方API DTOs。

这样一来,你最终只有2个层/环。应用(包括基础设施)和领域。

风险:如果核心业务规则没有被推入领域层,而是保留在应用层,它们的实现可能会被污染,并依赖于第三方DTO和API。因此,应用服务应该更努力地剥离业务规则。

这里有一个有趣的角落案例。如果你缺乏持久性数据(如果你没有存储很多数据),那么你就没有一个明显的领域模型来映射到/来自第三方数据。在这样的系统中,如果有严重的逻辑需要在这些第三方对象之上实现,在某些时候,也许值得创建你自己的数据结构来映射外部DTO,这样你就可以控制你编写复杂逻辑的结构。


领域复杂度不够
像任何工具一样,同心架构不适合任何软件项目。如果你的问题的领域复杂性相当低(类似CRUD),或者你的应用程序的挑战不在于其业务规则的复杂性,那么洋葱/六角形/端口-适配器/清洁架构可能不是最好的选择,你可能最好采用垂直切片、贫血模型、CQRS或其他类型的架构。

总结
在这篇文章中,我们研究了在应用同心架构(洋葱/六边形/清洁)时出现的过度工程、浪费和错误的以下来源

- 无用的接口 => 删除它们,除非≥2个实现或依赖性反转

- 严格的层 => 宽松的层 = 允许在同一方向上的调用跳过层

- 单线REST控制器 => 与下一层合并,但要控制其复杂性

- 模拟测试 => 折叠层或测试一个更大的块。

- 独立的应用<>控制器Dtos => 使用单一的对象集,除非有多个输出通道

- 从领域模型中分离出持久性 => 不要!使用单一的模型,但要学习ORM的魔力,以及性能的下降。

- 从基础设施中解耦应用 => 合并应用+基础设施层,但在领域中推动更多的业务规则。