Spring Modulith能成熟实现模块化了吗? - Foojay


设计微服务的主要原因之一是它们强制执行强大的模块边界
然而,微服务的缺点是如此之大,就像砍掉你的右手来学习用左手写字一样;有更多易于管理(并且痛苦更少!)的方法来实现相同的结果。
即使自微服务热潮开始以来,一些冷静的头脑也占了上风。
特别是, Spring 框架的开发人员Oliver Drotbohm长期以来一直是模块化替代方案的支持者:
这个想法是保持一个整体,但围绕模块进行设计。

许多人涌向微服务,因为他们处理的应用程序类似于意大利面条拼盘。
如果他们的应用程序设计得更好,微服务的吸引力就不会那么强大。

为什么要模块化?
模块化是一种减少变更对代码库影响的方法。这与设计(大型)船舶的方式非常相似。
当水不断渗入船内时,船通常会因为阿基米德推力的减小而沉没。为避免一次漏水沉船,它基于多个隔间设计。如果发生泄漏,水只会包含在一个隔间中。虽然这个方案并不理想,但它可以防止船沉没,使其能够重新路由到最近的港口,在那里人们可以修理它。
模块化的工作原理类似:它在部分代码周围设置了边界。这样,更改的影响仅限于该部分,不会超出其边界。

在 Java 中,这些部分称为包。与船舶的相似之处到此为止,因为包必须协同工作才能达到预期的结果。
包不能“滴水不漏”。Java 语言提供了跨包边界工作的可见性修饰符。有趣的是,最著名的一个public,允许完全交叉包。
设计遵循最小特权原则的边界需要不断努力。很可能在项目的初始开发压力或维护期间随着时间的推移,努力会失败,边界会衰减。
我们需要一种更先进的方式来强制执行边界。

模块,无处不在的模块
在 Java 的悠久历史中,“模块”一直是一种强制边界的解决方案。问题是,即使在今天,关于什么是模块的定义也有很多。
OSGI始于 2000 年,旨在提供可以在运行时安全部署和取消部署的版本化组件。它保留了 JAR 部署单元,但在其清单中添加了元数据。OSGi 很强大,但是开发 OSGi包(模块的名称)很复杂。
开发人员付出了更高的开发成本,而运维团队则享受了部署带来的收益,DevOps 尚未诞生;它并没有使 OSGi 像它本来应该的那样流行。

与此同时,Java 的架构师也在寻找将 JDK 模块化的途径:与 OSGI 相比,该方法要简单得多,因为它避免了部署和版本控制问题。
Java 9 中引入的 Java 模块将自身限制为以下数据:名称、公共 API 以及对其他模块的依赖性。

Java 模块适用于 JDK,但由于先有鸡还是先有蛋的问题,适用于应用程序的效果就差很多了。为了对应用程序有所帮​​助,开发人员必须将库模块化——而不是自动依赖模块。但只有当有足够多的应用程序开发人员使用它时,库开发人员才会这样做。当前,20 个公共库中只有一半是模块化的。

在构建方面,我需要引用 Maven 模块。它们允许将一个代码拆分到多个项目中。

强化边界的暂定方法
如上所述,微服务在开发和部署期间提供了最终的边界。在大多数情况下,它们是矫枉过正的。另一方面,不可否认的是,项目会随着时间的推移而腐烂。即使是最漂亮的、重视模块化的项目,如果没有持续的关注,也必然会变得一团糟。

我们需要规则来执行边界,并且需要像测试一样对待它们:当测试失败时,人们必须修复它们。同样地,当一个人破坏了一个规则时,他也必须修复它。
ArchUnit是一个创建和执行规则的工具。人们配置规则并将其作为测试进行验证。不幸的是,配置是很耗时的,而且必须不断维护以提供价值。
下面是一个遵循六边形架构原则的示例应用程序的片段:

HexagonalArchitecture.boundedContext("io.reflectoring.buckpal.account")
                     .withDomainLayer(
"domain")
                     .withAdaptersLayer(
"adapter")
                     .incoming(
"in.web")
                     .outgoing(
"out.persistence")
                     .and()
                         .withApplicationLayer(
"application")
                         .services(
"service")
                         .incomingPorts(
"port.in")
                         .outgoingPorts(
"port.out")
                     .and()
                         .withConfiguration(
"configuration")
                         .check(new ClassFileImporter()
                         .importPackages(
"io.reflectoring.buckpal.."));

请注意,HexagonalArchitecture类是ArchUnit API上定制的DSL立面。

总的来说,ArchUnit聊胜于无,但也只是聊胜于无。它的主要优点是通过测试实现自动化。如果架构规则能够被自动推断出来,它就会有明显的改善。这就是Spring Modulith项目背后的想法。

Spring Modulith
Spring Modulith 是 Oliver Drotbohm 的Moduliths 项目(尾随 S)的继承者。它同时使用 ArchUnit 和jMolecules。在撰写本文时,它是实验性的。
Spring Modulith 允许:

  • 记录项目包之间的关系
  • 限制某些关系
  • 在测试期间测试限制

它要求一个人的应用程序使用 Spring 框架:它利用后者对前者的理解,通过DI组装获得。
默认情况下,Modulith 模块是一个与SpringBootApplication-annotated 类位于同一级别的包:

|_ ch.frankel.blog
    |_ DummyApplication       // 1
        |_ packagex          
// 2
        |  |_ subpackagex    
// 3
        |_ packagey          
// 2
        |_ packagez          
// 2
          |_ subpackagez      
// 3

  1. 应用类
  2. 模块化模块
  3. 不是模块

默认情况下,一个模块可以访问任何其他模块的内容但不能访问
Spring Modulith 提供基于 PlantUML 生成基于文本的图表,带有 UML 或C4(默认)皮肤:

var modules = ApplicationModules.of(DummyApplication.class);
new Documenter(modules).writeModulesAsPlantUml();


要在模块访问常规包时中断构建,请verify()在测试中调用该方法:

var modules = ApplicationModules.of(DummyApplication.class).verify();


一个可供玩耍的样本
我创建了一个样本应用程序sample app 供大家使用:它模拟了一个在线商店的主页。这个主页是用Thymeleaf在服务器端生成的,并显示目录项目和新闻提要。后者也可以通过HTTP API访问,供客户端调用(我懒得编码)。物品的显示是有价格的,因此需要一个定价服务。

每个功能--页面、目录、新闻源和定价--都在一个包里,它被看作是一个Spring模块。Spring Modulith的记录功能产生了以下内容:

让我们检查一下定价功能的设计:

目前的设计有两个问题:

  • PricingRepository可以在模块外访问
  • 泄漏PricingServiceJPAPricing实体

我们将通过封装不应公开的类型来修复设计。我们将Pricing和PricingRepositorytypes 移动到模块的internal子文件夹中pricing:



如果我们调用该verify()方法,它会抛出并中断构建,因为Pricing无法从pricing模块外部访问:
Module 'home' depends on non-exposed type ch.frankel.blog.pricing.internal.Pricing within module 'pricing'!
让我们通过以下更改来解决违规问题:


结论
通过尝试示例应用程序,我确实喜欢 Spring Modulith。
我可以看到两个突出的用例:记录现有应用程序和保持设计“干净”。后者避免了应用程序随时间的“腐烂”效应。这样,我们可以保持设计的预期并避免意大利面条效应。
锦上添花:当我们需要将一个 或多个功能切入他们的部署单元时,这很棒。这将是一个非常直接的举措,不会浪费时间来理清依赖关系。Spring Modulith 提供了一个巨大的好处:将每个有影响力的架构决策推迟到最后一刻。