用 AI 重构:遗留代码梳理与行为不变替换
遗留代码的典型形态是一个 800 行的 service 文件:没人敢删、没人敢读、改一行要回归三天。在这种代码上直接让 AI「重构一下」,出来的是一个更难排查的新版本。可行的路径只有一条:先梳理清楚,再用行为不变的方式一小步一小步换掉。
第一步:让它梳理,别急着改写
第一轮提示词里就要禁止它改代码:
只做分析,不要产出任何代码改动,输出三份清单:
1) 调用链:从入口函数到最底层调用,逐层缩进列出,标注跨包/跨服务调用;
2) 副作用:每个函数涉及的外部状态变化(DB 写、缓存、消息、HTTP、文件、日志);
3) 隐式依赖:全局变量、单例、环境变量、时间与随机数、配置读取位置。
另外标出:无法从代码判断的行为、以及你猜测存在但无法确认的意图。
得到的结果大概长这样,一眼就能看到危险点在哪:
CreateOrder (handler)
└─ svc.Create (service/order.go:88) ← 800 行文件从这里开始
├─ validate(req) 只读,纯函数
├─ repo.GetUser(uid) 副作用:DB 读
├─ price.Calc(items) 副作用:读全局配置 + 调外部 HTTP 计价
├─ repo.InsertOrder(o) 副作用:DB 写(事务内)
├─ repo.UpdateStock(sku, n) 副作用:DB 写(并发热点,无版本号)
├─ cache.Set("order:"+no, o, 1h) 副作用:缓存
└─ mq.Publish("order.created") 副作用:不可回滚,失败只打日志 ← 危险点
梳理阶段要拿到三样东西:调用链、副作用清单、改哪些行为会破坏调用方。这三样定了,重构范围才定得下来。
第二步:先补测试,再动代码
遗留代码通常测试为零。重构前先写特征测试(characterization test)——记录当前行为,而不是记录正确行为:
// 特征测试:把当前输入输出固化成 golden 文件,哪怕当前行为看着像 bug
func TestCreateOrder_Golden(t *testing.T) {
cases := []struct{ name, in string }{
{"normal", "testdata/create_normal.json"},
{"zero_amount", "testdata/create_zero.json"}, // 当前允许 0 元下单,先固化
{"dup_order_no", "testdata/create_dup.json"}, // 当前返回 500,先固化
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := callCreateOrder(t, readFile(t, c.in))
golden.Assert(t, "testdata/"+c.name+".golden", got) // 比对失败时 diff 一目了然
})
}
}
三条纪律:
- 可疑行为也先固化,重构的目标是行为不变,不是顺手修 bug;确认为 bug 的行为单独记 TODO,重构完成后再单独修。
- 覆盖副作用:用真实事务 + 测试库,或注入假的 repo,把「写了几次、写了什么」记进 golden。
- 先跑通再动手:如果写不出测试,说明依赖还太深,先做依赖注入的解耦(见下一步)。
第三步:行为不变的替换
原则是接口不动、逐段替换、每步可回滚。一次只做一件事,提交粒度小到出问题能一眼定位。
以「时间藏在函数内部」这种最常见的不可测写法为例:
// 重构前:时间在内部取,测试只能靠 sleep 或改系统时间
func (s *OrderService) IsExpired(o *Order) bool {
return time.Since(o.CreatedAt) > 30*time.Minute
}
// 重构后:时间从外部注入,签名变化但要全局替换调用方
type Clock interface{ Now() time.Time }
func (s *OrderService) IsExpired(o *Order, now time.Time) bool {
return now.Sub(o.CreatedAt) > 30*time.Minute
}
// 保持原有调用方式给不关心的调用方,减少扩散面
func (s *OrderService) IsExpiredNow(o *Order) bool {
return s.IsExpired(o, s.clock.Now())
}
替换顺序建议固定成这个套路:
| 步骤 | 动作 | 验收 |
|---|---|---|
| 1 | 加一层薄包装或接口,原实现原样留在里面 | 测试全绿,行为零变化 |
| 2 | 新写一份实现,与旧实现并存,用开关切换 | 新旧实现在同一批输入下输出一致 |
| 3 | 切流量到新实现,旧实现保留一个版本 | 观察日志与指标,无差异后删除 |
| 4 | 删除旧实现与开关 | 测试与静态检查通过 |
不要一次重构到底。常见失控方式:顺手改命名、顺手抽公共包、顺手升级依赖、顺手把 interface{} 换成泛型——四种变化混在一个提交里,出问题时无法二分定位。一次只做一种变化,机械替换交给工具:
gopls rename -w ./internal/order/... OrderService.OrderSvc # 重命名交给语言服务器
gofmt -l -w ./internal/order # 格式统一交给 gofmt
go test ./internal/order/... -count=1 -race # 每步都跑,别攒到最后
检查清单
| 检查项 | 通过标准 |
|---|---|
| 梳理产物 | 调用链、副作用清单、隐式依赖三项齐全 |
| 边界界定 | 本次重构动哪些函数、不动哪些,写清楚了 |
| 测试先行 | 特征测试覆盖主要分支与副作用,动手前全绿 |
| 接口稳定 | 对外导出函数签名未变,或调用方已全部同步 |
| 变化单一 | 单个提交只含一种变化类型 |
| 可回滚 | 每步都能单独回退,不依赖前一步之外的假设 |
| 行为对比 | 新旧实现对同一批输入输出一致(含错误信息) |
常见坑
- 让 AI「优化一下这个文件」:它会顺手改逻辑、改命名、删「看起来没用」的分支,而那个分支恰好是某次线上事故的补丁。
- 没有测试就开始拆:重构和修 bug 同时进行,最后分不清是谁改坏的。
- 一次性改完再回归:三天工作量一把梭,出事只能整段回滚。
- 顺手修 bug:把可疑行为一起改掉,行为不变的前提就没了,回归基准也随之失效。
- 相信 AI 的「这个函数没人调用」:删除前用工具确认真实引用(含反射、字符串拼装的路由、模板、配置里的函数名)。
- 不记录中间态:重构方案要写进提交信息或设计笔记,否则下次没人知道为什么留了那个开关。
小结:用 AI 重构遗留代码,先让它只做梳理——调用链、副作用、隐式依赖三份清单;再补特征测试把当前行为固化下来;然后按「加包装、双实现、切流量、删旧码」的次序逐段替换,保持接口稳定、一次只做一种变化。AI 在这里是分析助手和批量改写工具,不是那个替你按下「全部重写」按钮的人。