14-模块二-架构基础功 第14讲-代码重构的艺术 - 识别坏味道 安全重构与 AI 辅助重构

0 阅读19分钟

模块二-架构基础功 | 第14讲:代码重构的艺术 - 识别坏味道、安全重构与 AI 辅助重构

开场:AI 写快了代码,也写快了技术债

大模型能在几分钟内生成可运行的服务骨架,但“能运行”与“能演进”不是一回事。你会很快遇到这样的文件:一个类包揽 HTTP、数据库、提示词拼装与业务规则;一个方法两百行,嵌套五层条件;一组字段用字符串字典传来传去,约束全靠注释。它们不是编译器的错误,却是 架构师在评审时必须拦截的坏味道(Code Smells)

本讲以 Martin Fowler 的重构思想为骨架,结合 AI 生成代码的高频问题,给出 可教的识别模式、可落地的安全重构手法、可嵌入 CodeSentinel 的“重构建议引擎”设计。你会看到:为什么“先加测试再动刀”不是教条而是保险;为什么 AI 辅助重构必须 人审合并;以及如何用一个小型 Smell Detector 在 CI 里做低成本扫描。完成本讲后,你应能把评审意见从“我觉得不舒服”升级为“这是 Shotgun Surgery,建议 Extract + Move,并补这三条测试”。

重构讨论很容易变成品味之争,因此本讲刻意把标准 可操作化:每个坏味道对应 触发信号风险重构动作测试抓手。CodeSentinel 作为 AI 驱动的代码审核平台,本身也应当吃自己的狗粮:用静态扫描 + LLM 解释 + 人工确认,形成 建议 → 证据 → 变更 的闭环。下一讲是模块二大实战,把分层架构完整跑通;本讲先把“代码如何安全变形”讲透。

你也可以把坏味道当作 风险信号:它们不一定会立刻导致故障,但会系统性地提高变更失败概率。架构师在评审中提出重构,不是为了“代码好看”,而是为了把未来三个月的返工成本压下去。面对 AI 产出的高速度,如果团队不在流程上同步提速 治理与测试,那么速度只会转化为 不可预测的维护税


全局视角:坏味道检测与 AI 辅助重构流水线

第一张图给出 CodeSentinel 中 重构建议 的端到端流水线:从拉取 diff 到规则扫描,再到 LLM 生成补丁建议(可选),最终由人工确认。

flowchart LR
  PR["Pull Request Diff"]
  SD["SmellDetector<br/>(AST/指标)"]
  RS["规则与阈值"]
  LLM["LLM 解释与改写建议"]
  H["人工复核"]
  CI["CI 门禁/报告"]

  PR --> SD
  RS --> SD
  SD -->|结构化 finding| LLM
  LLM --> H
  H --> CI

第二张图描述 安全重构工作流:小步、可回滚、每步有测试保护。

flowchart TB
  T0["建立基线测试"] --> T1["识别坏味道"]
  T1 --> T2["最小步重构<br/>Extract/Move/Rename"]
  T2 --> T3["运行测试"]
  T3 -->|绿| T4["提交原子 commit"]
  T3 -->|红| T5["回滚或修正"]
  T5 --> T2
  T4 --> T6["重复直到目标达成"]

第三张图对比 自动化重构的边界:机器擅长局部变换,人类负责语义与跨模块契约。

mindmap
  root((AI辅助重构))
    机器擅长
      格式化
      重命名
      提取函数骨架
      生成测试样例
    人类必须
      领域语义
      向后兼容
      性能权衡
      安全边界
    CodeSentinel
      证据链
      风险分级
      可执行建议

核心原理:坏味道分类与 AI 代码特有问题

1. 为什么 AI 更容易制造“上帝类(God Class)”

模型倾向于在单个模块里“把所有事做完”,因为上下文窗口里这样最省事。信号:单文件行数暴涨、公开方法过多、import 面极广、测试只能写集成测试。风险:变更耦合高、Reviewer 难以理解、并发修改冲突频繁。

2. Feature Envy(依恋情结):方法更关心别的对象

典型表现:一个服务方法大量调用 other.data 并操作其内部细节。重构方向:Move Method 到更合适的对象,或通过 引入接口 明确依赖。

3. Long Method(过长方法):控制流即复杂度

AI 常把错误处理、日志、业务规则写进同一函数。信号:圈复杂度过高、嵌套深、重复片段多。重构:Extract Method,并抽出 策略对象 处理分支。

4. Primitive Obsession(基本类型偏执)

dict[str, Any] 或裸 str 表示邮箱、金额、租户 ID。风险:校验散落、错误推迟到运行时。重构:引入值对象(Value Object)dataclass + validate()

5. Shotgun Surgery(霰弹式修改)

改一个需求要动十几个文件的小点。信号:全局搜索某个字符串出现次数异常高。风险:遗漏修改、评审困难。重构:内聚模块移动职责引入门面

6. Duplicate Code(重复代码)

AI 会复制粘贴相似块。重构:Pull Up / Form Template Method,但注意 相似≠相同,错误合并会制造隐性分支。

7. Speculative Generality(夸夸其谈通用性)

提前抽象一堆用不到的接口。信号:大量 pass 实现、从未被调用的策略类。重构:YAGNI 删除 或延迟抽象。

8. Data Clumps(数据泥团)

多参数总是一起出现。重构:参数对象化

9. Lazy Class(冗余类)

薄封装无价值。重构:内联或合并。

10. Inappropriate Intimacy(狎昵关系)

模块互相触碰私有细节。重构:调整可见性、引入中介。

11. AI 特供坏味道:提示词泄漏与“字符串业务规则”

业务规则以提示词片段散落在代码里,无法测试。重构:规则数据化 + 端口隔离,LLM 只负责生成解释,不负责持有真相。

12. 安全重构的黄金法则

小步前进测试先行或并行补测试每次只做一个重构动机(Fowler 的忠告)。不要“顺手优化性能 + 改接口 + 格式化”混在一个 commit。

13. 与 CodeSentinel 产品的结合点

平台输出应包含:坏味道类型证据(行号/指标)建议动作风险等级推荐测试点。LLM 用于生成 解释文本与示例补丁,但必须标注“建议非最终代码”。

14. 评审文化:把重构当作交付,而不是额外工作

没有排期的重构会永远排队。建议在迭代中固定 10%~20% 债务带宽,并用 CodeSentinel 报告驱动优先级。

15. 静态分析 vs LLM:分工明确

静态分析给 确定性证据;LLM 给 语义解释与改写草案。颠倒顺序会导致“模型胡说但听起来对”。

16. 测试作为安全网:没有测试时怎么办?

优先补 characterization tests(刻画现有行为),再进行重构。对遗留系统这是救命绳。

17. 何时拒绝重构?

发布窗口极紧、风险不可评估、缺少监控与回滚方案时,应先 止血 再谈整形。

18. 与 Clean Architecture 的关系

重构不是推翻分层,而是 把放错层的代码搬回家。领域规则误入基础设施时,要搬回领域;HTTP 细节渗入领域时,要推出去。

19. 度量驱动:复杂度阈值只是起点

圈复杂度、类长度、依赖度可以进 CI,但要防止 数字游戏。阈值应与代码库基线一起演进。

20. 小结:重构是经济学

每一次重构都是在买 未来的变更成本下降。AI 让生产代码变便宜,也让债务累积变快;架构师要用流程与工具把曲线拉平。

21. “重构”与“重写”的边界:什么时候该停手?

当模块测试覆盖极低、领域语言已经混乱、依赖图呈 spaghetti 时,局部重构可能变成 打补丁式迁移。此时应评估是否 划定边界上下文、引入 防腐层(ACL)、或以 绞杀者模式(Strangler) 渐进替换。CodeSentinel 若发现某旧集成模块不断触发 Shotgun Surgery,应把它标记为 重写候选,而不是无限 Extract Method。

22. AI 生成测试的陷阱:测试也可能被污染

让模型“顺便生成测试”时,常见问题是测试只覆盖 happy path,甚至 断言过弱(恒真)。评审要检查:是否包含负例、边界、并发相关假设是否与真实环境一致。重构安全网的价值取决于测试是否真的会失败。

23. 代码风格与重构:先统一格式,再谈结构

混用格式化会导致 diff 噪声巨大,掩盖真实语义变更。建议 Black/ruff 先落地,再开启结构重构;否则 Reviewer 会在空格与引号上浪费注意力。

24. 依赖方向与重构:Move Class 前先画依赖图

把类移动到“看起来更舒服”的包,可能意外制造 循环依赖 或破坏 Clean Architecture 依赖规则。移动前先回答:谁依赖谁?移动后 import 图是否仍然单向?这类问题在 AI 自动搬文件时尤其常见。

25. 性能敏感路径的重构策略

提取函数可能引入额外调用开销(通常可忽略),但也可能改变热路径上的内存分配。对极少数关键路径,重构后要做 基准对比。不要为了“优雅”在热循环里制造大量临时对象。

26. CodeSentinel 报告 UX:让人愿意点开

坏味道报告如果只有冷冰冰指标,开发者会习惯性忽略。建议输出 一句话摘要 + 三段结构:现象、影响、建议动作;并提供 跳转链接 到具体行。LLM 适合写摘要,但不适合单独作为证据。

27. 与 PR 大小的关系:大 PR 是重构的敌人

巨型 PR 让 Reviewer 无法证明安全性。重构应尽量 拆分:先纯搬移(行为不变),再改逻辑(行为有变)。AI 辅助生成时尤其要提示模型 不要一次输出巨型 diff

28. 语言特性误用:Python 里的“动态便利”

过度使用 **kwargs、猴子补丁、运行时改类属性,会让静态扫描失效。重构方向是 显式接口可测试的依赖注入

29. 团队协作:重构是否需要 RFC?

影响多个团队接口时,需要 RFC 或 ADR;影响单模块内部结构时,用 PR 描述 + 测试即可。把流程搞得太重会扼杀重构积极性,太轻又会引发集成灾难。

30. 终极问题:重构是否为功能让路?

现实里永远让路,但要有 债务预算可见指标(例如平均函数长度、重复率、依赖违规数)。CodeSentinel 可以把指标趋势图挂到团队看板上,让“让路”变成可恢复的策略,而不是永久状态。


代码实战:重构手法 Before/After + SmellDetector 完整实现

1. Replace Conditional with Polymorphism(示意 Before/After)

下面两段代码不是为了“炫技”,而是演示 同一抽象层级表达业务:Before 把规则堆在函数里,After 把规则变成可注册表。对 CodeSentinel 来说,这类改造让“新增一种漏洞类型”从改条件链,变成 新增一个规则类 + 单测,评审也更清晰。

Before(长条件链)

def score_severity(kind: str, lines: int) -> int:
    if kind == "sql_injection":
        return 100
    if kind == "xss":
        return 80
    if kind == "secret_leak":
        return 95
    if lines > 1000:
        return min(100, 50 + lines // 200)
    return 10

After(策略映射 + 多态)

from dataclasses import dataclass
from typing import Protocol


class SeverityRule(Protocol):
    def score(self, ctx: "FindingContext") -> int: ...


@dataclass(frozen=True)
class FindingContext:
    kind: str
    lines: int


@dataclass(frozen=True)
class SqlInjectionRule:
    def score(self, ctx: FindingContext) -> int:
        return 100


@dataclass(frozen=True)
class SecretLeakRule:
    def score(self, ctx: FindingContext) -> int:
        return 95


RULES: dict[str, SeverityRule] = {
    "sql_injection": SqlInjectionRule(),
    "secret_leak": SecretLeakRule(),
}


def score_severity(ctx: FindingContext) -> int:
    rule = RULES.get(ctx.kind)
    if rule is not None:
        return rule.score(ctx)
    if ctx.lines > 1000:
        return min(100, 50 + ctx.lines // 200)
    return 10

2. Extract Method(示意)

把“校验 + 组装 DTO + 记录日志”拆成私有方法,使主流程可读。核心原则是:每个函数的同一抽象层级上只讲故事,不讲故事里的每个标点

3. smell_detector.py:教学版坏味道扫描器(可运行)

以下脚本对单个 Python 文件做 AST 分析,检测:

  • 过长函数(行数阈值)
  • 过高圈复杂度(简化版:分支计数)
  • 上帝类信号:类方法数过多
  • Any 滥用计数
from __future__ import annotations

import ast
import dataclasses
from pathlib import Path
from typing import Iterable, List


@dataclasses.dataclass(frozen=True)
class SmellFinding:
    code: str
    message: str
    lineno: int
    name: str


class SmellVisitor(ast.NodeVisitor):
    def __init__(self, *, max_func_lines: int = 60, max_branch: int = 10, max_methods: int = 15) -> None:
        self.max_func_lines = max_func_lines
        self.max_branch = max_branch
        self.max_methods = max_methods
        self.findings: List[SmellFinding] = []

    def _iter_body_lines(self, body: Iterable[ast.stmt]) -> int:
        if not body:
            return 0
        start = body[0].lineno
        end = max(getattr(n, "end_lineno", n.lineno) for n in body)
        return end - start + 1

    def _count_branches(self, node: ast.AST) -> int:
        count = 0
        for child in ast.walk(node):
            if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.With)):
                count += 1
        return count

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        lines = self._iter_body_lines(node.body)
        if lines > self.max_func_lines:
            self.findings.append(
                SmellFinding(
                    code="LONG_METHOD",
                    message=f"函数 `{node.name}` 过长:约 {lines} 行(阈值 {self.max_func_lines})",
                    lineno=node.lineno,
                    name=node.name,
                )
            )

        branches = self._count_branches(node)
        if branches > self.max_branch:
            self.findings.append(
                SmellFinding(
                    code="HIGH_COMPLEXITY",
                    message=f"函数 `{node.name}` 分支/结构偏多:计数 {branches}(阈值 {self.max_branch})",
                    lineno=node.lineno,
                    name=node.name,
                )
            )

        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        methods = [n for n in node.body if isinstance(n, ast.FunctionDef)]
        if len(methods) > self.max_methods:
            self.findings.append(
                SmellFinding(
                    code="GOD_CLASS",
                    message=f"类 `{node.name}` 方法过多:{len(methods)}(阈值 {self.max_methods})",
                    lineno=node.lineno,
                    name=node.name,
                )
            )

        self.generic_visit(node)


class AnyCounter(ast.NodeVisitor):
    def __init__(self) -> None:
        self.any_hits: List[int] = []

    def visit_Name(self, node: ast.Name) -> None:
        if node.id == "Any":
            self.any_hits.append(node.lineno)
        self.generic_visit(node)


def detect_file(path: Path) -> List[SmellFinding]:
    source = path.read_text(encoding="utf-8")
    tree = ast.parse(source, filename=str(path))

    visitor = SmellVisitor()
    visitor.visit(tree)

    any_counter = AnyCounter()
    any_counter.visit(tree)
    for ln in any_counter.any_hits:
        visitor.findings.append(
            SmellFinding(
                code="PRIMITIVE_ANY",
                message="使用 `Any` 可能掩盖类型约束(考虑 TypedDict / Protocol / dataclass)",
                lineno=ln,
                name="Any",
            )
        )

    return sorted(visitor.findings, key=lambda f: f.lineno)


def main() -> None:
    import sys

    if len(sys.argv) < 2:
        print("用法: python smell_detector.py your_file.py")
        raise SystemExit(2)

    p = Path(sys.argv[1])
    findings = detect_file(p)
    if not findings:
        print("未检测到明显坏味道(在阈值内)。")
        return

    for f in findings:
        print(f"{p}:{f.lineno}: [{f.code}] {f.message}")


if __name__ == "__main__":
    main()

4. 如何把 SmellDetector 嵌进 CodeSentinel

  • CI 阶段:对变更文件运行 detector,超过阈值失败或警告。
  • 报告阶段:把 finding 序列化为 JSON,喂给 LLM 生成 人类可读建议(带代码引用)。
  • 治理阶段:不同仓库可配置不同阈值(核心库更严,脚本更松)。

5. 测试建议:为 detector 自己写单元测试

构造最小 AST 片段或示例文件 samples/god_class.py,断言输出包含 GOD_CLASS。否则规则演进时会无声回归。

6. 与人工评审的接口:输出要可执行

不要只输出“建议拆分”。要输出:推荐提取的函数签名草稿可能受影响的调用点列表需要补的测试场景列表

7. Move Method 迷你示例:把“评审评分”搬回领域

当你发现 FastAPI 路由函数里出现大量 if review.status == ... 的业务判断,这通常是 领域逻辑泄漏。重构不是简单搬运代码,而是把状态迁移规则收敛到 Review 聚合的方法中,例如 review.complete(),路由只负责鉴权与 DTO 映射。AI 生成代码时常把“方便”写在路由层,评审时要敏感。

8. Replace Magic Literal:把字符串状态变为枚举

status="done"status="completed" 并存是典型 AI 产物。引入 ReviewStatus 枚举或值对象,并在入库层做映射,能显著降低隐蔽分支。

9. 运行 smell_detector.py 的自检清单

worker.pyrate_limiter.py、本课程示例文件跑一遍,观察阈值是否合理;再对一个真实业务文件跑一遍,记录误报类型。把误报分类为:规则太严上下文特殊需要额外语义 三类,分别用阈值、豁免、LLM 二次确认解决。

10. 与 ruff/mypy 的边界

ruff 解决风格与部分静态问题;mypy 解决类型一致性;本讲 detector 解决 结构级坏味道。三者叠加才接近“可治理的 CI”。不要期待一个工具解决所有问题。

11. 交互式重构与批处理重构:CodeSentinel 的两种产品形态

一种是 IDE 插件里对选中片段给出 就地建议;另一种是对整个 PR 做 批处理扫描 生成报告。前者反馈快,适合局部;后者覆盖全,适合治理。架构上要共享同一套规则引擎,否则会出现“IDE 说没事,CI 说有事”的撕裂。对大型企业,还可以引入 分层策略:开源项目用公开规则集,内部核心库用更严规则集,并在组织层面统一版本号。

12. 证据链设计:让开发者“服气”

Finding 里应包含:规则编号阈值实际值相关代码片段官方文档或内部规范链接。没有证据链,LLM 再会写故事也会被当成胡扯。证据链也是法务与审计友好:当自动建议引发争议时,可以回溯“当时依据是什么”。

13. 负反馈闭环:误报如何回流改进规则

每周统计 Top 误报规则,负责人必须在迭代内给出 调整或文档解释。否则工具会被整体禁用——这在很多公司都发生过。负反馈应当进入工单系统,而不是停留在口头抱怨。

14. 与 Code Review 文化的耦合:Reviewer 要学会引用规则

当 Reviewer 说“我不喜欢”,开发者无从修改;当 Reviewer 说“这是 LONG_METHOD,按手册第 3.2 节拆分”,对话成本骤降。架构师要做的是把 品味翻译成规范,并把规范编码成可运行的检查。

15. 小结:重构能力是团队的可扩展性

如果团队只会堆功能不会重构,人员扩张只会线性增加沟通成本;如果团队会小步重构并有测试与工具支撑,扩张才可能带来 超线性产出。AI 时代尤其如此:代码产出曲线上升,治理曲线必须同步上升,否则系统会在看不见的地方失速。

16. 面向对象与设计模式:不要为了模式而模式

AI 喜欢“顺手给你一个策略模式 + 工厂模式 + 观察者”。评审要问:复杂度是否匹配问题规模?如果业务只有两种分支,强行模式化可能增加阅读负担。重构的目标是 降低认知负担,不是展示模式百科全书。

17. 数据模型重构与 API 兼容:双重挑战

当你把内部值对象引入时,对外 API 可能仍在使用旧字段。需要 版本化 DTO渐进迁移,并在 CodeSentinel 报告里提示“破坏性变更风险”。否则重构会变成集成事故。

18. 并发与重构:小心共享可变状态

AI 生成的全局缓存、模块级变量在单测里可能“刚好能过”,多线程下会炸。重构时要把共享状态收敛到明确的 边界,并用并发测试或静态检测提示风险。

19. 文档与注释:重构时要同步更新通用语言

变量改名、领域概念合并后,文档若仍使用旧词汇,会造成 onboarding 混乱。建议把“术语表”作为仓库根目录的短文档,并在重构 PR 里强制检查相关条目。

20. 把时间维度写进重构计划:不是一次做完

大型重构应拆成多个可交付里程碑,每个里程碑都能上线并回滚。CodeSentinel 可以在 PR 标签里识别 refactor 类型并统计趋势,帮助管理层看到治理投入。


生产环境实战:在真实团队落地 AI 辅助重构

  1. 分风险等级:格式化与重命名可自动;跨模块搬移必须人审。

  2. 补丁应用策略:建议以 git apply 小补丁形式提供,不允许直接覆盖主分支。

  3. 审计:记录模型版本、提示词哈希、输入 diff 哈希,满足合规追溯。

  4. 性能:全量 AST 扫描对大仓库要增量(只扫变更文件)。

  5. 误报管理:允许 noqa 但要附带理由与过期时间。

  6. 教育与对齐:把本讲坏味道表印成评审清单,减少争论成本。

  7. 与架构门禁联动:违反分层或依赖方向的 PR,detector 与架构测试双杀。

  8. 节奏:每周固定“还债日”,否则 detector 只会增加噪音。

  9. 负责人制度:每个高危坏味道指定 owner 推动修复。

  10. 复盘:对生产事故回溯是否因重构不足导致;把教训反哺阈值。

  11. 安全与隐私:不要把含密钥的代码块发送给外部模型;重构建议引擎要对输入做脱敏与最小化片段策略。

  12. License 合规:自动补丁可能引入不兼容许可证的代码片段;企业流程要保留人工审查。

  13. 多语言支持:CodeSentinel 若支持 Java/Go,需要插件化 AST 适配层;不要假设 Python 一种规则打天下。

  14. 版本冻结期:发布冻结窗口内只允许修复型重构,禁止大范围搬移;用分支策略强制执行。

  15. 教练机制:资深工程师每周审 2 份“重构 PR”做示范,比堆工具更能改变文化。

  16. 变更影响分析:对公共库重构要自动生成依赖方清单,避免“我改了接口但不知道谁炸了”。

  17. 发布回滚演练:重构伴随行为变化时,演练一键回滚到旧版本,并验证数据迁移可逆或可控。

  18. 开发者体验:报告里附“预计修复耗时”与“自动修复可用性”,帮助排期,而不是只吓唬人。


本讲小结:重构与检测思维导图

mindmap
  root((重构艺术))
    识别
      上帝类
      长方法
      霰弹式修改
      Any滥用
    手法
      提取
      搬移
      多态替换条件
      引入值对象
    安全
      小步
      测试网
      原子提交
    CodeSentinel
      静态证据
      LLM解释
      人工合并

延伸阅读:把重构手册变成可执行的团队资产

建议把坏味道词典、阈值策略、PR 模板字段与反例库整理成一份可搜索的手册;每次迭代回顾更新一次版本号。手册里写清楚:哪些规则是硬门禁,哪些只是建议,哪些需要架构委员会豁免。把“反例”也写进去,能显著减少无效争论,让讨论回到证据与目标。对 CodeSentinel 而言,这份手册还能直接映射为规则配置与提示词约束,减少模型自由发挥。


思考题

  1. 哪些重构动作适合自动化应用?哪些必须禁止自动合并?

  2. 如果 detector 误报很高,应该先调阈值还是先改团队习惯?

  3. 你如何在 CodeSentinel 里设计“建议补丁”的版权与责任边界(谁对合并后代码负责)?

  4. 如果团队坚持“先上线再补测试”,你会如何设计最小化的 characterization tests 来保护下一次重构?

  5. 当 LLM 给出的重构补丁通过测试但可读性更差时,评审标准应优先信测试还是信人类架构判断?


下一讲预告

下一讲是模块二 模块实战:我们将把 ReviewService 按 Clean Architecture 四层完整落地,包含领域聚合、用例、SQLAlchemy 仓储、Redis 事件发布与 FastAPI 路由,并用 pytest + testcontainers 做集成验证,让你真正 docker compose up 跑通端到端。你会看到:分层不是目录洁癖,而是把 可变部分(数据库、Redis、HTTP)隔离开,让领域规则可以在毫秒级单元测试里反复演练;这也是把 AI 生成代码“纳入轨道”的最强约束之一。