架构适应度函数与自动化守护

把架构约束变成可执行断言:分层模型与依赖方向的定义、ArchUnit 与 import-linter 的规则写法与构建集成、圈复杂度与性能回归门禁的阈值设计,以及如何在 CI 中分级拦截违规、生成报告并处理带期限的合理豁免,让架构在无人监督时也能自我守护。

架构图上的分层、边界、依赖方向,在代码评审里靠"人肉记忆"维持,通常撑不过三个迭代。适应度函数(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..");

豁免的三条纪律:

  1. 写进代码而非配置:让豁免出现在 diff 里,评审能看到。
  2. 带到期时间:在注释里写 // TODO(2026-12-31): 解耦后移除,配合定期扫描。
  3. 有独立看板:豁免数量应该是持续下降的指标,而不是越积越多。

7. 踩坑清单

坑后果规避
阈值一步到位全仓库红屏,团队加豁免ratchet:从基线开始只降不升
架构测试混在业务测试里失败定位慢,被忽略独立阶段、独立命名
依赖框架自身包误报规则被当成噪声关掉consideringOnlyDependenciesInLayers()
豁免无期限豁免永久化,规则名存实亡到期时间 + 定期扫描
只写规则不写文档新人不知为何失败规则名与报错信息写清意图
性能门禁用绝对值CI 机器噪声导致误报回归门禁 + 取中位数
规则过多过细维护成本超过收益优先守护 5~10 条高价值规则

8. 总结

适应度函数不是"再写几个测试”,而是把架构决策从文档变成构建的一部分。落地顺序建议:

  1. 先定边界:写清分层模型与允许的依赖方向,这是所有规则的前提。
  2. 再守结构:ArchUnit / import-linter 守护依赖、分层、循环,这几条最值钱。
  3. 后加量化:复杂度与性能门禁用 ratchet 起步,只允许变好。
  4. 最后接 CI:分级门禁 + 显式豁免 + 趋势看板,让它成为工程纪律而非障碍。

当一条架构规则能被自动拦截,它才真正存在。这也是 架构评审与技术债管理 从"周期性人工评审"走向"持续性自动守护"的关键一步——人负责判断该守什么,机器负责确保它一直被守住。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「架构」更多文章

  1. 韧性工程与错误预算:从 SLO 到故障演练
  2. 微前端架构:组合、隔离与独立部署
  3. 数据网格(Data Mesh):领域数据产品与去中心化治理