ArchUnit 入门:用单元测试守护分层架构
架构腐化不是一天发生的:某天有人图省事,Controller 直接调了 Mapper;三个月后,没人再说得清依赖规则——除了 ArchUnit,它把架构规则写成会失败的测试。
架构为什么会腐化
分层架构的规矩(Controller → Service → Mapper,禁止越层调用、禁止反向依赖)通常写在文档里、画在图上。但文档没有强制力:
- 项目赶工期,有人发现 Controller 里直接调 Mapper「更快」,没人拦;
- Code Review 没盯住一次越层调用,先例一开,后面人人效仿;
- 一年后,分层图还是那张图,代码里越层调用已经无处不在——这就是架构腐化。
ArchUnit 的思路:把架构规则写成单元测试。规则违反 → 测试红 → CI 挂 → 合不进去。文档靠自觉,测试靠机器。
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
第一条规则:分层依赖
@AnalyzeClasses(packages = "com.clx.clarity", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static void 分层依赖必须自上而下(JavaClasses classes) {
Architectures.layeredArchitecture().consideringAllDependencies()
.layer("Controller").definedBy("..interfaces.admin..")
.layer("Service").definedBy("..service..")
.layer("Mapper").definedBy("..mapper..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer() // 没人能调 Controller
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Mapper").mayOnlyBeAccessedByLayers("Service")
.check(classes);
}
}
这段测试声明了三件事:Controller 是最顶层没人能调它;Service 只能被 Controller 调;Mapper 只能被 Service 调。任何越层调用(哪怕一行),跑测试立刻爆出违反的类名和方法——精确到行。
常用规则示例
循环依赖检测:
@ArchTest
static void 不允许包之间循环依赖(JavaClasses classes) {
NoClassesShould.dependOnClassesThat()
.check(classes); // 配 slices 检测包级循环
SlicesRuleDefinition.slices().matching("com.clx.clarity.(**)..")
.should().beFreeOfCycles().check(classes);
}
循环依赖是代码腐化最早的信号,人眼看不出来,ArchUnit 一查一个准。
注解约束:
@ArchTest
static void 写操作必须挂管理员权限(JavaClasses classes) {
// 所有名含 save/update/delete 的公开方法必须有 @PreAuthorize
methods().that().arePublic()
.and().haveNameMatching(".*(save|update|delete).*")
.should().beAnnotatedWith(PreAuthorize.class)
.check(classes);
}
安全约束从「口口相传」变成了 CI 强制——这条规则的实践价值可能比分层检查还大。
命名与位置匹配:
@ArchTest
static void Service实现类必须放impl包(JavaClasses classes) {
classes().that().haveSimpleNameEndingWith("ServiceImpl")
.should().resideInAPackage("..service.impl..")
.check(classes);
}
实际工程里的分寸
从宽松开始。给存量项目上 ArchUnit,第一步是先跑一次现状检查——大概率一堆红。此时不要硬写严格规则,而是:先把当前已违反的列出来,能低成本修的修掉,修不动又暂且合理的放进 freeze 存档(ArchUnit 支持「冻结已知违规,只拦新增」),让规则只对增量代码生效。约束增量比清算存量重要得多。
规则要有共识。架构测试的规则必须是团队真实认可的规矩——把没人遵守的「理想架构」写成测试,结果只有两个:要么测试永远红着没人管(比没有更糟),要么大家学会绕过。测试是共识的固化,不是共识的替代。
性能可接受。全量类分析在百级类的模块上是毫秒级,挂进常规 mvn test 无感;超大 monorepo 可单独一个 profile 隔离跑。
本站的实践
本站后端是六模块 Maven 多模块结构(web / admin / auth / search / ai / common),模块间依赖靠 ArchUnit 测试守着:
- 模块边界:common 不准依赖任何业务模块(它是所有人的底层);
- 分层:接口层不碰 Mapper、业务逻辑不进 Controller;
- 规范:ServiceImpl 后缀、接口继承结构、写操作的权限注解。
一次想偷懒在 common 里引一个业务类的依赖,测试立刻红——那一刻就是 ArchUnit 的全部价值:腐化被拦在第一次发生时。
小结
ArchUnit 的核心洞察是:架构不是画出来的,是测试跑出来的。分层规则、循环依赖、注解约束,写进 JUnit 测试后,架构从「文档共识」升级为「机器强制」。落地口诀:从宽松规则起步、用 freeze 处理存量、只约束增量——让红测试保持权威性,规则就永远有效。