Ohhnews

分类导航

$ cd ..
foojay原文

使用ArchUnit检查Java单体应用的模块边界

#java#模块边界#archunit#单体架构#架构测试

模块边界通常始于一个完全合理的约定:ordering 可以使用 payments API,而支付实现则留在 payments 内部。每个人都理解它,图看起来很整洁,团队继续构建应用。

几个功能之后,有人在 payments.internal 中的一个 public 类上发现了一个有用的方法。使用它省了一点事,编译器很高兴,测试也保持绿色。包名默默地提出了异议,但它在构建中没有投票权。

这种依赖看起来可能无害,直到支付团队更改实现,并发现 ordering 已经对细节产生了依赖。到那时,最初的决策只能从一次对话、一张图,或者任何还记得为什么在这里划边界的人那里重新找回。

我更希望在引入依赖时就进行这场讨论。在本文中,我们会把 ordering 与 payments 之间的约定变成一项架构测试,故意违反它,然后更改依赖以使测试通过。

这个示例使用了 Modulith Rules,这是一个我维护的开源库,提供预构建的 ArchUnit 规则。其核心不依赖 Spring,可在测试作用域中运行,并与应用的其他测试一起执行。

包边界在哪里需要额外保障

我们的应用在 com.shop 下包含两个业务模块:ordering 和 payments。Payments 在其 API 包中暴露以下接口:

$ java
// src/main/java/com/shop/payments/api/PaymentGateway.java
package com.shop.payments.api;

public interface PaymentGateway {
    void pay();
}

实现位于 payments 内的另一个包中:

$ java
// src/main/java/com/shop/payments/internal/PaymentGatewayImpl.java
package com.shop.payments.internal;

import com.shop.payments.api.PaymentGateway;

public class PaymentGatewayImpl implements PaymentGateway {
    @Override
    public void pay() {}
}

我把方法留空,以便我们专注于依赖关系。一个什么也不做的支付实现会构成一个相当宽松的演示,不过我会让它远离结账流程。

该实现是 public 的,因此 payments 内的其他包可以使用它。同样的可见性也让 ordering 可以直接访问该实现:

$ java
// src/main/java/com/shop/ordering/internal/OrderService.java
package com.shop.ordering.internal;

import com.shop.payments.internal.PaymentGatewayImpl;

public class OrderService {
    private final PaymentGatewayImpl payments;

    public OrderService(PaymentGatewayImpl payments) {
        this.payments = payments;
    }

    public void placeOrder() {
        payments.pay();
    }
}

这段代码可以编译,因为该类及其方法是 public。对 Java 编译器来说,internal 只是包名的另一部分。我们表达了一个开发者能读懂的意图,却没有给构建任何可检查的东西。

当一个类型只需要在一个包内使用时,包级私有可见性会有所帮助。不过,一个业务模块通常会跨越多个包,而某些实现类型需要是 public,以便这些包能够协作。我们仍希望阻止其他模块的调用方接触这些实现细节。在这里,ordering 应该使用 payments API,而不去碰它的内部实现。

在测试中表达决策

GitHub 上的完整示例包含两个 Maven 项目:before 和 after,其中包含下文使用的源文件和构建配置。你需要 Java 17 或更高版本。

对于架构测试,请在测试作用域中添加核心库和 JUnit:

$ xml
<dependency>
    <groupId>io.modularmonolith</groupId>
    <artifactId>modulith-rules-core</artifactId>
    <version>0.2.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.2</version>
    <scope>test</scope>
</dependency>

然后我们可以使用包名注册两个模块,并选择保护内部包的规则。基于约定的配置会从根包和模块名推导出 com.shop.ordering 和 com.shop.payments:

$ java
// src/test/java/com/shop/architecture/ArchitectureTest.java
package com.shop.architecture;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import io.modulith.rules.ModulithRules;
import org.junit.jupiter.api.Test;

class ArchitectureTest {
    @Test
    void modulesDoNotAccessEachOthersInternals() {
        var classes = new ClassFileImporter().importPackages("com.shop");

        ModulithRules.forPackage("com.shop", "ordering", "payments")
            .boundaryRules()
            .internalsShouldNotBeAccessedFromOutside()
            .check(classes);
    }
}

运行包含直接实现依赖的版本:

$ shell
cd boundary-example/before
mvn test

测试会报告 OrderService 跨模块边界访问了 payments.internal.PaymentGatewayImpl。它的诊断信息会指出涉及的类,并建议通过 payments API 暴露一个服务接口。我们已经有了那个接口,因此这是一处令人愉快的小改动。

Java 已经检查了其可见性规则,而架构测试则检查了我们为模块约定的额外限制。现在,评审者有一个具体的依赖可以随改动一起讨论。要让代码评审有用,记住应用中的每一条边界就不再那么必不可少。

将依赖移到 API

Ordering 服务可以通过更改导入、字段类型和构造器参数来依赖 PaymentGateway:

$ java
package com.shop.ordering.internal;

import com.shop.payments.api.PaymentGateway;

public class OrderService {
    private final PaymentGateway payments;

    public OrderService(PaymentGateway payments) {
        this.payments = payments;
    }

    public void placeOrder() {
        payments.pay();
    }
}

同一个架构测试现在通过了。Ordering 使用 payments 暴露的契约,因此该契约背后的变更更有余地留在 payments 模块内部。当然,我们仍然需要仔细设计接口;如果调用方必须理解实现才能使用它,那么把接口放在 api 包中并没有太大好处。

应用组装时仍然需要提供实现。组合根或依赖注入配置负责这项接线。我在这个示例中省略了该机制,因为边界检查与应用程序如何提供实现无关。

示例的 after 项目包含修正后的服务:

$ shell
cd ../after
mvn test

将检查引入现有代码库

现有代码库对其边界的看法很可能比这个示例更多。一次性开启所有规则可能会产生一长串违规,其中一些反映了有意做出的妥协,另一些则是没人记得做过的决定。处理这份清单需要时间,尤其是在更改依赖会影响团队需要保留的行为时。

我会从一条团队理解得足够透彻、可以讨论的边界开始。约定哪些依赖属于那里,添加一项聚焦的检查,并处理它发现的问题。这样人们就有机会在构建开始对整个应用提出架构建议之前,判断这条规则是否有帮助。

Modulith Rules 还支持允许依赖列表和循环检测,模块定义可以用 Java 或 YAML 表达。便捷方法会将检查分组:allRules() 包含允许依赖、内部包保护和模块循环,而 allCoreRules() 还会增加仅 API 访问和通信契约检查。Spring 专用规则则单独选择。

这些配置有一些宽松的默认值,在解释通过结果时很重要:

  • 空的 allowedDependencies 配置会使模块不受允许依赖规则的限制。若要要求零出向依赖,请使用专用的 moduleShouldHaveNoDependencies(name) 规则。
  • 仅 API 访问会跳过没有配置 API 包的目标模块。
  • 这些模块规则会忽略注册模块包之外的类。
  • 如果没有显式的内部包配置,库会使用 .internal. 和 .infrastructure. 约定。

这些默认值使渐进式采用成为可能,但也值得仔细阅读。当我们忘记告诉测试要检查什么时,一个通过的测试可能会安静得让人安心。在依赖结果之前,请检查相关模块是否已注册,以及所选规则是否覆盖了你打算限制的依赖。

理解结果的含义

通过的边界测试会告诉我们一些关于所导入代码和所选规则的有用信息。我们仍然需要查看模块相互依赖的其他方式。

两个模块可以满足构建中的每一条 Java 依赖规则,却仍然共享一张数据库表。更改那张表,两个模块都可能需要关注。同样,检查代码依赖几乎无法告诉我们:当一个事件的处理进程在中途失败时,该事件会怎样。

阅读该库的通信契约时,需要牢记这些限制。ASYNCHRONOUS 声明会检查被禁止的直接调用,并有一个已有文档说明的例外:对简单名称以 Event 结尾的类的访问器调用。通过的结果并不能确定事件投递是否可靠,或消费者是否处理重复消息。导出的依赖图中的虚线箭头表示已声明的异步契约,而非观察到的运行时行为。

我会把这些检查与行为测试以及关于数据归属的明确决策放在一起。每一种都让我们从不同视角看待应用;当我们清楚一个通过的架构测试能回答哪些问题时,就更容易信任它。

考虑使用 Spring Modulith

Spring Modulith已经通过 ApplicationModules.verify() 提供结构验证,包括检查模块循环、对内部结构的访问以及显式允许的依赖。该验证可以在测试期间运行,而无需采用 Spring Modulith 的全部运行时能力。

它更广泛的范围包括模块集成测试和生成文档,以及可选的事件发布基础设施和可观测性。Modulith Rules 则专注于可配置的 ArchUnit 策略,具有不依赖框架的核心,以及对 Spring 特定架构约定的可选检查。

如果你的团队已经在使用 Spring Modulith,请先检查它的验证是否覆盖了你想要保护的边界。让构建把同一个决定检查两次,收益很小。在它的额外策略有帮助的地方,或者在你想要一个不依赖 Spring 就能工作的核心库时,Modulith Rules 值得考虑。## 从一个边界开始

前后对比示例 提供了一个小型起点。文档 介绍了可用的规则及其行为。Modulith Rules 采用 Apache License 2.0 许可,问题或疑问可通过问题跟踪器 提出。

当你尝试在自己的代码库中应用规则时,先让它有理由报错。添加一个你确定会违反规则的依赖,并确认测试能够捕获它;然后移除该依赖,但将这项检查保留在构建中。下一个试图访问内部包的人,会在改动还很小的时候就明白这条边界,即使最初的设计讨论发生在其加入团队很久之前。

作者

Omphile Matheolane 是一名软件工程负责人和会议演讲者,专注于 Java、领域驱动设计和模块化架构。他维护 Modulith Rules,这是一个面向模块化单体的开源 ArchUnit 规则库。