架构图上的分层、边界、依赖方向,在代码评审里靠"人肉记忆"维持,通常撑不过三个迭代。适应度函数(Fitness Function)的价值就是把每一条架构约束翻译成一条可执行、可失败、可进 CI 的断言,让架构在无人监督时也能自我守护。本文聚焦落地:写什么规则、用什么工具、阈值怎么定、CI 怎么接。
1. 把架构约束变成可执行断言
1.1 为什么架构约束会失效
约束失效几乎从不因为"没人知道",而是因为违反的代价是零。典型链条:
架构评审通过"domain 层不得依赖 infra 层"
│
├─ 新人赶需求,在 domain 里 import 了一个 DAO → 评审没发现
├─ 第二个人看到已有先例,照做 → 规则事实上被废除
└─ 半年后 domain 层与 infra 层彻底纠缠 → 重构成本远超收益
只要违规不会被自动拦截,它就会被逐步复制。适应度函数切断这条链条的方式很简单:把违规变成一次构建失败。
1.2 适应度函数的分类
按"守护什么"和"什么时候跑"两个维度分类:
| 维度 | 类型 | 手段 | 反馈时机 |
|---|---|---|---|
| 结构 | 依赖/分层规则 | ArchUnit、import-linter | 单测/构建期 |
| 结构 | 循环依赖、模块边界 | 依赖分析器 | 构建期 |
| 质量 | 圈复杂度、重复度 | SonarQube、PMD | 构建期 |
| 性能 | 启动时间、P95 延迟 | 基准测试 | CI 流水线 |
| 运行时 | SLO、错误率 | 监控告警 | 生产 |
越靠左的越应该做成硬门禁:结构类违规没有"这次先放过"的余地;性能与运行时类更适合做趋势告警。这与 演进式架构 中"适应度函数分层守护"的思路一致,区别在于本文给出的是可直接落地的工具与写法。
2. 依赖规则与分层校验
2.1 分层模型与依赖方向
先定义清楚"允许谁依赖谁",否则规则无从写起。一个在单体与模块化单体中通用的四层模型:
┌─────────────────────────────────────────┐
│ interface(Controller / API / Consumer) │ 最外层
├─────────────────────────────────────────┤
│ application(用例编排 / 事务边界) │
├─────────────────────────────────────────┤
│ domain(实体 / 值对象 / 领域服务) │ ← 核心,不依赖任何外层
├─────────────────────────────────────────┤
│ infrastructure(DB / MQ / 外部 HTTP) │ 最内层实现
└─────────────────────────────────────────┘
依赖方向:interface → application → domain ← infrastructure
(domain 定义端口,infra 实现)
关键点:domain 只定义端口接口,infrastructure 实现它(依赖倒置)。因此 domain 不 import 任何 infra 类,infra 反过来可以 import domain。这套结构在 模块化单体 里就是模块边界,在微服务里就是服务边界。
2.2 常见依赖违规
实践中反复出现的违规就那么几种,值得优先守护:
| 违规 | 表现 | 危害 |
|---|---|---|
| 反向依赖 | domain import infrastructure | 核心逻辑绑定具体存储,无法测试 |
| 跨层跳跃 | interface 直接调 infrastructure | 绕过用例层,事务与权限失效 |
| 循环依赖 | moduleA ↔ moduleB | 无法独立构建/替换,编译耦合 |
| 跨模块越界 | order 直接读写 user 的表 | 边界形同虚设,改动互相牵连 |
| 框架污染 | domain 出现 @Entity/@Autowired | 领域模型被框架绑架 |
3. ArchUnit 落地(Java)
3.1 依赖规则
ArchUnit 是 Java 生态最成熟的架构测试库,核心思想是"用测试写架构规则":
@AnalyzeClasses(packages = "com.acme", importOptions = ImportOption.DoNotIncludeTests.class)
class LayeredArchitectureTest {
@ArchTest
static final ArchRule layer_dependencies =
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Interface").definedBy("..interface..")
.layer("Application").definedBy("..application..")
.layer("Domain").definedBy("..domain..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Interface").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Interface")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Application");
}
consideringOnlyDependenciesInLayers() 很关键——不加它,测试会因为框架自身的包引用而误报。
3.2 更细的规则:命名、注解、循环
分层之外,几类高价值规则:
@ArchTest
static final ArchRule domain_must_not_depend_on_framework =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..", "com.baomidou..");
@ArchTest
static final ArchRule controllers_must_be_suffixed =
classes().that().resideInAPackage("..interface.rest..")
.should().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule no_cycles_between_modules =
slices().matching("com.acme.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule services_should_be_annotated =
classes().that().haveSimpleNameEndingWith("Service")
.should().beAnnotatedWith(Service.class);
beFreeOfCycles() 是最值钱的一条规则:循环依赖一旦形成,后续所有边界重构都要先解环,成本指数上升。
3.3 与构建集成
ArchUnit 就是一个 JUnit 测试,天然能跑在 mvn test / gradle test 里,只需在 surefire 的 <includes> 里补上 **/*ArchTest.java。建议把架构测试单独放到 arch-tests 模块或以 *ArchTest.java 命名,让它在 CI 中作为独立阶段运行——架构测试失败与业务测试失败的处置流程完全不同,混在一起会拖慢定位。
4. import-linter 落地(Python)
Python 没有编译期,依赖违规更隐蔽。import-linter 用声明式配置定义"层"与"禁止",通过静态分析 AST 检测:
# setup.cfg
[importlinter]
root_package = myapp
[importlinter:contract:layers]
name = 四层依赖方向
type = layers
layers =
myapp.interface
myapp.application
myapp.domain
[importlinter:contract:domain-purity]
name = domain 不得依赖框架与基础设施
type = forbidden
source_modules =
myapp.domain
forbidden_modules =
myapp.infrastructure
sqlalchemy
fastapi
redis
[importlinter:contract:independence]
name = 模块之间互相独立
type = independence
modules =
myapp.orders
myapp.users
myapp.payments
运行:
pip install import-linter
lint-imports # 违规时返回非零退出码,可直接作为 CI 门禁
# 输出示例
# ============= Import Linter =============
# ------------ 四层依赖方向 ------------
# myapp.domain.repository imported myapp.infrastructure.db (myapp/domain/repository.py:3)
# Contracts: 2 kept, 1 broken.
independence 契约非常适合守护模块化单体的边界——它等价于 ArchUnit 的 beFreeOfCycles() 加上"互不依赖"的更强约束。
5. 复杂度与性能门禁
5.1 圈复杂度与耦合度
结构规则之外,量化指标是第二道防线。阈值必须基于现状基线设定,而不是照搬教科书:
| 指标 | 建议阈值 | 说明 |
|---|---|---|
| 单方法圈复杂度 | ≤ 15 | 超过即建议拆分 |
| 单类方法数 | ≤ 20 | 过多通常是职责不清 |
| 单文件行数 | ≤ 500 | 硬指标,易触发 |
| 模块扇出(efferent coupling) | ≤ 12 | 依赖太多外部模块 |
| 重复代码块 | ≤ 3% | 影响可维护性 |
以 PMD 为例,把复杂度做成构建失败而非警告:
<rule ref="category/java/design.xml/CyclomaticComplexity">
<properties>
<property name="methodReportLevel" value="15"/>
<property name="classReportLevel" value="80"/>
</properties>
</rule>
踩坑点:一次性把阈值卡到理想值,会让整个仓库瞬间"红屏",团队第一反应是加豁免,规则随即失效。正确做法是先把阈值设在当前最差值 + 一点余量,只允许变好不允许变坏(ratchet 机制)。
5.2 性能门禁
性能适应度函数比结构规则更难做,因为它有噪声。可行做法是只守护数量级,配置形如:
performance_gates:
- name: 下单接口 P95
baseline_ms: 120
max_regression_pct: 20 # 超过基线 20% 才失败
samples: 5 # 取 5 次中位数,抗噪声
绝对值门禁(“P95 必须 < 100ms”)在共享 CI 机器上极不稳定,会因为邻居负载而误报。**回归门禁(相对基线)**配合多次取中位数,误报率低得多。
6. 把架构约束接入 CI
6.1 门禁分级
不是所有适应度函数都该阻断合并。按"误报代价"与"修复成本"分级:
| 级别 | 规则示例 | 行为 |
|---|---|---|
| 阻断(blocking) | 分层依赖、循环依赖 | 失败即拒绝合并 |
| 警告(warning) | 复杂度超阈值、命名不规范 | 评论提醒,不阻断 |
| 趋势(trend) | 性能回归、重复度上升 | 记录指标,超阈值告警 |
# GitHub Actions:架构守护流水线(on: [pull_request])
jobs:
arch-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 结构规则(阻断)
run: mvn -q test -Dtest='*ArchTest'
- name: 依赖契约(阻断)
run: pip install import-linter && lint-imports
- name: 复杂度扫描(警告,不阻断)
continue-on-error: true
run: mvn -q pmd:check -Dpmd.failOnViolation=false
- name: 性能回归(趋势)
continue-on-error: true
run: ./scripts/perf-gate.sh
6.2 报告与豁免
规则一多,“合理例外"就不可避免(如某个遗留模块暂时无法解耦)。豁免必须显式、可审计、带期限:
@ArchTest
static final ArchRule legacy_exemption =
noClasses().that().resideInAPackage("..domain..")
.and().haveSimpleNameNotStartingWith("Legacy") // 显式豁免前缀
.should().dependOnClassesThat().resideInAPackage("..infrastructure..");
豁免的三条纪律:
- 写进代码而非配置:让豁免出现在 diff 里,评审能看到。
- 带到期时间:在注释里写
// TODO(2026-12-31): 解耦后移除,配合定期扫描。 - 有独立看板:豁免数量应该是持续下降的指标,而不是越积越多。
7. 踩坑清单
| 坑 | 后果 | 规避 |
|---|---|---|
| 阈值一步到位 | 全仓库红屏,团队加豁免 | ratchet:从基线开始只降不升 |
| 架构测试混在业务测试里 | 失败定位慢,被忽略 | 独立阶段、独立命名 |
| 依赖框架自身包误报 | 规则被当成噪声关掉 | consideringOnlyDependenciesInLayers() |
| 豁免无期限 | 豁免永久化,规则名存实亡 | 到期时间 + 定期扫描 |
| 只写规则不写文档 | 新人不知为何失败 | 规则名与报错信息写清意图 |
| 性能门禁用绝对值 | CI 机器噪声导致误报 | 回归门禁 + 取中位数 |
| 规则过多过细 | 维护成本超过收益 | 优先守护 5~10 条高价值规则 |
8. 总结
适应度函数不是"再写几个测试”,而是把架构决策从文档变成构建的一部分。落地顺序建议:
- 先定边界:写清分层模型与允许的依赖方向,这是所有规则的前提。
- 再守结构:ArchUnit / import-linter 守护依赖、分层、循环,这几条最值钱。
- 后加量化:复杂度与性能门禁用 ratchet 起步,只允许变好。
- 最后接 CI:分级门禁 + 显式豁免 + 趋势看板,让它成为工程纪律而非障碍。
当一条架构规则能被自动拦截,它才真正存在。这也是 架构评审与技术债管理 从"周期性人工评审"走向"持续性自动守护"的关键一步——人负责判断该守什么,机器负责确保它一直被守住。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。