Controller 写了1600行,我打开一看全是业务逻辑,差点晕过去

33 阅读11分钟

老炮踩坑录 · F02 · 翻车现场系列

· 基于「企业融合评估系统」真实源码

· Controller类怎么就写到1600行了呢?回头看,每一步都有"合理"的理由,但合在一起就是一场灾难。

引子

晚上十一点,也就是项目上线前的最后一个夜晚。

我打开 EnterpriseRegistController.java,准备做最后的检查。鼠标滚轮往下滑……滑了很久。

100行……300行……700行……1600行。

我愣了一下:这个文件什么时候这么长了呀?是我写的吗?看看@Author xxx

更可怕的是——我居然看不懂自己写的代码了:

  • exportEnterpriseCloud() 和 exportExampleFactory() 有什么区别?
  • backExportEnterprise() 里那个22个字段的 properties 数组是干什么的?
  • 那个365行的 exportPlustekDiagnosis() 到底在干嘛?

我写了18年Java,自认为对代码整洁度还是有追求的,但这个1600行的Controller,是怎么从我手底下溜出来的?

这篇文章,就是我对它彻底复盘、改造、优化的记录。

体检报告:这个Controller到底装了什么?

​ 先上一张"体检报告",看看这1600行都分布在哪里:

image-20260908175301292.png

​ 一个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
}

问题在哪?

  1. 职责越界:校验是业务规则,不是Controller的事。Controller只管"接请求、调Service"。违反了单一职责原则(SRP)。
  2. 修改成本高:你看代码里被注释掉的格式校验(isNumeric、checkFloat),说明校验规则一直在变。每次改校验,都要动Controller,都要重新发布。
  3. 不可复用:如果另一个入口也要校验同样的逻辑,只能复制。

应该怎么改呢?

用 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个分支)……

问题在哪?

  1. 复制粘贴不是复用。5处重复意味着:如果"业务类型"加了一个新值"3=混合行业",你要改5个地方,漏改一个就是Bug。
  2. 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 重构后

image-20260908174011054.png

改造背后的设计原则

这5个改造不是拍脑袋瞎说,每一个都有经典的设计理论支撑:

image-20260909092542376.png

改造后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个坑能填》。咱们下期见。