AI 重构 1600 行 Controller 实测:方案看起来很专业,但我一条都没敢用

15 阅读17分钟

老炮踩坑录 · A01· AI 时代系列

· 基于「企业融合平台」真实源码

· 关键词:AI 重构 · 上下文缺失 · 重构的隐藏前提

上一篇,我花了三天把一个停更多年的老项目跑了起来。

跑起来之后,我做了件很多人会做的事:把最扎眼的那个文件喂给了 AI,让它给重构建议。

这个文件是 EnterpriseRegistController类,总共1600 行,是整个项目里最长的 Controller。打开它的第一眼,任何有代码洁癖的人都会皱眉——Excel 导出、参数校验、业务逻辑、样式设置全糊在一个文件里,同一段整数转中文的 if-else 复制粘贴了 5 遍。

看过上一篇文章《Controller 写了1600行,我打开一看全是业务逻辑,差点晕过去》的朋友可能还记得,那篇文章我拆解了这个 Controller 的 5 个反模式,给出了"如果重来我会怎么改"的重构方案——枚举提取、策略模式、Validation 框架,一个不落。

那篇写的是"设计原则"。今天这篇,是另一个视角。

我把同样的代码喂给 AI,它给了 6 条建议——和我在上一篇文章里给出的方案几乎一模一样。前 4 条,我一边看一边点头。第 5 条,我看完差点冒冷汗。第 6 条看完,我直接把对话窗口关了。

同一个文件,同样的方案,为什么上一篇文章说"应该这么改",这一篇却说"一条都没敢用"?

这就是本文要聊的事。

先看看这个文件长什么样

在逐条分析之前,我们得先知道这 1600 行到底都写了啥。我把它按功能拆成 5 块:

功能区域行数范围行数做了什么
注册校验 validateSubmitData167-357190分两步校验企业注册信息,20+ 个字段
企业信息导出 backExportEnterprise543-704160遍历企业数据,逐字段转中文,导出 Excel
诊断导出 exportPlustekDiagnosis737-1101365动态二级表头 + 合并单元格 + 冻结列
三个模型导出(Platform/Factory/Cloud)1110-1455345结构和上面类似,但维度不同
Excel 样式(表头/内容/合并)1463-1598135POI Cell Style 设置

剩下约 400 行是常规的 CRUD 功能接口(注册、查询、省市区联动等),没什么特别要讲的。

真正让 AI "兴奋"的,是中间那 1000 行导出逻辑和 200 行校验逻辑。

我把代码喂给 AI,它给了 6 条建议

我没有给 AI 任何业务背景,只给了它文件内容,然后问:"这段代码有什么问题?你怎么重构?"

三分钟后,6 条建议出来了。

建议一:把重复的整数转中文提取成枚举

enterpriseTurnover(企业年营业额)的转换逻辑,同一段 7 层 if-else 在文件里出现了 5 次:

if (enterpriseTurnover == 0) {
    item.put("enterpriseTurnoverStr", "2000万以下");
} else if (enterpriseTurnover == 1) {
    item.put("enterpriseTurnoverStr", "2000万至7000万");
} else if (enterpriseTurnover == 2) {
    item.put("enterpriseTurnoverStr", "7000万至1个亿");
}
// ... 一共 7 个分支

businessType 的 0/1 → "离散行业" / "流程行业" 也是 5 次重复。

AI 说:“提取成枚举类,一处定义、处处调用。”

看起来确实该提取。 5 次重复是代码腐化的经典信号。上一篇文章里,我就是用这个例子给出了枚举提取的方案,5处 × 10行 → 5行,代码量降低10倍。

但这次我不动。 是因为这 7 个字符串不是普通文本——它们是政府申报表上的固定选项。"2000万至7000万" 不是 "2000万-7000万",不是"2000~7000万",政府文件都是非常严谨的,必须和政府文件一字不差。

提取成枚举后,映射值从 5 个地方收拢到 1 个地方——改起来方便了,但改错的后果也从影响 1 个报表变成影响 5 个报表。

现在 if-else 虽然笨,但每个导出方法里的转换逻辑是独立的。一个方法改错了,其他 4 个不受影响。提取成公共方法后,一次修改同时影响 5 个场景,你反而失去了 "逐场景验证" 的安全网。

结论:有道理,"笨拙但隔离"比"优雅但牵一发动全身"更安全。不改动。

建议二:4 个导出方法用模板方法模式合并

exportInternetPlatform、exportExampleFactory、exportEnterpriseCloud 三个方法结构高度相似:

for (Map<String, Object> dataMap : applyInfoData) {
    item = new HashMap<>();
    item.put("enterpriseName", dataMap.get("enterpriseName"));
    // ... businessType 转换 ...
    // ... enterpriseTurnover 转换 ...
    // 分数
    Map<String, Object> data = xxxService.diagnoseData(rapplyid);
    // 按维度名称映射到字段
    dataExcel.add(item);
}
elecExcelExportUtil.exportByTpl(response, N, "某某报表");

AI 说:“抽取抽象基类,定义模板方法,子类覆盖差异部分。”

三个方法确实像三胞胎。 但仔细看,差异不在细枝末节,而在核心数据:

PlatformFactoryCloud
调用的 ServicereportAppServicereportCapabilityServicereportCloudService
维度字段数5 个4 个10 个
维度名称"应用服务能力"等"生产现场优化"等"开发设计优化"等
导出模板appsRescxResdeRes

如果强行抽取模板方法,抽象方法会占子类 80% 的代码量——"模板"本身只剩 20% 的公共部分。如果新增一个导出类型时,你还是要从头写核心逻辑,模板能复用的只有遍历和导出调用。更别说第四个方法 exportPlustekDiagnosis 是个彻底的异类——365 行动态二级表头算法,和其他三个完全没有公共部分。

结论:结构相似 ≠ 可以合并。差异部分是核心业务逻辑,不是参数配置。不采纳,不改。

建议三:Controller 里的 Excel 样式代码应该下沉到 Service

createHeadCellStyle、createContentCellStyle、setDataStyleAndHeight 三个方法共 135 行,全是 POI 的样式设置:

protected void setDataStyleAndHeight(Sheet sheet, Workbook wb) {
    CellRangeAddress cellRangeAddress0 = new CellRangeAddress(0, 1, 0, 0);
    CellRangeAddress cellRangeAddress1 = new CellRangeAddress(0, 1, 1, 1);
    // ... 9 个合并区域,每个设 4 条边框 = 36 行重复代码
}

Controller 确实不该管样式。 这条原则我没有异议。

但 "下沉到 Service" 只是搬家。这个项目的 Service 层本身就很薄——查数据库、组装 JSONObject、返回。把 135 行样式代码移进去,Service 就从"薄薄的数据层" 变成了 "既管数据又管样式的混合层"。

而且 setDataStyleAndHeight 里的 9 个 CellRangeAddress 对应的是诊断报表的前 9 列固定列,和动态表头算法紧密耦合。移到工具类里,它就失去了和表头算法的上下文关联,变成 "9 个不知道为什么这么写的魔法数字"。

正确的做法不是移动代码,而是报表模板化——把列名、样式、合并规则全定义在 Excel 模板文件里。但这是架构改造,不是"把方法从 Controller 搬到 Service"能解决的。

结论:原则没错,但解决方案不是移动代码,而是改变报表生成方式。在架构没变之前,样式代码留在 Controller 里至少和调用它的导出方法在一起,上下文完整。不采纳,不改。

建议四:paperid 的 if-else 用策略模式

if (paperid == 0) {
    return exportPlustekDiagnosis(response);
} else if (paperid == 1) {
    return exportInternetPlatform(response, data);
} else if (paperid == 2) {
    return exportExampleFactory(response, data);
} else if (paperid == 3) {
    return exportEnterpriseCloud(response, data);
}

AI 说:”用策略模式 + 工厂,每种模型一个策略类,通过 paperid 自动路由。“ 新增模型时只需加一个策略类,不用改 Controller。

完全遵循了 "开闭原则"——对扩展开放,对修改关闭。 的确是教科书级的正确。上一篇文章里,我给出了完整的策略模式 + 工厂实现代码,说"以后新增导出类型只要加一个实现类,Controller一行都不用改"。

方案本身没问题。但那是"如果重来"的理想状态。 回到现实——看看这 4 个分支各自在做什么:

  • paperid=0:365 行,动态二级表头,不传 applyInfoData
  • paperid=1:110 行,5 个维度,调 Service A
  • paperid=2:100 行,4 个维度,调 Service B
  • paperid=3:120 行,10 个维度,调 Service C

4 个策略实现的接口都不一样——第一个连入参都不同。你得定义一个足够宽泛的接口来容纳所有实现,结果就是接口里全是 Object 和 Map,类型安全比现在的 if-else 还差。

策略模式适用场景是 "同一种操作的不同算法"——比如不同的折扣计算方式。但这里的 4 个导出是完全不同的操作,公共部分只有"接收请求、返回 Excel"这个壳子。用策略模式,只是把 if-else 换成了工厂 + 接口 + 4 个实现类,代码量一行没少,还多了间接层。

结论:设计模式本身没错,但用错了场景。4 个分支是完全不同的操作,不是同种算法的变体。不采纳,不改。

到这里,4 条建议我们都分析了,每条都有不改的理由,但每条也确实 "看起来对" 。

然后我看到了第 5 条。

建议五:用 Validation 框架重写 200 行校验方法(这条差点毁掉生产)

AI 说:

validateSubmitData 有 200 行,全是 if-null-append 的重复模式。应该使用 JSR 380,在 VO 上加 @NotNull、@NotBlank 注解,一行代码替代 200 行。

先看现在的代码长什么样:

private void validateSubmitData(CompanyRegistVo companyRegistVo, MultipartFile[] file) {
    StringBuilder sb = new StringBuilder();
    EnterpriseBaseinfo baseinfo = companyRegistVo.getEnterpriseBaseinfo();
    int stepNo = baseinfo.getStepNo();
    if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_FIRST) { // 第一步
        if (baseinfo.getEstablishTime() == null) {
            sb.append("企业成立时间必填;");
        }
        if (StringUtils.isEmpty(baseinfo.getTotalAssets())) {
            sb.append("企业总资产(万元)必填;");
//        } else if (!ValidateUtils.isNumeric(baseinfo.getTotalAssets())) {
//            sb.append("企业总资产(万元)格式不正确;");
        }
        // ... 还有 15 个类似的 if ...
    } else if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_SECOND) { // 第二步
        // ... 12 个联系人信息的 if ...
    }
​
    if (sb.length() != 0) {
        throw new SystemException(sb.toString());
    }
}

AI 的重构方案很 "标准":

// VO 上加注解
public class EnterpriseBaseinfo {
    @NotNull(message = "企业成立时间必填")
    private Date establishTime;
​
    @NotBlank(message = "企业总资产(万元)必填")
    private String totalAssets;
    // ...
}
​
// Controller 里一行搞定
@PostMapping("submitRegistInfo")
public Result submitRegistInfo(@Valid @RequestBody CompanyRegistVo vo, BindingResult result) {
    if (result.hasErrors()) {
        return new Result().fail(result.getAllErrors().get(0).getDefaultMessage());
    }
    // ...
}

这条原则我也没异议——上一篇文章里,我也是这么推荐的:用 JSR 303 注解 + @Valid,"一行校验逻辑都不应该出现在 Controller 里"。

AI 给的方案,和我自己写的方案,几乎一模一样。

但这次,我对自己推荐的方案产生了怀疑。 因此我们把这段代码的业务逻辑拆开来看清楚——里面至少埋了 3 颗雷,上一篇文章里我没有看到。

第一颗雷:分步校验会丢。

同一个 VO,第一步(stepNo=1)只校验企业基本信息,第二步(stepNo=2)只校验联系人信息。JSR 303 虽然有分组校验(groups = {Step1.class}),但 AI 给的方案里完全没有提分组——它只是把所有 @NotNull 平铺在字段上。

如果按 AI 的方案改,用户提交第一步时,联系人的字段全是空的,@Valid 直接报错——第一步永远过不去。

第二颗雷:错误处理模式变了。

看现在的代码——它用 StringBuilder 收集所有错误,一次性返回:

sb.append("企业成立时间必填;");
sb.append("企业总资产(万元)必填;");
sb.append("企业负债率必填;");
// ... 所有错误攒齐 ...
throw new SystemException(sb.toString());  // 一次性抛给用户

用户提交一次,能看到所有填错的字段,一次改完。

而 @Valid + BindingResult 的标准做法是 result.getAllErrors().get(0)——只返回第一个错误。用户提交 → 看到第一个错 → 改 → 提交 → 看到第二个错 → 改 → 提交 → ……

20+ 个必填字段,用户可能要提交 20 次才能全部通过。 这是一个政府申报系统——用户可能是区县工信局的人,网络慢、浏览器老、耐心有限。你让他提交 20 次吗?要骂人的。

第三颗雷:注释掉的格式校验会被"好心"恢复。

代码里有大量被注释掉的格式校验:

if (StringUtils.isEmpty(baseinfo.getTotalAssets())) {
    sb.append("企业总资产(万元)必填;");
//} else if (!ValidateUtils.isNumeric(baseinfo.getTotalAssets())) {
//    sb.append("企业总资产(万元)格式不正确;");
}

这些注释说明原作者有意去掉了格式校验——可能是前端已经做了,后端不再重复;也可能是业务上放宽了限制。

但如果 AI "帮忙"重构,它看到"这里有校验逻辑被注释了",很可能在注解方案里"好心"地加上 @Pattern——把原作者故意关掉的校验又悄悄打开了。

结果:以前能提交的合法数据,突然被 "格式不正确" 挡回去了。用户一脸懵X,查了半天不知道哪里变了。

三颗雷任何一颗爆了,都是生产事故。

这条如果真用了:分步校验丢失 → 第一步永远过不去;错误一次只报一个 → 用户提交 20 次;格式校验被恢复 → 合法数据被拒。上线第一天就会有人投诉。

这就是那颗 "差点毁掉生产" 的炸弹。它之所以危险,不是因为建议本身错了,而是因为它太"标准"了——标准到你不会去细看它的实现,标准到你以为"框架都这么用"就不会有问题。


第 6 条看完,我直接把对话窗口关了

第 6 条建议是:用 DTO 替代 JSONObject 传参。

整个 Controller 大量使用 JSONObject 作为方法参数,丢失了类型安全。应该定义强类型的 Request/Response 类。

原则完全正确。 但在这个项目里,JSONObject 不是某一个接口要偷懒,而是贯穿全链路的架构决策:

Controller (JSONObject)
  → Service (JSONObject)
    → Mapper (JSONObject)
      → XML (#{companyId})

只改 Controller?到了 Service 还是得转回 JSONObject——多了一层转换,反而更乱。

要真正去掉 JSONObject,需要同时改 Service 接口(18 个方法)、Service 实现(18 个类)、Mapper 接口(18 个文件)、Mapper XML(25 个文件)。这是 80 个文件的联动改造。

而且项目里有大量动态字段:

data.put(SysContants.SESSION_COLUMN_NAME_COMPANY_ID, companyId);
data.put(SysContants.SESSION_COLUMN_NAME_BATCH_NO, batchNo);

这些 key 是运行时从常量拼出来的,DTO 在编译期不知道有哪些字段。

看到这条,我倒回去把前 5 条又重新看了一遍。

前 4 条,每条都有道理,每条也都有"不动"的理由——但这些理由,都是我在理解业务之后才能看出来的。AI 看不到。

第 5 条,AI 不知道有分步校验、不知道错误要一次性返回、不知道格式校验是故意关掉的——它只看到"200 行 if-else = 该用框架"。

第 6 条,AI 不知道 JSONObject 贯穿全链路、不知道改了 Controller 还得改 80 个文件——它只看到"JSONObject = 没类型安全"。

6 条建议,没有一条是"技术上错误的"。但有 1 条会直接炸,有 5 条会引入不同程度的风险,而这个项目没有测试、没有文档、原作者离职两年——任何风险都是不可接受的。

所以我一条都没敢采用。

不是 AI 不行,是它缺了最关键的东西

冷静下来想,AI 的 6 条建议,本质上是一套 "代码形式问题的标准答案" 。

AI 看到的AI 没看到的
5 次重复的 if-else映射值是政府申报标准,改错是合规事故
4 个结构相似的导出方法差异部分是核心业务逻辑,不是参数配置
Controller 里有 135 行样式代码Service 层太薄,下沉只是搬家
paperid 的 if-else 硬编码4 个分支是完全不同的操作
190 行校验方法分步校验 + 一次性报错 + 故意关掉的格式校验
JSONObject 满天飞全链路 JSONObject,改动量 = 重写半个项目

它擅长识别代码的"形式问题"——重复、过长、耦合。但它看不到代码背后的业务决策、架构约束和历史妥协。

前 4 条我之所以能看出"有道理但不能动",是因为我读了代码、理解了业务。第 5 条之所以差点翻车,是因为它的"标准感"太强了——Validation 框架是 2020 年以后 Java 项目的标配,太"应该用了",以至于你不去细看它的实现细节。

第 6 条是转折点——它让我意识到,AI 对上下文的理解是零。 不是它"理解得不够深",是"完全没有理解"。一个建议要你改 80 个文件,但它不知道这个项目一共才多少个文件。

遗留系统重构的"三问法则"

经过这次分析,我总结了一个判断框架(方法论)。面对老系统的任何重构建议,先问三个问题:

第一问:有测试兜底吗?

重构的前提是你能证明改完之后行为和改之前一样。证明的方式只有一种:跑测试。

这个项目没有单元测试、没有集成测试。任何改动都是"盲改"——编译通过,但你不知道某个导出报表的某个字段是不是悄悄变了值。

没有测试的重构,不是重构,是赌博。

第二问:你真的理解业务吗?

AI 看到 5 次重复的 if-else,不知道这 7 个字符串是政府申报表上的标准选项。它看到 190 行校验,不知道分步校验是业务需求、一次性报错是用户体验、注释掉的格式校验是产品经理要求去掉的。

代码是业务决策的产物。 不理解业务就改代码,是耍流氓,等于在不了解承重墙的情况下砸墙装修。

第三问:改错了代价是什么?

改动改对的收益改错的代价
提取枚举减少 50 行重复映射值改了 → 5 个报表全错 → 合规问题
模板方法减少 200 行重复合并逻辑搞混 → 某个报表数据错
样式下沉Controller 变短样式和表头算法脱钩 → 改一个忘一个
策略模式消除 if-else增加间接层 → 调试变难
Validation减少 150 行分步校验丢失 + 报错模式变了 → 生产事故
DTO 替代类型安全80 个文件联动 → 无法收场

当"改错的代价"远大于"改对的收益"时,不动就是最优解。

写在最后

回到题目。

上一篇文章(《Controller 写了1600行》)里,我给出了完整的重构方案:枚举提取、策略模式、Validation 框架、POI 下沉。改造后 Controller 从 1600 行缩到 200 行。那篇文章的结尾我写:"这才是 Controller 该有的样子。"

那篇文章没说错。但它有一个隐藏前提——你有测试、有文档、有团队维护。

今天这篇,是同一个文件、同样的方案,但视角完全不同:这个项目没有测试、没有文档、原作者离职。在这种条件下,那些"教科书级正确"的方案,每一条都可能是一颗定时炸弹。第 5 条(Validation)差点直接炸了。

两篇文章合在一起,才是完整的判断:先知道什么是好的代码,再知道好的代码不一定现在就能改。

前 4 条,我赌不起。第 5 条,差点赌了。第 6 条,让我看清了整副牌。

这就是资深工程师的价值所在:不是写出比 AI 更优雅的代码,而是知道哪些"优雅"现在碰不得。

给所有面对老系统重构的同行一句话:

代码的"丑"和"危险"是两回事。有些丑代码是定时炸弹,必须马上拆;有些丑代码是承重墙上的裂缝,看着难看,但你一动,整栋楼都可能塌。

分辨这两种"丑",是经验,是判断力,也是 AI 目前还替代不了的东西。


AI重构这个系列还没完。

下期预告:《AI 代码审查实测:给老项目挑 20 个坑,老炮只认 15 个》

下一篇换个玩法——我把整个项目扔给AI做全量代码审查。它能挑出多少真问题?和我这个18年老炮的审查结果比,命中率有多少?

下期见分晓。

如果这篇对你有用,请点个赞、转发给身边还在维护老项目的兄弟。你的支持,是我继续写下去的动力。

我是老炮,18年Java老兵,仍在一线。关注「Java老炮踩坑录」,少踩坑。