用 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 在这里是分析助手和批量改写工具,不是那个替你按下「全部重写」按钮的人。

笔记加载中…