一次"把回调改成 Promise"的重构,助手在十分钟内改了 40 个文件,编译通过、测试全绿。两周后发现有个地方的错误处理被悄悄改掉了:原来 catch 里会重试一次,改成 await 之后异常直接抛给了调用方——而那处调用方没有 try。
重构的难度不在于"改多少行",而在于哪些改动是机械的、哪些是语义的。助手在机械改动上比人快且不容易漏;在语义改动上它会做出"看起来合理"的选择,
这也决定了团队该怎么分工:人负责"这次重构的语义边界在哪",助手负责"按这个边界批量执行"。边界没划清就让它动手,等于让它替你做设计决定。
而那个选择没人复核。
1. 两类重构的分界
| 类型 | 例子 | 助手可靠性 |
|---|---|---|
| 机械重构 | 改名、提取常量、格式统一 | 高 |
| 结构重构 | 提取方法、拆分函数 | 中 |
| 语义重构 | 回调改 Promise、同步改异步 | 低 |
| 行为重构 | 改错误处理、改重试策略 | 极低 |
第三项与第四项的差别很细:回调改 Promise 属于"改变控制流的表达",而重试策略属于"改变行为"。前者可以机械验证(编译 + 测试),后者只能靠人判断。
实践上的分界线是:改动是否会改变"错误发生时发生什么"。会,就必须人工设计。
2. 机械重构为什么适合交给助手
| 原因 | 说明 |
|---|---|
| 规则可枚举 | 改名、替换都有明确模式 |
| 规模效应 | 40 个文件人工要一天 |
| 一致性 | 不会漏掉第 37 个文件 |
| 可验证 | 编译 + 测试即可确认 |
第四项(可验证)还有一个前提:验证要能覆盖改动类型。类型改动靠编译,逻辑改动靠测试;而"改了错误处理"这类改动,编译与测试都不一定覆盖,所以它落在第三、四类而不是第一类。
第三项是最实在的收益:人工批量改名最典型的错误是"漏了几处",而漏掉的那几处会在运行时才暴露(比如反射、字符串拼的类名、配置文件里的键)。
第二项的收益也要算清:十分钟 vs 一天,但前提是改完能验证。不能验证的批量改动,省下的时间会在排查时还回去。
3. 语义重构的三个风险点
| 风险 | 表现 |
|---|---|
| 错误处理被简化 | 重试、降级、日志丢失 |
| 执行顺序被改变 | 副作用顺序颠倒 |
| 并发语义改变 | 从串行变并行或反之 |
第二项(顺序被改变)在重构里很隐蔽:把两个独立的副作用提取成两个方法、再按字母序调用,看起来是整理,实际改变了"先写库再发消息"与"先发消息再写库"的差别。这类改动在代码评审里几乎看不出来。
第一项最常见:助手倾向于"让代码更简洁",而错误处理代码在它看来是冗余的。于是 try/catch 被合并、重试被去掉、日志被删掉——而测试通常不覆盖这些路径。
第三项最危险:把"顺序 await"改成"Promise.all" 看起来是性能优化,实际改变了并发语义(可能打爆下游)。
// 反例:并发化改变了语义(下游可能承受不了 40 个并发)
const results = await Promise.all(items.map(i => api.call(i)));
// 正例:保持串行,或显式限制并发
for (const i of items) { results.push(await api.call(i)); }
// 或:并发但限流
const results = await pLimit(4)(items.map(i => () => api.call(i)));
4. 让助手做重构的正确流程
| 步骤 | 做法 |
|---|---|
| 1 先写"不变量" | 哪些行为不能变 |
| 2 分批改 | 一次 5–10 个文件 |
| 3 每批验证 | 编译 + 测试 + 抽查 |
| 4 人工审语义 | 只看错误处理与顺序 |
还有一个更硬的约束方式:让它先输出"改动清单"再动手。清单里逐条写"哪个文件、改了什么、为什么",人只需审清单。审清单的成本远低于审 40 个 diff。
// 先要清单,再要代码
输出格式:
1) 文件名 + 改动摘要(一行)
2) 是否触及错误处理/并发/外部接口(是/否)
3) 该文件的验证方式(编译/测试/人工)
// 只要有一条"是" → 该文件转人工
第一项是最有效的约束:明确告诉它"错误处理的语义不能变,包括重试次数与日志"。有了这条,它就不会把 catch 合并掉。
第二项的意义是把"不可验证的大改动"变成"可验证的小批次"——一旦某批出问题,回滚范围是 10 个文件而不是 40 个。
// 重构任务单的四行(写进提示里)
不变量:错误处理的重试次数、日志字段、返回值类型均不变
范围:仅 src/legacy/*.js,共 8 个文件
验证:npm test 全绿 + 抽查 3 处的 catch 分支
禁止:不得合并 catch、不得改 async 边界、不得引入新依赖
重构还有一个"提交粒度"的实践:每个批次一个提交、一次可独立回滚。这样"第 3 批出问题"时,回滚的是第 3 批而不是全部——而一次改 40 个文件只提交一次的做法,让回滚等于重做。
// 分批提交:每批一个 commit,可独立回滚
git checkout -b refactor/callback-to-promise
// 批 1:src/legacy/a*.js(8 个文件)
git commit -m "refactor(legacy): a* 回调改 async,行为不变"
// 批 2:src/legacy/b*.js
git commit -m "refactor(legacy): b* 回调改 async,行为不变"
// 出问题时:git revert <批 3 的 commit> 即可,不影响前两批
5. 验证手段的分层
| 手段 | 能发现 |
|---|---|
| 编译 | 类型与语法 |
| 单元测试 | 已覆盖的行为 |
| 集成测试 | 跨组件行为 |
| 人工抽查 | 未覆盖的错误路径 |
| 灰度对比 | 真实流量差异 |
第五项(灰度对比)有个前提:灰度要有可比的基线。同一时段、同一流量比例下的错误率对比才有意义;若灰度窗口与基线窗口流量特征不同,差异会被误读成重构问题。
第四项不可省略:未覆盖的错误路径正是重构最常破坏的地方,因为测试很少写"下游超时后重试一次"这类用例。
第五项是最后一道:重构后灰度跑一段时间,对比错误率与延迟。如果错误率上升而测试全绿,说明破坏的正是未被覆盖的路径。
机械重构也有"看不见的引用"清单,值得逐项搜一遍:
// 批量改名后必搜的五类引用
1. 字符串里的类名/方法名(反射、动态调用)
2. 配置键(yaml/properties 里的键名)
3. SQL 与 ORM 映射里的列名
4. 前端与后端的接口字段名
5. 日志与监控里的指标名/事件名
// 前四项错了会功能失效;第五项错了会"看起来正常但监控断掉"
6. 排错:五种"重构引入的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 测试全绿但线上错误变多 | 错误路径被改 | 人工抽查 catch |
| 下游被打爆 | 串行改并发 | 恢复串行或限流 |
| 日志少了一段 | 合并了 catch | 补回日志 |
| 某处功能悄悄失效 | 批量改名漏了反射/字符串 | 全局搜字符串引用 |
| 回滚困难 | 一次改太多文件 | 分批 + 每批独立提交 |
第二项(下游被打爆)在"重构 + 性能优化"混做时最常出现:一次提交里既有结构调整又有并发化,出问题时无法判断是哪一项导致的。所以重构与性能改动应当分开提交。
第四项是机械重构的专属风险:改名工具改不了"用字符串拼出来的名字",包括配置键、反射调用、SQL 里的列名。所以批量改名后要专门搜一遍字符串。
7. 边界:什么情况不该交给助手重构
| 场景 | 为什么 | 做法 |
|---|---|---|
| 核心错误处理逻辑 | 语义不可机械验证 | 人工改 + 评审 |
| 并发与事务边界 | 语义容易变形 | 人工设计 |
| 涉及外部契约的命名 | 改名即破坏兼容 | 人工确认范围 |
| 没有测试覆盖的模块 | 无法验证 | 先补测试 |
第四项的顺序很重要:先补测试再重构,而不是"重构完再补"。因为重构后补的测试是照着新实现写的,等于失去了对照。
最后一条与"规模"有关的边界:文件数超过 20 时,收益不再线性增长。因为复核成本随规模上升,而漏改的概率也在上升——此时更划算的是"分批 + 每批独立提交",而不是一次改完。
判断口径一句话:这次改动如果错了,测试会不会变红。会,可以交给助手;不会(错误路径、并发语义、字符串引用),必须人工。
8. 自测
- 两类重构的分界线是什么?为什么"回调改 Promise"与"改重试策略"可靠性差别极大?
- 机械重构适合交给助手的四个原因里,哪一项是最实在的收益?"不能验证的批量改动"为什么省不下时间?
- 三个语义风险点中,哪一项最危险?把"顺序 await"改成
Promise.all改变了什么? - 重构流程四步里,"先写不变量"为什么最有效?分批改的收益是什么?
- 为什么"未覆盖的错误路径"是重构最常破坏的地方?灰度对比能发现什么?
- 判断"能否交给助手"的那个问题是什么?