38-模块六-架构演进与团队协作 第38讲-演进式架构 - 适应度函数与架构守护的工程化实现

7 阅读10分钟

模块六-架构演进与团队协作 | 第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。

安全适应度:对接 banditpip-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) 或加权分阈值。


生产环境实战

  1. 与 FastAPI 集成:提供 POST /internal/fitness/run 供 CI 回调,传入 reposhapath,返回 JSON 报告;历史写入对象存储或 PostgreSQL,替代 JSONL。
  2. 性能与安全适应度:性能 job 产出 benchmark.json,本讲 FitnessFunction 子类读取并与基线对比;安全子类解析 sarif 或 CSV,将「高危数=0」作为 passed
  3. 噪声控制:对耦合类指标使用「相对主分支的 delta」而非绝对阈值,避免大仓库误杀。
  4. 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

思考题

  1. 若业务要求「基础设施层可回调领域接口」,你如何在分层适应度中表达「依赖倒置」而非简单禁止 infrastructure -> domain
  2. 加权适应度总分与「任一关键维度失败即阻断」如何组合,才能兼顾敏捷与风险?
  3. AI 生成代码频繁引入跨层 import,你会把该问题放在 IDE 实时提示、pre-commit 还是仅 CI?

下一讲预告

第39讲:技术债务管理——识别、量化与系统性偿还:从 Ward Cunningham 的隐喻到 RICE 优先级,再到 CodeSentinel TechDebtTracker 与偿还流水线,让你看清「利息」从何而来并用数据驱动还债顺序。


本课程项目 CodeSentinel:Python + FastAPI + LangChain 驱动的代码审核与架构治理平台。适应度函数是其架构守护核心组件之一。