防御性代码:防御的位置比防御本身更重要
从 AI 生成的过度防御代码谈起,讨论防御性代码的必要性、正确分层,以及哪些错误应该被兜底、哪些必须直接暴露
防御性代码:防御的位置比防御本身更重要
AI 写的防御性代码不是太多,而是放错了地方——该厚的地方薄,该薄的地方厚。
引子
用 AI 生成代码的人大概都见过这种风格:到处判空、到处给默认值、到处包 try/catch,读环境变量时自动加 trim() 和 || default。它表面上很安全,实际是把本该暴露的问题悄悄藏起来。参考:AI 生成代码时总爱写防御性代码?,那篇文章说清了"问题是什么",这里补一刀:问题不在有没有防御,而在防御的分层。
防御的分层
| 层次 | 防御策略 | 说明 |
|---|---|---|
| 边界处 | 严格校验 | env、入参、外部 API 是信任边界,防御要厚 |
| 核心逻辑 | 契约清晰 | 内部函数之间少防御,靠类型与契约说话 |
| 可恢复失败 | 结构化表达 | 用 Result/错误码,让调用方显式处理 |
| 不可恢复错误 | fail fast | 启动时直接炸,带全错误信息 |
AI 恰好反着来:核心逻辑里堆防御,边界处偷懒。区别兜底和校验是关键:兜底是"让程序继续跑",校验是"让错误被发现"——衡量代码的标准不是"不崩溃",而是"崩溃是否发生在正确的位置、带着正确的信息"。
配置错误是典型:启动时悄悄 fallback,等于把一次性的、可立即修复的错误,变成长期的、难定位的错误。比如 DATABASE_URL 给了默认值,部署漏配时系统会默默连上错误的库,数据写错地方很久才被发现——这不是没兜住,是兜得太好。
案例:配置加载模块的规则设计
原文那个 prompt 的核心,是教 AI 一件事:哪些错误可以兜,哪些必须暴露。判断依据就一条——这个值错了后果是什么:连错库、泄露凭据,必须抛错;端口不同、超时稍长,可以给默认值。其中有两条最有味道:
- secret 不 trim:
JWT_SECRET等出现前后空白应直接报错。空白往往意味着"从错误的地方复制来的",异常格式本身就是错误信号,该暴露而不是修正。 - 数字必须显式 parse:隐式转换(如
+"8080")会悄悄吞掉格式错误,parse 后还要校验整数、正数和范围。
完整约束可复用:
- 所有环境变量只允许在 config 模块中读取,应用启动时完成解析和校验
- 必填配置缺失时直接抛错,禁止静默 fallback
- 低风险配置(PORT、REQUEST_TIMEOUT_MS)可以有默认值
- 高风险配置(DATABASE_URL、JWT_SECRET、API_KEY、SESSION_SECRET)禁止默认值
- 普通 URL 和枚举值可以 trim;secret 不 trim,出现前后空白直接报错
- 不要使用 process.env.X || default 这种写法
- 数字配置必须显式 parse,并校验整数、正数和范围
AI 生成代码审查清单
- catch:捕获后只 log 无恢复动作的,删掉。catch 的唯一合法理由是"有明确的恢复策略"。
- 默认值:会掩盖配置错误且业务无意义的,改成抛错。
- trim:secret、token 类不 trim,trim 会毁掉"异常格式"这个错误信号。
- 判空:核心逻辑里类型已保证非空的,删掉——那是噪音,不是防御。
要补的防御只有一种:边界处缺失的校验(启动期校验、数字范围校验),这两处值得在 prompt 里显式要求。
小结
一句话原则:边界厚、核心薄、错误分级、fail fast。 审查 AI 代码时先问"这个兜底在掩盖什么错误",再问"这个错误该不该暴露"。AI 生成代码最需要审查的地方,不是它有没有考虑异常,而是它有没有把真正应该暴露的问题悄悄吞掉。