硬编码 paperId==0/1/2/3 的代价:新增模型要改 7 个文件用策略模式重构了一遍

25 阅读8分钟

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

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

关键词:硬编码分支 · switch 派发 · 策略模式 · 开闭原则 · 魔法值

👋 欢迎阅读

doubao-03-eb81943ae6664c10ac3f5a141a202032.png 🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:策略模式

引子

2022项目复盘到申报评估模块的时候,我给自己出了道题:假如产品经理现在走进来,说要加第 5 个评估模型,我多久能干完?

我本来想,一个模型而已,加个表、加个菜单的事。

顺着代码走读了四十分钟,后背有点发凉。

这个项目的评估模型一共四个,用一个叫 paperid 的字段编号:

paperid模型
0科技数字化生产诊断
1工业互联网平台建设
2互联网标杆建设
3企业入云

编号本身规规矩矩地定义在常量类里(SysContants.java:143):

int UPLOAD_DIAGNOSIS_TYPE_SCCSD = 0;
int UPLOAD_DIAGNOSIS_TYPE_HLWPT = 1;
int UPLOAD_DIAGNOSIS_TYPE_WBGGC = 2;
int UPLOAD_DIAGNOSIS_TYPE_QYSY = 3;

看着挺像那么回事。问题在于这四个数字被拿去干什么了。

案发现场的四张脸

第一张脸:String 版 switch。 ReportController.java:89 统一查询试卷信息,入参是 String:

switch (paperid) {
    case "1":
        ReportMaturity reportMaturity = maturityService.getReportMaturityById(rapplyid);
        return result.success(reportMaturity);
    case "2":
        ReportCapability reportCapability = capabilityService.getReportCapabilityById(rapplyid);
        return result.success(reportCapability);
    case "3":
        ReportCloud reportCloud = cloudService.getReportCloudById(rapplyid);
        return result.success(reportCloud);
    default:
        break;
}
return result.fail("没有查询到试卷信息");

paperid=0 没有 case,静默落进 default。0 号模型的报告查不了,前端只会收到一句"没有查询到试卷信息",跟"系统里没这个模型" 意思是一样的。

第二张脸:裸 int 版 if 链。 EnterpriseRegistController.java:715 后台导出模型维度得分,同一个编号体系,这里连常量都懒得用:

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);
}
return null;

兜底返回 null。一个非法 paperid 请求导出,接口返回 200 加空响应体,前端的 Excel 解析器收到一坨空气。

第三张脸:白名单守卫。 FileController.java:656 上传诊断附件,四个常量组成白名单,删除接口(749 行)抄了一份一模一样的:

if (paperid != SysContants.UPLOAD_DIAGNOSIS_TYPE_SCCSD
        && paperid != SysContants.UPLOAD_DIAGNOSIS_TYPE_HLWPT
        && paperid != SysContants.UPLOAD_DIAGNOSIS_TYPE_WBGGC
        && paperid != SysContants.UPLOAD_DIAGNOSIS_TYPE_QYSY) {
    return result.fail("非法的 paperid");
}

第四张脸:藏在数据层的魔法值。 ApplyInfoServiceImpl.java:542:

List<ApplyPapers> papersList = paperMapper.getApplyPapperListByIds(0, 1, 2, 3);

Mapper XML 文件里也没放过,三处:

-- ApplyPapersMapper.xml:32
apply_papers where status = 1 and paperid != 0
-- DiagnosisInfoMapper.xml:63
where enterpriseId = #{enterpriseId} and paperid=0 and status = 1
-- SysBackDeclareListMapper.xml:39
and ati.paperid != 0

paperid != 0 翻成人话是"除了精益诊断之外"。分类规则被写成了 SQL 里的一个数字。

第 5 个模型进来之后

模拟一遍。假设新模型叫"数据管理成熟度评估",编号4,apply_papers 表里插一行,菜单挂上,企业能选了。然后:

企业选模型 4
  ├─ 查报告 → ReportController 落 default → "没有查询到试卷信息"
  ├─ 传附件 → FileController 白名单没有 4 → "非法的 paperid"
  ├─ 后台导出 → if 链走完 → return null
  ├─ 结果列表 → getApplyPapperListByIds(0,1,2,3) 查不到 → 列表里压根没有它
  └─ 发起申报 → startDeclarFlow 的 else → "未找到申报项"

五个路口,五种死法。三个安静地错,一个明确拒绝,一个直接消失。没有任何一个地方会在编译期提醒你 "这里还有一个分支没处理"。

我粗粗一数,七个文件起步。等复盘时逐个 grep 才发现,这套常量在 13 个 Java 文件里出现了 64 次,光 ApplyInfoServiceImpl 一个文件就占了 21 次,外加 3 处 XML。你没法保证自己找全了,只能一个接口一个接口点过去,点到哪个算哪个。

慌就慌在这里。

这债是怎么滚起来的

看代码分层的痕迹,0 号模型和 1/2/3 号明显不是一个时期的东西。0 是精益诊断,接口、Mapper、SQL 里到处单独照顾它;1/2/3 是第二批,连报告结构(Maturity/Capability/Cloud)都是三胞胎。模型数量的增长顺序,就这么被编号和分支化石记录了下来。

第一批代码里写死 0,第二批把 0/1/2/3 一起写死,还顺手补了常量。常量解决了可读性,没解决派发结构。更微妙的是,这个项目已经有 apply_papers 表了——paperid、模型名、试卷 ID 全在表里,列表类接口是数据驱动的,菜单和目录加一行数据就行;可一旦进入"这个模型具体干什么",立刻退回 switch。

目录开放了,行为关着。这就是半拉子状态最坑的地方:你以为加个模型就是插一行数据,插完才发现后面排着十几个分支等着你呢。

改法:分三层,别上来就策略模式

很多文章到这里就甩策略模式了。我先把丑话说前面:paperid 在这个项目里的分支,根本不在一个维度上。查报告是一个维度,导出 Excel 表头是一个维度,前置条件校验是一个维度,跳转链接拼接又是一个维度。一个策略接口吞不掉所有这些,硬吞只会造出一个上帝接口。

第一层:先让默认分支变响。 这是十分钟能干完、收益最大的一步。所有 switch 和 if 链的 default,禁止静默 break 和返回 null:

default:
    throw new SystemException(ResultEnum.ATI_NOT_FOUND_APPLYITEM);

再把支持的编号收成一个集中校验点,替换 FileController 里抄了两遍的白名单:

public final class AssessmentTypes {
    private static final Set<Integer> SUPPORTED = Collections.unmodifiableSet(
            new HashSet<>(Arrays.asList(
                    UPLOAD_DIAGNOSIS_TYPE_SCCSD,
                    UPLOAD_DIAGNOSIS_TYPE_HLWPT,
                    UPLOAD_DIAGNOSIS_TYPE_WBGGC,
                    UPLOAD_DIAGNOSIS_TYPE_QYSY)));
​
    private AssessmentTypes() {}
    public static void checkSupported(int paperid) {
        if (!SUPPORTED.contains(paperid)) {
            throw new SystemException(ResultEnum.ATI_NOT_FOUND_APPLYITEM);
        }
    }
}

加模型时漏改一个路口,立刻炸在明面上,还给了你一份明确的待改清单。静默错误转成显式错误,排查成本直接降一个量级。

第二层:真正多态的派发点,用 Spring 注册表。 查报告、导出这类 "同一动作、不同实现"的,才轮到策略模式。定义接口,每个模型一个实现:

public interface AssessmentReportStrategy {
    int paperid();
    Object getReport(String rapplyid);
}
​
@Component
public class MaturityReportStrategy implements AssessmentReportStrategy {
​
    @Autowired
    private ReportMaturityService maturityService;
​
    @Override
    public int paperid() {
        return SysContants.UPLOAD_DIAGNOSIS_TYPE_GYHLWPTJS;
    }
​
    @Override
    public Object getReport(String rapplyid) {
        return maturityService.getReportMaturityById(rapplyid);
    }
}

Controller 注入全部实现,自己建索引:

@RestController
public class ReportController {
​
    private final Map<Integer, AssessmentReportStrategy> registry;
​
    public ReportController(List<AssessmentReportStrategy> strategies) {
        this.registry = strategies.stream()
                .collect(Collectors.toMap(
                        AssessmentReportStrategy::paperid,
                        Function.identity()));
    }
​
    @GetMapping("/searchPaperInfo")
    public Result searchPaperInfo(String paperid, String rapplyid) {
        AssessmentReportStrategy strategy = registry.get(Integer.valueOf(paperid));
        if (strategy == null) {
            throw new SystemException(ResultEnum.ATI_NOT_FOUND_APPLYITEM);
        }
        return new Result().success(strategy.getReport(rapplyid));
    }
}

新模型进来,加一个 @Component,Spring 自动收编,Controller 零改动。0 号模型没有报告策略,天然查不到,错误信息明确,不用再靠一个没人维护的 default 兜着。

第三层:剩下的分支,该归数据归数据,该归配置归配置。 paperid != 0 这种 SQL 里的分类规则,背后是"模型分组"这个概念——在 apply_papers 加个分组字段,比在每条 SQL 里写排除条件稳。跳转链接的差异本来就配置在 yml 里,继续用配置表达。前置条件校验是业务规则,留在 Service,但走第一层的集中校验,别再复制常量白名单。

说句可能挨骂的话:switch 本身没那么罪大恶极。 三年四个模型,变更频率极低,一个 switch 加一个会抛异常的 default,可读性和成本都合适。真正的债是同一份编号逻辑复制了十几个副本、还配上一群哑巴默认分支。

判断要不要上策略模式,看的是"分支会不会继续长" 和 "分支有没有分散复制",而不是教科书第几页写了开闭原则。

自查清单

检查项怎么搜危险信号
switch/if 链的 default 静默处理搜 switch,看 default 是否 break 或返回 null不抛异常、不记日志 = 新分支永远漏得悄无声息
同一组编号在多处派发搜编号常量名,统计文件数三五个文件以上 = 加一个值得上注册表
魔法值写进 XMLmapper XML 里搜字段名配数字分类规则混进 SQL,改编号等于拆炸弹
目录已数据化、行为没数据化看有没有对应的配置表,再看行为层有没有 switch半拉子开闭最有迷惑性,新人最容易踩
入参类型不统一同一个编号字段,有的接口收 String 有的收 Integer"1" 和 1 混着 switch,迟早有人传错

老炮点评

这颗债的利息长在"找不全"三个字上。硬编码分支最坑的时刻从来不是写代码当天,是半年后一个看似简单的需求把所有副本同时激活,而你根本不知道副本一共有几份。

复盘下来我给自己立的规矩也简单:默认分支必须响,编号清单必须唯一,真正多态的派发才配进注册表。剩下的,老老实实用表和配置表达。

工具会过时,框架会换代,"一组编号散落在十几个文件里、每个路口都安静地处理未知值" 这个味道,我在哪种技术栈里见了都得绕着走。


下期预告:《OBS 同名文件互相覆盖,客户拿错了别人的诊断评估报告》

上期讲的是用户数据串号——B 登录看到了 A 的企业信息。

下期讲一个更离谱的:文件也送错了人。

项目里用华为云 OBS 存文件,文件名直接用企业名+报告类型。结果两家企业重名,后上传的直接把先上传的覆盖了。A 客户下载诊断报告,打开一看——是 B 公司的。

下期讲这个坑是怎么发现的,以及文件名唯一性的三种解决方案。

如果你也在用 OBS 或类似对象存储,下期这篇得看。

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

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

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注我,不错过每一篇真实案例,少踩坑。