老炮踩坑录 · F02 · 翻车现场系列
· 基于「企业融合评估系统」真实源码
· Controller类怎么就写到1600行了呢?回头看,每一步都有"合理"的理由,但合在一起就是一场灾难。
引子
晚上十一点,也就是项目上线前的最后一个夜晚。
我打开 EnterpriseRegistController.java,准备做最后的检查。鼠标滚轮往下滑……滑了很久。
100行……300行……700行……1600行。
我愣了一下:这个文件什么时候这么长了呀?是我写的吗?看看@Author xxx
更可怕的是——我居然看不懂自己写的代码了:
exportEnterpriseCloud()和exportExampleFactory()有什么区别?backExportEnterprise()里那个22个字段的properties数组是干什么的?- 那个365行的
exportPlustekDiagnosis()到底在干嘛?
我写了18年Java,自认为对代码整洁度还是有追求的,但这个1600行的Controller,是怎么从我手底下溜出来的?
这篇文章,就是我对它彻底复盘、改造、优化的记录。
体检报告:这个Controller到底装了什么?
先上一张"体检报告",看看这1600行都分布在哪里:
一个Controller干了6件事——注册CRUD、参数校验、数据转换、Excel导出、样式渲染、路由分发。
它不是Controller,它是个"全能选手"。
平台限制不能展示完整源码,需要私信或回复源码。
演化史:它是怎么一步步变胖的?
没有哪个Controller是一天写到1600行的。
我复盘了一下它"发胖"过程,发现每一步在当时看起来都是无比合理(自嗨)。
第1周:需求来了
新增:检查企业是否注册过的逻辑。
@PostMapping("isRegist")
public Result isRegist() {
JSONObject data = new JSONObject();
String companyId = SessionCacheUtils.getCompanyId(session);
data.put(SysContants.SESSION_COLUMN_NAME_COMPANY_ID, companyId);
String userId = SessionCacheUtils.getUserId(session);
data.put(SysContants.SESSION_COLUMN_NAME_USER_ID, userId);
String batchNo = SessionCacheUtils.getBatchNo(session);
data.put(SysContants.SESSION_COLUMN_NAME_BATCH_NO, batchNo);
return applyEnterpriseBaseinfoService.isRegist(data);
}
10 行,干干净净。接请求、取Session、调Service、返结果,教科书级别的Controller。
第2周:注册功能要加
加了 toRegist()、submitRegistInfo(),Controller 涨到 300 行。还好,都是正常的CRUD。
第3周:校验逻辑调整
接口参数校验逻辑要修改。
private void validateSubmitData(CompanyRegistVo companyRegistVo, MultipartFile[] file) {
StringBuilder sb = new StringBuilder();
EnterpriseBaseinfo enterpriseBaseinfo = companyRegistVo.getEnterpriseBaseinfo();
if (enterpriseBaseinfo == null) {
throw new SystemException(ResultEnum.FAIL_REGIST_DATA_FAIL);
}
int stepNo = enterpriseBaseinfo.getStepNo();
if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_FIRST) {
if (enterpriseBaseinfo.getEstablishTime() == null) {
sb.append("企业成立时间必填;");
}
if (StringUtils.isEmpty(enterpriseBaseinfo.getTotalAssets())) {
sb.append("企业总资产(万元)必填;");
}
// ... 还有18个类似的 if
}
}
190行校验逻辑,全写在Controller里。
当时的想法是:"校验嘛,放Controller里最直接,反正就几个if-else。"
← 第一个坑。
第4周:要导出Excel
加了 backExport(),将近40行,从Service查数据、填模板、导出,很正常嘛。
第5周:导出的字段要新加
backExportEnterprise() 从40行膨胀到了165行。为什么?因为要做的数据转换太多了:
// 业务类型转义
Integer businessType = (Integer) jsonObject.get("businessType");
if (businessType != null) {
if (businessType == 0) {
item.put("businessTypeStr", "xxx行业");
} else {
item.put("businessTypeStr", "YYY行业");
}
} else {
item.put("businessTypeStr", LINE);
}
// 年营业额转义
Integer enterpriseTurnover = (Integer) jsonObject.get("enterpriseTurnover");
if (enterpriseTurnover != null) {
if (enterpriseTurnover == 0) {
item.put("enterpriseTurnoverStr", "2000万以下");
} else if (enterpriseTurnover == 1) {
item.put("enterpriseTurnoverStr", "2000万至5000万");
} else if (enterpriseTurnover == 2) {
item.put("enterpriseTurnoverStr", "5000万至1个亿");
}
// ... 一直到数字6
}
22个字段,每个字段都要做这种 if-else 转义。一个方法就吃掉了165行。
当时的想法是:"先跑起来再说,反正就这一个导出方法。"
← 第二个坑。
第6周:数字化生产也要导出
exportPlustekDiagnosis() 一口气写了 365行。
动态表头并集算法、数据清洗、POI创建Workbook/Sheet/Row/Cell、合并单元格、冻结列、自适应列宽——全塞在一个方法里。
当时的想法是:"这个逻辑太特殊了,拆出去反而乱,就放这吧。"
← 第三个坑。
第7周:三种模型都要导出
然后来了个 backExportModel(),根据 paperid 分发到不同的导出方法:
if (paperid == 0) {
return exportPlustekDiagnosis(response);
} else if (paperid == 1) {
return exportInternetPlatform(response, applyInfoData);
} else if (paperid == 2) {
return exportExampleFactory(response, applyInfoData);
} else if (paperid == 3) {
return exportEnterpriseCloud(response, applyInfoData);
}
后面三个导出方法,基本上是复制粘贴 exportPlustekDiagnosis() 的模式,改改字段名。三个方法加起来330行,80%的代码是重复的。
当时的想法是:"复制粘贴改一改就行,反正结构一样,先跑起来。"
← 第四个坑,也是最致命的。
最终,1600行。回头一看,已经拆不动了。
没有哪个Controller是一天写到1600行的。它是每次"先这样吧,回头再改"这种思想堆出来的。
问题诊断:5个典型反模式
逐一拆解,看看这个Controller到底违反了哪些设计原则。
反模式1:Controller里写校验逻辑
(190行)
// 第一步
if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_FIRST) {
if (enterpriseBaseinfo.getEstablishTime() == null) {
sb.append("企业成立时间必填;");
}
if (StringUtils.isEmpty(enterpriseBaseinfo.getTotalAssets())) {
sb.append("企业总资产(万元)必填;");
}
if (StringUtils.isEmpty(enterpriseBaseinfo.getDebtratio())) {
sb.append("企业负债率必填;");
}
if (StringUtils.isEmpty(enterpriseBaseinfo.getTotalNumber())) {
sb.append("企业总人数必填;");
}
// ... 还有16个if
} // 第二步
else if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_SECOND) {
if (StringUtils.isEmpty(enterpriseContactinfo.getBelongcity())) {
sb.append("所属辖区(市、区、县)必选;");
}
// ... 还有10个if
}
问题在哪?
- 职责越界:校验是业务规则,不是Controller的事。Controller只管"接请求、调Service"。违反了单一职责原则(SRP)。
- 修改成本高:你看代码里被注释掉的格式校验(
isNumeric、checkFloat),说明校验规则一直在变。每次改校验,都要动Controller,都要重新发布。 - 不可复用:如果另一个入口也要校验同样的逻辑,只能复制。
应该怎么改呢?
用 JSR 303 注解 + @Valid,或者独立的 Validator 类:
// 方案一:注解校验
public class EnterpriseBaseinfo {
@NotNull(message = "企业成立时间必填")
private Date establishTime;
@NotBlank(message = "企业总资产必填")
private String totalAssets;
}
// 方案二:独立Validator
@Component
public class EnterpriseRegistValidator {
public void validateStep1(EnterpriseBaseinfo info) {
if (info.getEstablishTime() == null) {
throw new BizException("企业成立时间必填");
}
}
}
一行校验逻辑都不应该出现在Controller里。
反模式2:Controller里做数据转换
(165行 × 5处重复)
这段代码,我在Controller里写了5遍,一字不差:
// 出现在5个方法:
// backExportEnterprise()、exportPlustekDiagnosis()、exportInternetPlatform()
// 、exportExampleFactory()、exportEnterpriseCloud() 中
Integer businessType = (Integer) dataMap.get("businessType");
if (businessType != null) {
if (businessType == 0) {
item.put("businessTypeStr", "XXX行业");
} else if (businessType == 1) {
item.put("businessTypeStr", "YYY行业");
}
} else {
item.put("businessTypeStr", LINE);
}
同样的转义逻辑还有 "企业年营业额"(7个分支)、"企业制造类型"(3个分支)、"是否上市公司"(2个分支)、"近三年盈利"(2个分支)……
问题在哪?
- 复制粘贴不是复用。5处重复意味着:如果"业务类型"加了一个新值"3=混合行业",你要改5个地方,漏改一个就是Bug。
- Controller不应该知道枚举值的含义。"0==XXX行业" 是业务字典,不是Controller的认知范围。
应该怎么改?
// 定义枚举
public enum BusinessType {
DISCRETE(0, "XXX行业"),
PROCESS(1, "YYY行业");
private final int code;
private final String desc;
public static String getDesc(Integer code) {
if (code == null) return "-";
for (BusinessType t : values()) {
if (t.code == code) return t.desc;
}
return "-";
}
}
// Controller里一行搞定
item.put("businessTypeStr", BusinessType.getDesc((Integer) dataMap.get("businessType")));
5 处 × 10行 = 50行代码 → 5行代码,代码量降低10倍。而且以后改枚举就够了。
反模式3:Controller里操作POI
一共365行代码。
exportPlustekDiagnosis() 方法,365行,从创建Workbook到写出响应流,全在Controller里:
HSSFWorkbook wb = new HSSFWorkbook();
HSSFSheet sheet = wb.createSheet("精益数字化");
sheet.setColumnWidth(0, 3000);
sheet.setColumnWidth(1, 4000);
// ...
HSSFRow row0 = sheet.createRow(rowNum++);
row0.setHeight((short) 600);
// ...
HSSFCell tempCell = row0.createCell(i);
tempCell.setCellStyle(headerStyle);
tempCell.setCellValue(row_first[i]);
问题在哪?
Controller的职责是HTTP层的路由和参数绑定。创建Sheet、设置列宽、合并单元格——这些是"报表渲染引擎"的事。
你想想:如果明天要把Excel导出换成PDF导出,难道要改Controller吗?
应该怎么做?
// Controller只负责调度
@PostMapping("back/exportModel")
public void exportModel(@RequestParam Integer paperid, HttpServletResponse response) {
exportService.export(paperid, response);
}
// 导出逻辑在Service里
@Service
public class DiagnosisExportService {
public void export(Integer paperid, HttpServletResponse response) {
// 365行的POI逻辑搬到这里
}
}
反模式4:Controller里写样式
(137行)
这段代码让我印象深刻——因为它的"壮观"程度堪称复制粘贴的巅峰:
protected void setDataStyleAndHeight(Sheet sheet, Workbook wb) {
CellRangeAddress cellRangeAddress0 = new CellRangeAddress(0, 1, 0, 0);
CellRangeAddress cellRangeAddress1 = new CellRangeAddress(0, 1, 1, 1);
CellRangeAddress cellRangeAddress2 = new CellRangeAddress(0, 1, 2, 2);
CellRangeAddress cellRangeAddress3 = new CellRangeAddress(0, 1, 3, 3);
CellRangeAddress cellRangeAddress4 = new CellRangeAddress(0, 1, 4, 4);
CellRangeAddress cellRangeAddress5 = new CellRangeAddress(0, 1, 5, 5);
CellRangeAddress cellRangeAddress6 = new CellRangeAddress(0, 1, 6, 6);
CellRangeAddress cellRangeAddress7 = new CellRangeAddress(0, 1, 7, 7);
CellRangeAddress cellRangeAddress8 = new CellRangeAddress(0, 1, 8, 8);
sheet.addMergedRegion(cellRangeAddress0);
sheet.addMergedRegion(cellRangeAddress1);
// ... 9次
// 然后每个Region设置4个边框
RegionUtil.setBorderBottom(HSSFCellStyle.BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderTop(HSSFCellStyle.BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderLeft(HSSFCellStyle.BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderRight(HSSFCellStyle.BORDER_THIN, cellRangeAddress0, sheet, wb);
// ↑ 这4行重复了9次,只改了变量名 ↓
RegionUtil.setBorderBottom(HSSFCellStyle.BORDER_THIN, cellRangeAddress1, sheet, wb);
RegionUtil.setBorderTop(HSSFCellStyle.BORDER_THIN, cellRangeAddress1, sheet, wb);
RegionUtil.setBorderLeft(HSSFCellStyle.BORDER_THIN, cellRangeAddress1, sheet, wb);
RegionUtil.setBorderRight(HSSFCellStyle.BORDER_THIN, cellRangeAddress1, sheet, wb);
// ... 再来7遍
}
137行。同样的4行代码重复了9次,连变量名都只改了个数字后缀。
一个 for 循环就能解决的事呀,当时是怎么想的?
for (int i = 0; i <= 8; i++) {
CellRangeAddress region = new CellRangeAddress(0, 1, i, i);
sheet.addMergedRegion(region);
RegionUtil.setBorderBottom(HSSFCellStyle.BORDER_THIN, region, sheet, wb);
RegionUtil.setBorderTop(HSSFCellStyle.BORDER_THIN, region, sheet, wb);
RegionUtil.setBorderLeft(HSSFCellStyle.BORDER_THIN, region, sheet, wb);
RegionUtil.setBorderRight(HSSFCellStyle.BORDER_THIN, region, sheet, wb);
}
137行 → 7行。而且这段代码根本不该在Controller里,它应该在 ExcelStyleHelper 工具类中。
反模式5:路由式 if-else 分发
if (paperid == 0) {
return exportPlustekDiagnosis(response);
} else if (paperid == 1) {
return exportInternetPlatform(response, applyInfoData);
} else if (paperid == 2) {
return exportExampleFactory(response, applyInfoData);
} else if (paperid == 3) {
return exportEnterpriseCloud(response, applyInfoData);
}
每加一种导出类型,就要改Controller。加到第10种的时候,这个 if-else 就有10个分支了。
违反开闭原则——对扩展开放,对修改关闭。新增类型不应该修改已有代码。
应该怎么做?
// 策略接口
public interface ModelExporter {
int getPaperId();
void export(List<Map<String, Object>> data, HttpServletResponse response);
}
// 每种导出策略一个实现类
@Component
public class DiagnosisExporter implements ModelExporter {
public int getPaperId() { return 0; }
public void export(...) { /* 365行逻辑搬这 */ }
}
@Component
public class PlatformExporter implements ModelExporter {
public int getPaperId() { return 1; }
public void export(...) { /* ... */ }
}
// 工厂自动注册
@Service
public class ExporterFactory {
private Map<Integer, ModelExporter> exporters = new HashMap<>();
@Autowired
public ExporterFactory(List<ModelExporter> list) {
list.forEach(e -> exporters.put(e.getPaperId(), e));
}
public ModelExporter getExporter(int paperId) {
return exporters.get(paperId);
}
}
// Controller一行搞定
@PostMapping("back/exportModel")
public void exportModel(@RequestParam Integer paperid, HttpServletResponse response) {
exporterFactory.getExporter(paperid).export(applyInfoData, response);
}
以后新增导出类型,只要加一个实现类就行,Controller一行都不用改。
重构方案:如果重来,我会怎么做?
小憩听歌:
迪克牛仔-《有多少爱可以重来》
重构前 vs 重构后
改造背后的设计原则
这5个改造不是拍脑袋瞎说,每一个都有经典的设计理论支撑:
改造后Controller只剩200行左右:注册CRUD的6-7个接口方法,每个方法3-5行,只做"取参数→调Service→返结果"。
这才是Controller该有的样子。
很多人觉得"设计模式是面试用的",但你看,我们刚才做的5个改造——策略模式、工厂模式、枚举模式——全是真实项目里逼出来的。不是你想用设计模式,是代码烂到一定程度了,设计模式自然就成了唯一的解药。
老炮点评
写了18年Java,回过头来看这个1600行的Controller,有几句掏心窝的话:
1. "Controller超过300行,就该警惕了。"
Controller的唯一职责是"接请求、调Service、返响应"。它应该像一个饭店前台接待员——客人来了,问清楚找谁,带过去。接待员不应该自己下厨炒菜。
2. "复制粘贴不是复用,是埋雷。"
businessType 的 if-else 复制了5次。如果哪天业务类型加了新值,改一处忘四处,Bug就是这么来的。每一次复制粘贴,都是在给未来的自己挖坑。
3. "校验写在Controller里,改一次发一次版。"
业务规则变了——比如"总资产"从必填改成非必填——你愿意改Controller、走一遍测试、再发一版吗?如果校验在独立的Validator里,改一行配置就够了。
4. "Excel样式不是业务逻辑。"
合并单元格、设置边框、调整列宽——这些是"渲染",应该和"数据获取"职责分开。就像HTML和CSS分离一样,数据和样式也应该分离,这是单一设计原则。
5. "代码能跑不等于代码没问题。"
这个1600行的Controller在线上跑了两年没出Bug。但每次加新需求,我都要从头看一遍,生怕改坏了别的地方。
这不是"能跑",这是**"不敢动"**。
一个让你不敢改的Controller,就是最大的技术债。
写在最后:
这篇文章不是要批评谁——包括当时的我自己。
在项目初期,快速交付是第一优先级。"先跑起来"没有错。但如果跑起来之后不做重构、不做清理,那"先这样吧"就会变成"一直这样吧",这类似破窗效应。
技术债不可怕,可怕的是欠了债而不自知。
-共勉。
我是老炮,一个18年还奋斗在一线的Java老兵。这里记录真实项目里的踩坑、避坑、优化经验。关注我,少走弯路。
预告:下一篇——《18年老码农看这个项目:5个亮点能吹,6个坑能填》。咱们下期见。