老炮踩坑录 · F06 · 翻车现场系列
基于「企业融合评估系统」真实源码复盘
关键词:硬编码分支 · switch 派发 · 策略模式 · 开闭原则 · 魔法值
👋 欢迎阅读
🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:策略模式
引子
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 | 不抛异常、不记日志 = 新分支永远漏得悄无声息 |
| 同一组编号在多处派发 | 搜编号常量名,统计文件数 | 三五个文件以上 = 加一个值得上注册表 |
| 魔法值写进 XML | mapper XML 里搜字段名配数字 | 分类规则混进 SQL,改编号等于拆炸弹 |
| 目录已数据化、行为没数据化 | 看有没有对应的配置表,再看行为层有没有 switch | 半拉子开闭最有迷惑性,新人最容易踩 |
| 入参类型不统一 | 同一个编号字段,有的接口收 String 有的收 Integer | "1" 和 1 混着 switch,迟早有人传错 |
老炮点评
这颗债的利息长在"找不全"三个字上。硬编码分支最坑的时刻从来不是写代码当天,是半年后一个看似简单的需求把所有副本同时激活,而你根本不知道副本一共有几份。
复盘下来我给自己立的规矩也简单:默认分支必须响,编号清单必须唯一,真正多态的派发才配进注册表。剩下的,老老实实用表和配置表达。
工具会过时,框架会换代,"一组编号散落在十几个文件里、每个路口都安静地处理未知值" 这个味道,我在哪种技术栈里见了都得绕着走。
下期预告:《OBS 同名文件互相覆盖,客户拿错了别人的诊断评估报告》
上期讲的是用户数据串号——B 登录看到了 A 的企业信息。
下期讲一个更离谱的:文件也送错了人。
项目里用华为云 OBS 存文件,文件名直接用企业名+报告类型。结果两家企业重名,后上传的直接把先上传的覆盖了。A 客户下载诊断报告,打开一看——是 B 公司的。
下期讲这个坑是怎么发现的,以及文件名唯一性的三种解决方案。
如果你也在用 OBS 或类似对象存储,下期这篇得看。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言
你的每一次互动都是我继续更新的动力,我们下一篇见!🚀
我是老炮,18 年 Java 老兵,仍在一线。关注我,不错过每一篇真实案例,少踩坑。