那个函数叫 process_order,3247 行,接了 11 个参数,其中 3 个是可选的 dict。它是我们系统里最重要也最没人敢碰的一段代码,跑了六年,经手过大概八任维护者,我是第九个。
接手它的原因很简单:我们要加一个「预售订单」类型,而所有人的第一反应都是「在那个大函数里再加个 if」。我不想再加了,那已经是第 60 多个 if 了。
先别急着动手
我做的第一件事不是拆函数,而是花了整整五天读它,边读边画流程图。画完之后我发现几件事:
- 函数里有 4 段几乎一模一样的代码块,是历年复制粘贴的产物,但每段都有细微差别(有的少个日志,有的多个字段),你根本不知道差别是有意的还是笔误;
- 有 7 个变量在函数中段被重新赋值,导致同一个变量名在前 800 行和后 800 行含义完全不同——比如
result前面是校验结果,后面变成了支付结果;
第二件事是查提交历史。这个文件有 400 多次提交,我把最近两年涉及线上故障修复的那 23 次单独挑出来,标成「雷区」——它们背后往往藏着没写进注释的业务约束。
安全网:特征测试
这个函数一行单元测试都没有。直接改等于自杀。
我的做法是写特征测试(characterization test):不管现有逻辑对不对,先把它当前的行为完整记录下来。具体操作是在生产环境的影子流量里,把这个函数的入参和出参序列化落盘,跑了三天,收集了 21 万组样本。去重、脱敏之后,按分支覆盖度做采样,挑出 340 组作为测试用例。
这 340 组能覆盖大约 88% 的分支。剩下 12% 我手工构造了一部分,还有大概 5% 是真的构造不出来,我在代码里标了注释,重构时完全不碰。
写这套测试花了两周。当时有同事觉得性价比太低,「测试比重构本身还费时间」。但事实是,后面六周的重构里,这套测试帮我拦下了 17 次行为改变,其中至少 5 次如果上线就是资损。
拆的顺序
我的顺序是从最不危险的往最危险的走:
- 提取魔法值。 把散落的
status == 3换成枚举。纯机械操作,零风险,但一下子让代码可读性提升了一大截——你终于知道 3 是「已发货」了。 - 提取纯计算逻辑。 那些只依赖入参、不碰数据库和外部服务的片段,直接提成独立函数。这类最好拆,也最容易补精确的单元测试。
- 把 IO 收拢到边界。 原函数里数据库查询散布在各处,有的在循环里(是的,六年里没人发现那是个 N+1,一次下单最多查了 90 次库)。我把所有读操作提到函数开头,一次性查完;写操作压到最后统一提交。
- 按业务阶段切分。 到这一步函数已经瘦到 1400 行了,结构也清晰了,能明显看出「校验、计价、扣减、落库、通知」五个阶段,顺势切成五个方法。
- 最后才处理那些 if。 六十多个分支里,有 40 个其实是订单类型的判断,用策略模式换掉了;剩下的是真正的业务规则,保留原样。
踩的坑
最大的教训:中途手痒改了逻辑。 第三周的时候我看到一段明显写错的边界判断(用了 <= 应该是 <),顺手修了。结果特征测试挂了 6 个用例,我以为是测试数据的问题,跳过了。上线后发现有一类退款金额算错了——那个「明显的错误」是六年前为了兼容一个历史数据格式故意写的。
从那以后我立了规矩:重构期间只做等价变换,发现的 bug 一律记在单独的清单里,重构完成、稳定运行两周之后再单独修。清单最后记了 14 条,重构结束后修了 9 条,另外 5 条经确认是「看着像 bug 其实是需求」。
结果
六周,3247 行变成 16 个函数,最长的一个 180 行。测试覆盖率从 0 到 76%。那个「预售订单」需求,最后用了半天写完,改动 3 个文件、新增 60 行。
但我最满意的不是这些数字,而是三个月后一个刚来的同事跟我说:「这块代码挺好读的。」他不知道它以前长什么样,这大概是重构能得到的最高评价了。