老项目重构实战:没有测试、没有停机窗口、没有专项人力,我是怎么分四期做手术的

1 阅读11分钟

老炮踩坑录 · V02 · 老炮视野系列

基于「企业融合评估平台」真实源码,给出一份带约束、带顺序、带放弃清单的重构方案

关键词:重构 ≠ 重写 · 安全网先行 · 导出剥离 · 三兄弟合并 · 不动清单

👋 欢迎阅读 v02.jpg 🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:项目重构

引子

在我之前写的文章底下经常收到一类私信:老炮,别光吐槽,要是让你回去重构这个项目,你会怎么动手呀?

我一般会先反问他三个问题:给多少人?给多久?系统能不能停?

很多人听到这三问就不说话了。他们脑子里的"重构",是一个理想化的周末:拉个新分支,推倒重写,DDD 分层,微服务,新技术栈,周一上线。

这种方案我写不出来,写出来也没人敢批。

这篇我换个写法:假设我真的回去了,手里是 224 个 Java 文件、3.5 万行代码、一个不能停的在跑系统、三两个能挤出来的人力,我按什么顺序下刀,以及哪些东西我非常清楚是不能碰的。

先把"重构"两个字说清楚

我用的是老 Fowler 的定义:行为保持,小步推进,每一步做完都能随时停下来。

推倒重写在我这儿叫重写,归另一个项目,走另一套预算,承担另一份风险。这篇只谈在原系统上做手术。

手术有三个绕不开的约束:没有测试兜底、没有停机窗口、没有专项人力。后面的所有方案都在这三个约束前提下产生的,所以你看不到"先把代码重写一遍"这种建议。

诊断:病灶地图

先看我在仓库里实际数出来的数字(2026-10 跑的,命令都在,欢迎复核):

224 个 Java 文件,35,256 行
800 行以上的文件 9 个:
  1796  ApplyInfoServiceImpl       34 个方法
  1600  EnterpriseRegistController 25 个方法
  1488  ApplyElecInfoServiceImpl
  1048  EnterpriseRegistServiceImpl
   903  ApplyInfoDeptServiceImpl
   871  FileController             21 个方法
   866  HuaweiyunFileServiceImpl
   866  ApplyTypeInfoServiceImpl
   812  HttpUtil

光看行数会误判,我把几个大文件单独挨个翻开看他们的功能、职责。

EnterpriseRegistController 名义上管企业注册,里面塞着四个报表导出方法。exportPlustekDiagnosis 从 737 行铺到 1109 行,373 行,一个方法干了数据清洗、表头并集、样式渲染、填充输出四件事。加上另外三个导出方法,这个 Controller 里近一半篇幅跟"注册"没有关系。

ApplyInfoServiceImpl 的 34 个方法里混着问卷加载、推荐列表、区县列表、Excel 打印、云端文件上传、评估结果修改——至少六件事。区县列表还存着三个孪生方法:countyApplyInfoList、countyApplyInfoListForQuxianOnly、countyApplyInfoListZhuanjia,从 1276 行排到 1716 行,骨架几乎一样,差在查询条件和少数字段。

FileController 里能看到 uploadDiagnosis 和 uploadDiagnosis1、deleteDiagnosis 和 deleteDiagnosis1 这种编号并存的方法,两段带进度监听的下载代码各自内联了一个匿名类,复制粘贴的痕迹明显。

工具层同样热闹:四个 Excel 工具类(ExcelUtil、ExcelReaderUtils、ExportExcelUtils、ElecExcelExportUtil),三个华为云 OBS 相关的类散在两个包下。

有意思的是,重构的头前人已经起过:仓库里有个 AbstractFileService,模板方法写得像模像样,但只有 HwFileServiceImpl 一个子类接了过去,FileController 该多大还是多大。典型的重构进行到一半,人又去忙别的了。

我们把病灶摸清楚了,下面是手术方案,分期来。

第 0 期:先织安全网,再动刀

直接在 373 行的方法上动结构,跟蒙眼拆炸弹差不多。第一步得给这些方法兜住底。

我的做法是给导出接口写特征测试,不评判业务正确性,只做一件事:把旧代码的输出钉在那里。

// 同样的输入,新旧代码产出的字节必须一致
@Test
public void exportPlustekDiagnosis_sameBytesAsBefore() throws Exception {
    List<Map<String, Object>> queryData = fixture("plustek-3-enterprises.json");
​
    byte[] legacy = runLegacyExport(queryData);     // 旧方法,原样保留
    byte[] refactored = runNewExporter(queryData);  // 新结构,同一批数据
​
    assertArrayEquals(legacy, refactored);
}

项目里本来就有一个 ExportPlustekDiagnosisTest,JUnit4 的,测试数据构造得挺用心,能直接当底座。这一期不碰生产代码,只产出一批输出快照:四份导出各喂两到三批数据(单企业、多企业维度不齐、含异常返回),把生成的文件存成基准文件。

后面每动一刀,跑一遍字节比对,一致才算过。办法不高级,在没有覆盖率的项目里,这是唯一能让我晚上睡着的东西。

第 1 期:把导出从 Controller 里剜出来

安全网就位,动第一刀:四个导出方法整体外迁。

先定一个极简接口,四个模型各一个实现:

public interface ReportExporter {
    Integer paperid();
    void export(List<Map<String, Object>> data, HttpServletResponse response);
}
​
@Component
class PlustekDiagnosisExporter implements ReportExporter {
    public Integer paperid() { return 0; }
    public void export(List<Map<String, Object>> data, HttpServletResponse response) {
        List<Map<String, Object>> cleaned = new DiagnosisDataCleaner().clean(data);
        DiagnosisHeader header = new DiagnosisHeaderBuilder().build(cleaned);
        new DiagnosisSheetRenderer(header, cleaned).write(response);
    }
}

那个 373 行的方法顺势拆成三段:清洗、表头构建、渲染,每段一个类,每类只管一件事。

Controller 里原来按 paperid 分支的入口(backExportModel,709 行那个),换成一张注册表:

@Autowired
private List<ReportExporter> exporters; // Spring 自动收集所有实现
​
@PostMapping("backExportModel")
public Result backExportModel(HttpServletResponse response, @RequestParam Integer paperid) {
    exporters.stream()
        .filter(e -> e.paperid().equals(paperid))
        .findFirst()
        .orElseThrow(() -> new SystemException("不支持的模型: " + paperid))
        .export(applyInfoService.selectApplyInfoExport(paperid), response);
    return new Result().success();
}

ReportController.searchPaperInfo 里那个 case "1"/"2"/"3" 的 switch,用同一套注册表思路收编。以后产品说加第五个模型,写个新实现丢进 Spring 容器,老代码一行不用动。

这一刀做完,EnterpriseRegistController 从 1600 行掉到 700 行上下,回到"企业注册"该有的体量。

第 2 期:合并区县列表三兄弟

三兄弟的差异我逐行比过,集中在查询条件(区县限定、专家视角)和返回字段的增减。这种差异,不配各占一个 150 行的方法。

抽一个查询参数对象,差异收进显式开关:

public class CountyListQuery {
    private Integer pageNum;
    private Integer pageSize;
    private boolean quxianOnly;  // 只看本区县
    private boolean expertView;  // 专家视角,字段集不同
    // 其余查询条件...
}

合成一个方法,公共骨架只写一遍,差异点用两三个私有小方法隔出来。Mapper XML 里对应的三段 SQL 同样处理:公共部分抽 <sql> 片段,where 条件按开关拼。

这一期比第一期便宜,风险却更碎。三个方法在生产环境各自有页面在调,返回字段差一个,前端表格就空一列。顺序上我会让新方法和旧方法并存,先切一个页面观察一周,再切下一个,三个页面都切完,再删旧方法。

第 3 期:收口基础设施

结构松快之后,处理那些不致命但天天恶心人的东西。

文件上传收编。 AbstractFileService 已经在仓库里躺着,把 FileController 里 uploadDiagnosis、companyUpload 这些重复的上传方法逐个接到模板方法上,编号带 1 的孪生方法确认没有调用后直接删。两段重复的进度监听下载,抽一个 DownloadProgressListener,内联匿名类消失。

异常处理统一。 117 处 printStackTrace 是最扎眼的数字,光 HttpUtil 一家就占 27 处。上一个 @RestControllerAdvice 全局兜底,工具类里 catch 到异常,要么有正当理由吞掉并记录,要么包装成业务异常往上抛,堆栈交给日志框架,不允许直接拍控制台。这件事没法一步到位,按调用链分批改,改一批回归一批。

事务回滚的坑先填。 @Transactional 事务失效排查写过,catch 里吞异常加 return,@Transactional 形同虚设。这属于数据正确性问题,真要排期,我会把它提到第 1 期之前,跟着第一次导出回归一起验证。

这份不动清单,和动刀清单一样重要

成熟的重构方案,一半内容是"不做什么"。我明确不碰这几样:

鉴权体系不动。 AuthAspect 加三个注解虽然长得笨——切点大、每个请求查库、缓存容量写死 50——但它在生产环境跑了几年,权限正确性有实战背书。鉴权代码的每一次改动都踩在安全红线上,为"优雅"重写它,收益和风险完全不成比例。真要优化查库频率,加个一分钟短缓存足矣,局部小改,不碰骨架。

Session 不换 JWT。 这是项目级别的决策,牵涉客户端存储、失效策略、三套入口的改造,混在重构里做等于给自己挖坑。

war 和外置 Tomcat 不动。 之前的文章推演过,换部署形态要运维一起动,那是升级专项的预算,重构期不沾。

数据库表结构不动。 重构里改 schema,数据迁移和回滚预案会吃掉所有节奏。结构问题记账,单独立项。

包结构不做大规模搬家。 按业务域重组包名看着很爽,git 历史全断、merge 冲突遍地,系统行为却没有任何改善。这种为整洁交的学费,我不交。

落地:这笔预算怎么要

方案再好,没人批就是废纸。我工作十多年,没见过哪个老板批重构专项批得简单痛快过,但每个迭代里都塞有重构的需求。

我的经验是把手术拆碎了混进去:这个迭代做导出需求,顺手外迁一个 Exporter;下个迭代修区县列表的 bug,顺手合并一个孪生方法。每刀都小,每刀都有业务需求当掩护,每刀合上去都可回滚。特征测试和基准文件提前一个迭代备好,谁也看不出你在"搞重构"。

完成的标准得定好:旧方法删干净、调用方切完、基准测试全绿、对应页面回归过。四条缺一条,这期手术不算完——仓库里那个只有一个子类的 AbstractFileService,就是"算了完"三个字的下场。

决策清单

判断项怎么看我的取舍
先动哪里看调用频率和出错代价,不看丑陋程度数据正确性(事务)先于结构
没有测试能不能改能,先补特征测试再动结构安全网先行,没有例外
大方法怎么拆按职责阶段拆,拆完行为字节级一致清洗 / 构建 / 渲染分段
孪生方法怎么并差异参数化,先并存再逐个切流量页面切完再删旧方法
什么不碰安全红线、跨团队成本、无行为收益鉴权、认证、部署、schema、包搬家
怎么落地拆碎混进业务迭代,不立专项小步、可停、可回滚

老炮点评

我对重构这件事的看法,越做越保守。

年轻时候觉得重构是审美活动,看见长方法手就痒,恨不得一夜之间把代码改成教科书。现在我知道,重构是经济活动:每一刀都有成本、有风险、有收益,方案的高下取决于资源约束下的排序和取舍。一个能落地的笨方案,强过一百张漂亮的目标架构图。

这个项目真正的教训也在这儿:大量时间花在纠正当年图省事留下的东西。AbstractFileService 写到一半烂尾、孪生方法不断累加、导出逻辑在 Controller 里生根——每一笔单独看都情有可原,合在一起就是 3.5 万行需要做手术的代码。

重构能力决定一个系统能活多久。而把小重构坚持做在日常的团队,永远不需要一篇这样的文章。


下期预告:《代码腐化的五个信号:我从这个项目里看到的》

printStackTrace 117 处、注释掉的代码块、编号孪生方法、没人敢删的基建——代码腐化是可以量化的。下期接着拿这个项目当标本,数一数五个信号各出现多少次。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。