模块六-架构演进与团队协作 | 第38讲:演进式架构 - 适应度函数与架构守护的工程化实现
开场:当架构不再是「一次性蓝图」
在大模型与快速交付并行的时代,许多团队面临同一困境:上线初期的分层清晰、边界明确,却在三个月后因「紧急需求」「AI 批量生成补丁」而层层穿透——领域层直接调用基础设施、循环依赖悄然出现、性能回归无人察觉。传统做法是把架构画在 PPT 上,评审时「对齐愿景」,落地后却缺少可执行的守护机制。Neal Ford 等人在《Building Evolutionary Architectures》中提出的演进式架构(Evolutionary Architecture)给出了另一条路:把架构视为持续演化的系统,用自动化反馈持续验证「我们关心的架构特性」是否仍然成立。
本讲将这一思想落到 CodeSentinel 平台:用**适应度函数(Fitness Functions)**把「依赖方向」「耦合度」「性能与安全基线」变成可运行的检查;用 **ADR(Architecture Decision Records)**把关键决策与演进轨迹记录下来;在每次 PR 上自动执行适应度套件、累积历史分数并在退化时告警。读完本讲,你将能区分「单元测试」与「架构适应度」的边界,并拥有一套可插拔的 Python 实现骨架,直接接入 FastAPI 与 CI。
全局视角:演进式架构与适应度反馈环
下图从「变更入口」到「架构守护闭环」概括 CodeSentinel 在本讲中的角色:开发者提交 PR → CI 触发 FitnessRunner → 多类适应度函数并行评估 → 结果写入历史与看板 → 退化触发告警;同时 ADR 与元数据为「为何如此设计」提供可追溯上下文。
flowchart TB
subgraph 变更流
PR[Pull Request]
CI[CI / CodeSentinel Job]
end
subgraph 适应度层
S[结构适应度<br/>依赖方向/分层]
P[性能适应度<br/>延迟/吞吐 SLO]
SEC[安全适应度<br/>漏洞/合规]
C[耦合适应度<br/>模块互联/API 面]
end
subgraph 产出
R[FitnessRunner 报告]
H[FitnessHistory 趋势]
A[告警: 退化检测]
end
PR --> CI
CI --> S & P & SEC & C
S & P & SEC & C --> R
R --> H
H --> A
ADR[(ADR 知识库)] -.->|决策上下文| PR
适应度函数类型在工程上可归纳为四类(本讲代码覆盖结构与耦合,性能与安全可对接外部工具结果):
mindmap
root((适应度函数))
结构
依赖方向 AST
分层边界 import 规则
性能
延迟 SLO
吞吐基准
安全
漏洞扫描结果
合规策略检查
耦合
模块互联度
公共 API 表面积
核心原理:演进式架构、适应度函数与 ADR
1. 演进式架构的三要素
演进式架构强调三点:可演进性(在可控成本下持续变更)、架构特性(-ilities)的显式优先级(如可测试性、可部署性、安全性)、以及快速反馈。与传统「大设计 upfront」不同,它承认需求与技术栈会变化,因此需要机制保证「每次小步演进」不破坏既定特性。Neal Ford 将适应度函数定义为:以自动化方式持续验证某一架构特性或组合特性的手段——可以是测试、静态分析、指标阈值、策略规则,甚至是对外部系统(APM、安全扫描)结果的断言。
注意:适应度函数不是业务单元测试的替代品。单元测试验证「函数/模块行为正确」;适应度函数验证「系统是否仍满足我们声明的架构约束」。二者互补:前者保证局部正确,后者保证全局形态与质量属性不漂移。
2. 四类适应度在本讲中的含义
结构适应度:例如「领域层不得 import 表示层」「禁止 a→b 与 b→a 的循环依赖」。实现上常用 AST 解析 import/from ... import,构建依赖图后做方向校验。这类检查对 AI 生成代码尤其有效:模型常因「就近调用」破坏分层。
性能适应度:将延迟 P95、QPS、资源占用与 SLO 对比。实现可以是独立基准测试 job,或由 CodeSentinel 读取最近一次基准产物 JSON,在 PR 上与基线分支 diff。
安全适应度:对接 bandit、pip-audit 或企业 SCA 流水线输出,将「高危漏洞数」「许可证违规」作为适应度得分输入。
耦合适应度:统计模块间依赖边数、扇入扇出、包级 API 导出符号数量等。过高耦合预示变更涟漪大,可作为架构退化的早期信号。
3. ADR 与适应度的关系
**ADR(Architecture Decision Record)**用简短文档记录「背景—决策—后果」,通常一个决策一份 Markdown,版本与代码库共存。适应度函数回答「现在是否仍然 OK」;ADR 回答「当初为什么这样 OK」。当团队要修改某条适应度阈值或放宽规则时,应同步新增 ADR,避免「静默降低标准」。CodeSentinel 可将 ADR 元数据(如 adr/0001-use-hexagonal.md)链接到对应适应度规则 ID,实现治理上的可追溯。
4. 工程化要点:PR 门禁、历史趋势与告警
- 每次 PR 运行:将适应度作为必需检查项,失败则阻断合并(或与「仅警告」策略分环境配置)。
- 分数时间序列:同一仓库、同一分支策略下,记录每次运行的加权总分与各维度分,便于可视化「何时开始滑坡」。
- 退化检测:例如「连续三次下降超过 5%」或「某维度从绿变黄」触发 Slack/Webhook。避免单点噪声可用滑动窗口均值。
下面进入与 CodeSentinel 对齐的完整 Python 实现。
代码实战:适应度框架、依赖方向、耦合度、运行器与历史追踪
以下代码可在独立目录中保存为多个 .py 文件,或通过 python fitness_demo.py 运行内联示例(将 if __name__ 块置于单独脚本亦可)。依赖仅标准库,便于接入 FastAPI 服务层。
fitness_base.py — 基类与结果模型
# fitness_base.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
class FitnessCategory(str, Enum):
STRUCTURE = "structure"
PERFORMANCE = "performance"
SECURITY = "security"
COUPLING = "coupling"
@dataclass
class FitnessResult:
name: str
category: FitnessCategory
passed: bool
score: float # 0.0 - 100.0
message: str
details: Dict[str, Any] = field(default_factory=dict)
class FitnessFunction(ABC):
"""适应度函数抽象:子类实现 evaluate(),返回 FitnessResult。"""
name: str = "unnamed"
category: FitnessCategory = FitnessCategory.STRUCTURE
weight: float = 1.0
@abstractmethod
def evaluate(self, root_path: str) -> FitnessResult:
raise NotImplementedError
dependency_direction.py — 基于 AST 的依赖方向检查
# dependency_direction.py
from __future__ import annotations
import ast
import os
from typing import Dict, List, Optional, Set, Tuple
from fitness_base import FitnessCategory, FitnessFunction, FitnessResult
class DependencyDirectionFitness(FitnessFunction):
"""
结构适应度:检查 Python 包内 import 是否违反分层规则。
规则示例:presentation -> application -> domain -> infrastructure
高层不得被低层 import(方向沿 allowed_edges)。
"""
name = "dependency_direction"
category = FitnessCategory.STRUCTURE
weight = 1.5
def __init__(
self,
package_roots: List[str],
layer_order: List[str],
allowed_edges: Set[Tuple[str, str]],
) -> None:
super().__init__()
self.package_roots = package_roots
self.layer_order = layer_order
self.allowed_edges = allowed_edges
def _layer_of(self, mod: str) -> Optional[str]:
"""从 'presentation' 或 'app.presentation.api' 等字符串中解析所属分层名。"""
if mod in self.layer_order:
return mod
for part in mod.split("."):
if part in self.layer_order:
return part
return None
def _imports_in_file(self, path: str) -> List[str]:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
tree = ast.parse(f.read(), filename=path)
mods: List[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
mods.append(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
mods.append(node.module)
return mods
def evaluate(self, root_path: str) -> FitnessResult:
violations: List[str] = []
for base in self.package_roots:
for dirpath, _, files in os.walk(os.path.join(root_path, base.replace(".", os.sep))):
for fn in files:
if not fn.endswith(".py"):
continue
fp = os.path.join(dirpath, fn)
rel_pkg = os.path.relpath(fp, root_path)
parts = rel_pkg.split(os.sep)
src_layer = None
for layer in self.layer_order:
if layer in parts:
src_layer = layer
break
if not src_layer:
continue
for mod in self._imports_in_file(fp):
tgt_layer = self._layer_of(mod)
if not tgt_layer or tgt_layer == src_layer:
continue
edge = (src_layer, tgt_layer)
if edge not in self.allowed_edges:
violations.append(f"{fp}: {src_layer} -> {tgt_layer} (import {mod})")
if not violations:
return FitnessResult(
name=self.name,
category=self.category,
passed=True,
score=100.0,
message="依赖方向符合分层规则",
details={"violations": []},
)
# 分数:违规越多越低,下限 0
penalty = min(100.0, len(violations) * 15.0)
score = max(0.0, 100.0 - penalty)
return FitnessResult(
name=self.name,
category=self.category,
passed=False,
score=score,
message=f"发现 {len(violations)} 处依赖方向违规",
details={"violations": violations[:50]},
)
说明:
_imports_in_file保留完整模块路径(如app.presentation),以便在包名前缀与分层目录并存时仍能命中分层。若与你司目录约定不同,可改为读取import-linter的别名配置。
coupling_fitness.py — 模块耦合度
# coupling_fitness.py
from __future__ import annotations
import ast
import os
from typing import Dict, List, Set, Tuple
from fitness_base import FitnessCategory, FitnessFunction, FitnessResult
class CouplingFitness(FitnessFunction):
"""
耦合适应度:统计包级模块之间的 import 边数,边数过多则扣分。
可扩展为扇出阈值、API 表面符号计数等。
"""
name = "module_coupling"
category = FitnessCategory.COUPLING
weight = 1.2
def __init__(self, scan_roots: List[str], max_edges: int = 80) -> None:
super().__init__()
self.scan_roots = scan_roots
self.max_edges = max_edges
def _top_module(self, path: str, root: str) -> str:
rel = os.path.relpath(path, root)
parts = rel.split(os.sep)
return parts[0] if parts else rel
def _imports_top_levels(self, path: str) -> Set[str]:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
tree = ast.parse(f.read(), filename=path)
tops: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
tops.add(a.name.split(".")[0])
elif isinstance(node, ast.ImportFrom) and node.module:
tops.add(node.module.split(".")[0])
return tops
def evaluate(self, root_path: str) -> FitnessResult:
edges: Set[Tuple[str, str]] = set()
for sub in self.scan_roots:
base = os.path.join(root_path, sub)
if not os.path.isdir(base):
continue
for dirpath, _, files in os.walk(base):
for fn in files:
if not fn.endswith(".py"):
continue
fp = os.path.join(dirpath, fn)
src = self._top_module(fp, root_path)
for t in self._imports_top_levels(fp):
if t == src:
continue
edges.add((src, t))
n = len(edges)
passed = n <= self.max_edges
ratio = min(1.0, n / max(self.max_edges, 1))
score = max(0.0, 100.0 * (1.0 - ratio * 0.9))
return FitnessResult(
name=self.name,
category=self.category,
passed=passed,
score=round(score, 2),
message=f"模块间依赖边数: {n} (阈值 {self.max_edges})",
details={"edge_count": n, "sample": list(edges)[:30]},
)
fitness_runner.py — 运行器与历史
# fitness_runner.py
from __future__ import annotations
import json
import time
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from fitness_base import FitnessCategory, FitnessFunction, FitnessResult
def _fitness_result_to_dict(r: FitnessResult) -> dict:
d = asdict(r)
d["category"] = r.category.value if isinstance(r.category, FitnessCategory) else r.category
return d
class FitnessRunner:
def __init__(self, functions: List[FitnessFunction]) -> None:
self.functions = functions
def run_all(self, root_path: str) -> List[FitnessResult]:
results: List[FitnessResult] = []
for fn in self.functions:
results.append(fn.evaluate(root_path))
return results
def weighted_score(self, results: List[FitnessResult]) -> float:
total_w = sum(f.weight for f in self.functions)
if total_w <= 0:
return 0.0
acc = 0.0
for res, ff in zip(results, self.functions):
acc += res.score * ff.weight
return round(acc / total_w, 2)
class FitnessHistory:
"""将每次运行总分与明细追加到 JSONL,支持简单趋势分析。"""
def __init__(self, storage_path: str) -> None:
self.storage_path = Path(storage_path)
def append(
self,
repo: str,
commit: str,
branch: str,
weighted: float,
results: List[FitnessResult],
) -> None:
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"repo": repo,
"commit": commit,
"branch": branch,
"weighted_score": weighted,
"results": [_fitness_result_to_dict(r) for r in results],
}
with self.storage_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def load_last_n(self, n: int = 20) -> List[dict]:
if not self.storage_path.is_file():
return []
lines = self.storage_path.read_text(encoding="utf-8").strip().splitlines()
out = [json.loads(x) for x in lines[-n:]]
return out
def detect_regression(self, window: int = 5, drop_pct: float = 5.0) -> Optional[str]:
rows = self.load_last_n(window)
if len(rows) < 3:
return None
scores = [r["weighted_score"] for r in rows]
recent = scores[-1]
prev_avg = sum(scores[:-1]) / max(len(scores) - 1, 1)
if prev_avg <= 0:
return None
if recent < prev_avg * (1.0 - drop_pct / 100.0):
return (
f"适应度退化: 最近得分 {recent} 低于前序均值 {prev_avg:.2f} 超过 {drop_pct}%"
)
return None
fitness_demo.py — 可运行入口
# fitness_demo.py
"""演示:在示例目录上运行适应度套件。"""
from __future__ import annotations
import os
import tempfile
from dependency_direction import DependencyDirectionFitness
from coupling_fitness import CouplingFitness
from fitness_runner import FitnessHistory, FitnessRunner
def main() -> None:
root = tempfile.mkdtemp(prefix="codesentinel_fit_")
# 构造最小 app 分层
layers = ["presentation", "application", "domain", "infrastructure"]
for L in layers:
os.makedirs(os.path.join(root, "app", L), exist_ok=True)
open(os.path.join(root, "app", L, "__init__.py"), "w", encoding="utf-8").close()
# domain 引用 presentation -> 违规
bad = "from app.presentation import api\n"
with open(os.path.join(root, "app", "domain", "bad.py"), "w", encoding="utf-8") as f:
f.write(bad)
allowed = {
("presentation", "application"),
("application", "domain"),
("application", "infrastructure"),
("domain", "infrastructure"),
}
dd = DependencyDirectionFitness(
package_roots=["app"],
layer_order=layers,
allowed_edges=allowed,
)
cp = CouplingFitness(scan_roots=["app"], max_edges=50)
runner = FitnessRunner([dd, cp])
results = runner.run_all(root)
w = runner.weighted_score(results)
for r in results:
print(r.name, r.passed, r.score, r.message)
print("weighted:", w)
hist = FitnessHistory(os.path.join(root, "fitness_history.jsonl"))
hist.append("demo/repo", "abc123", "main", w, results)
print("regression:", hist.detect_regression())
if __name__ == "__main__":
main()
接入 PR:在 GitHub Actions / GitLab CI 中检出代码后执行 python -m fitness_runner(按你项目结构调整 import),将 FitnessResult 列表以 JUnit 或自定义 JSON 上传;合并前要求 all(r.passed for r in results) 或加权分阈值。
生产环境实战
- 与 FastAPI 集成:提供
POST /internal/fitness/run供 CI 回调,传入repo、sha、path,返回 JSON 报告;历史写入对象存储或 PostgreSQL,替代 JSONL。 - 性能与安全适应度:性能 job 产出
benchmark.json,本讲FitnessFunction子类读取并与基线对比;安全子类解析sarif或 CSV,将「高危数=0」作为passed。 - 噪声控制:对耦合类指标使用「相对主分支的 delta」而非绝对阈值,避免大仓库误杀。
- ADR 链接:在每条适应度配置中增加
adr_id,UI 上可跳转,便于评审理解「为何必须有这条检查」。
本讲小结(Mermaid mindmap)
mindmap
root((第38讲小结))
演进式架构
架构特性优先
小步快跑+反馈
适应度函数
结构 AST 分层
性能 SLO 基准
安全 扫描断言
耦合 图指标
工程化
PR 必跑
历史趋势
退化告警
ADR
决策可追溯
规则变更同步
适应度看板线框(Mermaid)
flowchart LR
subgraph Dashboard[CodeSentinel 适应度看板]
T[总分趋势折线]
B[按维度条形图<br/>结构/性能/安全/耦合]
L[最近失败明细列表]
ADR[关联 ADR]
end
CI[CI 推送结果] --> T & B & L
ADR -.-> L
思考题
- 若业务要求「基础设施层可回调领域接口」,你如何在分层适应度中表达「依赖倒置」而非简单禁止
infrastructure -> domain? - 加权适应度总分与「任一关键维度失败即阻断」如何组合,才能兼顾敏捷与风险?
- AI 生成代码频繁引入跨层 import,你会把该问题放在 IDE 实时提示、pre-commit 还是仅 CI?
下一讲预告
第39讲:技术债务管理——识别、量化与系统性偿还:从 Ward Cunningham 的隐喻到 RICE 优先级,再到 CodeSentinel TechDebtTracker 与偿还流水线,让你看清「利息」从何而来并用数据驱动还债顺序。
本课程项目 CodeSentinel:Python + FastAPI + LangChain 驱动的代码审核与架构治理平台。适应度函数是其架构守护核心组件之一。