模块二-架构基础功 | 第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.py、rate_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 辅助重构
-
分风险等级:格式化与重命名可自动;跨模块搬移必须人审。
-
补丁应用策略:建议以
git apply小补丁形式提供,不允许直接覆盖主分支。 -
审计:记录模型版本、提示词哈希、输入 diff 哈希,满足合规追溯。
-
性能:全量 AST 扫描对大仓库要增量(只扫变更文件)。
-
误报管理:允许
noqa但要附带理由与过期时间。 -
教育与对齐:把本讲坏味道表印成评审清单,减少争论成本。
-
与架构门禁联动:违反分层或依赖方向的 PR,detector 与架构测试双杀。
-
节奏:每周固定“还债日”,否则 detector 只会增加噪音。
-
负责人制度:每个高危坏味道指定 owner 推动修复。
-
复盘:对生产事故回溯是否因重构不足导致;把教训反哺阈值。
-
安全与隐私:不要把含密钥的代码块发送给外部模型;重构建议引擎要对输入做脱敏与最小化片段策略。
-
License 合规:自动补丁可能引入不兼容许可证的代码片段;企业流程要保留人工审查。
-
多语言支持:CodeSentinel 若支持 Java/Go,需要插件化 AST 适配层;不要假设 Python 一种规则打天下。
-
版本冻结期:发布冻结窗口内只允许修复型重构,禁止大范围搬移;用分支策略强制执行。
-
教练机制:资深工程师每周审 2 份“重构 PR”做示范,比堆工具更能改变文化。
-
变更影响分析:对公共库重构要自动生成依赖方清单,避免“我改了接口但不知道谁炸了”。
-
发布回滚演练:重构伴随行为变化时,演练一键回滚到旧版本,并验证数据迁移可逆或可控。
-
开发者体验:报告里附“预计修复耗时”与“自动修复可用性”,帮助排期,而不是只吓唬人。
本讲小结:重构与检测思维导图
mindmap
root((重构艺术))
识别
上帝类
长方法
霰弹式修改
Any滥用
手法
提取
搬移
多态替换条件
引入值对象
安全
小步
测试网
原子提交
CodeSentinel
静态证据
LLM解释
人工合并
延伸阅读:把重构手册变成可执行的团队资产
建议把坏味道词典、阈值策略、PR 模板字段与反例库整理成一份可搜索的手册;每次迭代回顾更新一次版本号。手册里写清楚:哪些规则是硬门禁,哪些只是建议,哪些需要架构委员会豁免。把“反例”也写进去,能显著减少无效争论,让讨论回到证据与目标。对 CodeSentinel 而言,这份手册还能直接映射为规则配置与提示词约束,减少模型自由发挥。
思考题
-
哪些重构动作适合自动化应用?哪些必须禁止自动合并?
-
如果 detector 误报很高,应该先调阈值还是先改团队习惯?
-
你如何在 CodeSentinel 里设计“建议补丁”的版权与责任边界(谁对合并后代码负责)?
-
如果团队坚持“先上线再补测试”,你会如何设计最小化的 characterization tests 来保护下一次重构?
-
当 LLM 给出的重构补丁通过测试但可读性更差时,评审标准应优先信测试还是信人类架构判断?
下一讲预告
下一讲是模块二 模块实战:我们将把 ReviewService 按 Clean Architecture 四层完整落地,包含领域聚合、用例、SQLAlchemy 仓储、Redis 事件发布与 FastAPI 路由,并用 pytest + testcontainers 做集成验证,让你真正 docker compose up 跑通端到端。你会看到:分层不是目录洁癖,而是把 可变部分(数据库、Redis、HTTP)隔离开,让领域规则可以在毫秒级单元测试里反复演练;这也是把 AI 生成代码“纳入轨道”的最强约束之一。