老炮踩坑录 · V02 · 老炮视野系列
基于「企业融合评估平台」真实源码,给出一份带约束、带顺序、带放弃清单的重构方案
关键词:重构 ≠ 重写 · 安全网先行 · 导出剥离 · 三兄弟合并 · 不动清单
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:项目重构
引子
在我之前写的文章底下经常收到一类私信:老炮,别光吐槽,要是让你回去重构这个项目,你会怎么动手呀?
我一般会先反问他三个问题:给多少人?给多久?系统能不能停?
很多人听到这三问就不说话了。他们脑子里的"重构",是一个理想化的周末:拉个新分支,推倒重写,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老炮踩坑录」,不错过每一篇真实案例,少踩坑。