代码重构实战——面对10万行的遗留代码,我的实际操作步骤
代码重构实战——面对10万行的遗留代码,我的实际操作步骤
适读人群:有1年以上工作经验的 Java 开发者 | 阅读时长:约18分钟 | 核心价值:一套真实可落地的遗留代码重构方法,不是理论,是我自己踩过坑之后沉淀下来的步骤
说实话,大多数讲重构的文章都在讲 Martin Fowler 那本书,说要提炼函数、内联变量、以多态替换条件表达式。我也读过那本书,读完之后依然不知道面对真实项目里那坨10万行的屎山代码应该从哪里下手。
这篇文章我想讲的,是我在真实项目里做重构的实际经历,包括我犯过的错、走过的弯路,以及最终让我觉得有效的那套流程。
先说一个惨痛的教训
2019年,我接手了一个电商系统的订单模块。前任开发离职前把代码写得一言难尽:一个 OrderService 有4000行,各种业务逻辑全塞在里面,数据库直连写在 service 层,有些地方连事务都没加,注释几乎是零。
我当时刚接手,血气方刚,觉得这代码简直没法看,决定"重写"而不是"重构"。用了三个月,从头写了一遍。写完之后发布上线,当天就出了三个 P1 故障:一个是优惠券并发扣减的隐患,一个是退款状态机的边界 case,还有一个是大促期间的库存超卖。
这三个问题,在老代码里其实都有处理,只是藏得很深,我没发现,新代码就没带过来。
那次事故之后,我花了将近两周时间做故障复盘,老板问我:你为什么要重写而不是重构?我说,因为旧代码太烂了,没法改。老板说:旧代码再烂,里面有三年的业务沉淀,你把那些沉淀扔掉,就等着被业务打脸。
这句话是我那几年听到的最有价值的一句话之一。
重构和重写的本质区别
很多人把重构和重写搞混了。重写是把现有代码推倒重来,重构是在不改变外部行为的前提下,改善代码的内部结构。
这个定义听起来简单,但实际执行起来有个很大的坑:你怎么保证"不改变外部行为"?
靠测试。
没有测试覆盖的重构,都是在裸奔。这是我在那次事故之后总结出来的第一条铁律。
但问题来了:遗留系统通常测试覆盖极低,很多项目根本没有单元测试,集成测试也只有个位数。这时候你面临一个先有鸡还是先有蛋的问题:要重构才能写测试,要写测试才能重构。
我的解法是:先补特征测试(Characterization Test),再动代码。
特征测试不是标准叫法,但我觉得这个概念很好理解:你把现有代码的行为,原原本本录下来,作为基准。不管那个行为对不对,你先把它固定住。这样改了代码之后,如果测试失败了,你至少知道"我改动了某个行为",然后判断这个改动是不是你想要的。
我实际用的10步流程
第一步:建立代码全景图(1-2天)
不要一开始就头铁去看代码。先在白板上(或者脑子里)建立这个系统的全景图:
- 这个模块/服务的边界在哪里?对外暴露了哪些接口?
- 依赖了哪些外部系统?数据库、缓存、消息队列、第三方接口?
- 流量从哪里进来?大概的 QPS 是多少?
- 最近三个月,这个模块出现过几次故障?故障的原因是什么?
这个步骤的目的不是把代码读懂,是先搞清楚这块代码对整个系统的重要性,以及它最容易出问题的地方在哪里。
有一次我们要重构一个推荐系统的排序模块,做全景图的时候发现,这个模块每天凌晨2点有一个大批量的离线计算任务,白天有实时调用,两个路径共享同一套代码,但调用方式完全不同。如果我一开始不做全景图,直接下手,很可能会把离线任务那条路径给改坏。
第二步:找出"热点代码"(半天)
10万行代码你不可能全部重构。也不应该全部重构。
用两个维度来定位热点代码:
- 变更频率:用
git log --follow --stat看哪些文件在过去6个月改动最频繁,改动频繁的地方往往是业务最集中、最乱的地方。 - Bug 来源:看线上故障的根因统计,哪些类/方法反复出现在故障 RCA 里?
把这两个维度叠加,优先重构那些既频繁变更、又容易出故障的代码。其他地方,能不动就不动。
这个原则听起来有点反直觉,但我觉得是对的:对遗留代码做重构的主要目的,是降低未来维护的成本,而不是让代码变得"更漂亮"。漂亮不会给业务带来价值,稳定+可维护才会。
第三步:补特征测试
找到热点代码之后,开始补测试。
我的做法是:把现有代码的调用入口找出来,写集成测试或者端到端测试,把所有已知的典型 case 覆盖一遍。注意,我说的是"已知的典型 case",不是"所有情况",因为遗留代码里往往有大量隐式的 case,你根本不知道。
补测试的时候会遇到一个困难:代码耦合太重,根本没办法单独测某个类。这时候不要强行拆,先用集成测试覆盖住行为,之后再通过重构让代码变得可测。
在那个订单系统里,我们当时用了 Spring Boot Test + H2 内存数据库,把整个 OrderService 的主要流程都跑了一遍,把所有测试结果截图存档。这个存档在后来的重构过程中救了我好几次——改完某个地方之后测试突然红了,一对比存档,发现是某个边界场景的返回值变了,立刻能定位到问题。
第四步:划定重构边界
这一步很多人会忽略,但我认为是最重要的一步:在正式动代码之前,和团队(包括测试、产品,甚至老板)明确好:
- 这次重构的目标是什么?(提升可维护性?消灭某类 Bug?为某个新功能铺路?)
- 重构的范围是什么?(哪些类、哪些逻辑在范围内,哪些不在?)
- 怎么验证重构是成功的?(测试通过?代码复杂度下降?回滚方案是什么?)
没有清晰边界的重构,很容易范围蔓延(Scope Creep)。我见过太多次"改着改着就改了整个系统"的案例,最后搞得上线日期全部推迟,老板暴跳如雷,团队士气跌入谷底。
边界要落成文字,发到群里确认。这不是走流程,是保护自己,也是保护项目。
第五步:小步快跑,每步可回滚
这是我最强调的一条:重构要小步,每一步都要能回滚。
我的操作习惯是:每个重构 commit,只做一件事。提炼一个方法是一个 commit,重命名一个变量是一个 commit,把某个条件判断反转是一个 commit。
这样做有几个好处:
- 如果出了问题,可以精确 revert 到某一个 commit,不会丢掉其他有价值的改动。
- Code Review 会更容易,因为每个 commit 的目的都很清晰。
- 心理上压力更小,每次只改一点点,但积累起来效果显著。
有一次我做一个方法提炼,一口气提炼了5个方法,合成一个 commit 提交了。结果后来发现其中一个方法的提炼引入了一个线程安全问题,但因为5个方法混在一起,很难区分是哪里出的问题,排查花了半天时间。从那之后,我把这条原则写进了团队的重构规范。
第六步:从最外层开始,由外向内
具体到代码改动,我通常遵循"由外向内"的原则:先改接口和入参,再改内部逻辑。
原因是:如果你先改内部逻辑,但接口还是旧的,那么外部调用方对这次重构是无感的,你等于是在一个黑盒里折腾。而且内部逻辑一旦改了,外部调用方也跟着要改,容易形成连锁改动,边界失控。
先改接口,可以强迫你先想清楚:这个组件对外应该暴露什么,调用方应该怎么用它。这个思考过程本身就是一种设计,往往能让内部实现思路更清晰。
第七步:处理"魔法数字"和硬编码
遗留代码里通常充满了魔法数字和硬编码字符串。比如:
if (status == 3) {
// 处理退款中状态
}
if (type.equals("VIP_GOLD")) {
discount = 0.85;
}这类代码要优先处理,因为它们是理解代码的最大障碍。但处理的时候要小心:不要假设自己知道这个数字的含义,要先找到原始的需求文档或者问当时写代码的人(如果还在的话),确认了含义再抽成常量或者枚举。
我碰到过一个 case,有个地方写了 timeout = 5000,我想当然认为是5秒,抽成了 TIMEOUT_MS = 5000,结果发现其实是5000毫秒没错,但这个5000是经过反复调优的,有一段注释说明了原因,被前任开发删掉了。幸好我在修改之前找到了那段注释的 git 历史,没有踩坑。
教训:处理魔法数字时,先查 git blame 和 git log,看看这个数字的来历。
第八步:消灭深层嵌套
遗留代码里最常见的一种形态是"箭头型代码",就是多层 if-else 嵌套,代码向右缩进越来越深,像一个箭头。
if (order != null) {
if (order.getStatus() == OrderStatus.PAID) {
if (order.getItems() != null && !order.getItems().isEmpty()) {
for (OrderItem item : order.getItems()) {
if (item.getProduct() != null) {
// 真正的业务逻辑在这里
}
}
}
}
}处理这种代码,我通常用"卫语句"(Guard Clause)方法:把所有前置条件判断提到最前面,不满足条件就直接 return,让正常的业务逻辑路径保持扁平。
改完之后:
if (order == null) return;
if (order.getStatus() != OrderStatus.PAID) return;
if (CollectionUtils.isEmpty(order.getItems())) return;
for (OrderItem item : order.getItems()) {
if (item.getProduct() == null) continue;
// 真正的业务逻辑在这里
}这个改法看起来很简单,但效果显著:同样的逻辑,阅读时的认知负担降低了很多。
第九步:处理超长方法
一个方法超过100行,基本上可以断定它做了不止一件事。
处理超长方法的核心思路是:识别出这个方法里的"段落"——通常可以通过空行、注释来识别——然后把每个段落提炼成一个私有方法,给它起一个能准确表达它干了什么的名字。
注意:提炼方法时,方法名要用动词短语,要说清楚"做什么",而不是"怎么做"。
比如 handleOrderPayment() 好过 processPaymentData(),因为前者说清楚了意图。
第十步:重构完收尾
重构不是改完代码就结束了。收尾工作同样重要:
- 更新文档:如果有 API 文档、设计文档,要同步更新,否则文档和代码的分离会成为下一个人的噩梦。
- 删除死代码:重构过程中会暴露出很多没人调用的老代码,确认无人调用之后,果断删除,不要留着"以防万一"。
- 跑完整测试:不只是你补的那些特征测试,要跑所有测试,包括集成测试和 E2E 测试。
- 做一次 Code Review:哪怕是单人项目,也要把自己的改动通读一遍,用"三天之后的自己能不能看懂"这个标准来检查。
- 灰度发布:如果条件允许,不要全量上线,先灰度到5%流量,观察一段时间再放量。
几个常见误区
误区一:重构之前要把所有代码读懂
不用,也不可能。遗留代码里有大量历史包袱,很多逻辑已经不再重要,花时间去读那些死代码是浪费。用"热点代码"方法定位到值得花时间的地方,专注在那里。
误区二:重构要一次性完成
不要这样想。重构是一个持续的过程,不是一个项目。好的工程实践是把重构融入日常开发:每次改某个地方的时候,顺手把周边的坏味道清理一下,这叫"童子军规则"——比你来的时候让营地更干净。
误区三:重构一定要获得老板和产品的许可
这个要分情况。大范围的重构(影响多个模块、需要专门的迭代周期)当然需要获得支持。但日常的小重构,比如改个方法名、提炼个函数,完全可以自己决定,不需要专门开会讨论。把重构变成一件需要"申请审批"的事,会让工程师丧失改善代码的动力。
误区四:好的架构可以一步到位
我见过太多团队,花了几个月时间做"大重构",设计了一套完美的新架构,结果一上线就发现现实和设计有很大的偏差,又花几个月来修修补补。架构是演进出来的,不是设计出来的。重构也是一样,要接受"这次只改一部分"的现实。
一个完整的案例:从4000行到300行
我花了大概4个月时间,把那个4000行的 OrderService 改到了大约1200行,核心逻辑被拆分到了8个专职类里。这不是一次性完成的,而是分了7个迭代,每个迭代专注处理一类坏味道。
最终的效果:
- 新功能开发速度提升了大约40%(这个是团队的主观感受,没有精确数字)
- 和订单模块相关的线上故障,从平均每个月2-3次降到了平均每季度1次以下
- 新同学 onboarding 时理解订单流程的时间,从原来的"大概两周"缩短到"一周以内"
这几个数字不是 KPI,而是我自己记录下来的观察。重构的价值有时候很难量化,但这些变化是真实发生的。
