一个 amount 字段,网关查一次、控制器查一次、Service 查一次、库上还有 CHECK。四层都查了,规则却不一致:网关要求 > 0,控制器要求 >= 0,Service 允许负数表示退款。上线后出现"接口 200 但业务报错"和"接口 400 但数据已写入"两种结果,没人说得清哪一层是权威。
校验不是"多一层更安全"。每条规则都有唯一归属层,其余层要么是防御性冗余(可接受成本),要么是规则漂移(不可接受负债)。
1. 四种职责与各自的归属层
"校验"拆开是四类性质不同的判断,归属由信息在哪决定,不由习惯决定。
| 职责 | 判断依据 | 归属层 | 越界后果 |
|---|---|---|---|
| 格式 | 只看值本身(非空、长度、正则、枚举) | 接入层/控制器 | 放领域层:领域被 HTTP 语义污染 |
| 业务规则 | 需当前状态或外部数据(余额、库存、状态机) | 领域层 | 放控制器:旁路入口(任务、MQ)照样写脏 |
| 不变量 | 任何时刻必须成立(精度、区间闭合) | 领域模型内部 | 放外部:并发下必然被绕过 |
| 授权 | 谁能否做这件事(角色、租户、属主) | 接入层判定 + 领域复核 | 只在前端或只在网关:越权 |
判据:规则所需的信息在哪层可得,就归哪层。 控制器拿不到余额,放那里校验只能再查一次库,等于把领域逻辑搬进控制器,还在下游留了个可绕过的检查点。
授权特殊:必须出现两次。接入层判"这个身份能否访问这个路由",领域层判"能否操作这个具体资源"。只有前者是经典越权成因。
2. 重复校验:防御性冗余与规则漂移的分界
同一条规则出现两次,可能是好事也可能是事故,区别在两次的意图能否声明。
| 形态 | 例子 | 判定 |
|---|---|---|
| 防御性冗余 | 前端拦空值 + 后端再拦 | 可接受:目的不同(体验 vs 底线) |
| 纵深防御 | 控制器拦格式 + 库上 NOT NULL |
可接受:兜并发与旁路写入 |
| 规则漂移 | 三层对同一字段范围判断不同 | 事故:无权威源,行为随入口变化 |
| 影子规则 | 领域有校验,控制器又抄一遍且更严 | 事故:更严的那层成事实标准 |
漂移的成因不是疏忽,而是规则没有单一事实源。同一条约束在三个地方各写一遍,改动只落一处,剩下两处就成了"历史遗留的额外限制",而且没人敢删。
可接受:一条规则 + 明确兜底层
格式 → 控制器(权威)→ 数据库约束(并发兜底)
业务 → 领域(权威)→ 无兜底,靠入口收敛
不可接受:多层各自定义阈值
网关 min=1 / 控制器 min=0 / 领域 min=-100(退款)
判据:能否一句话说出"权威在哪一层"。说不出就是漂移。
3. 在哪一层拒绝,错误码怎么设计
拒绝位置决定两件事:调用方看到什么,事务回滚到哪。
| 拒绝点 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 网关 | 不占应用资源 | 无业务上下文,表达不了语义 | 体积、频率、明显畸形 |
| 控制器 | 语义清晰,可自动生成文档 | 只覆盖 HTTP 入口 | 格式与形状 |
| 领域层 | 唯一能保证绕不过去 | 需把异常翻译成对外错误 | 业务规则与不变量 |
| 持久层 | 并发下最后一道 | 数据库方言错误,不可直接外露 | 唯一、外键、检查约束 |
错误码三个决定:分字段还是一次性(表单接口分字段,命令接口一次性,混用会让前端写两套渲染);码稳定、文案可变(码是契约,文案走国际化,把中文文案当码用,改一次文案就是一次破坏性变更);校验失败与业务失败分段(否则监控分不清"客户端乱传"与"业务在拒人")。
领域失败必须显式翻译,否则以 500 外泄:
领域抛 AmountBelowMinimum(min=1, actual=0)
→ 接入层映射 422 + {code:"AMOUNT_TOO_SMALL", field:"amount"}
→ 未映射异常一律 500 并记 error(不要静默转 400)
4. 注解式校验的适用边界
@NotNull、@Size、@Pattern、@Min 便宜、可生成文档、可被框架统一处理,但只能表达单字段、无外部依赖、纯函数的规则。
// 跨字段只能靠类级约束,错误定位差
@AssertTrue(message = "end must be after start")
boolean isRangeValid() { return end.isAfter(start); }
// 依赖数据库(手机号是否已注册)、依赖身份(是否本租户资源)→ 注解表达不了
三条边界:类级约束的错误挂在类上,前端无法高亮字段;注解里的复杂正则不可单测,规则复杂到要测就写成显式方法;注解校验发生在绑定之后、领域之前,拦不住"绑定成功但语义非法"(枚举合法但当前状态不可用)。
5. 领域不变量:构造期还是操作期
| 位置 | 做法 | 优点 | 失效边界 |
|---|---|---|---|
| 构造期 | 构造/工厂内校验,失败抛异常 | 类型即证明,下游无需再查 | 表达不了跨对象约束(总额=明细之和) |
| 操作期 | 每个变更方法内校验 | 能表达状态迁移约束 | 漏一个方法就漏一个口子 |
| 双处 | 两处都查,抽私有方法 | 最稳 | 代码重复 |
结论:能进构造期的,一定进构造期。构造期失败是"对象造不出来",类型系统能把"可能非法的对象"排除在参数之外;操作期失败只能运行时发现,且依赖调用方记得调。
失败策略由调用方决定,不由不变量决定:外部入口抛领域异常由接入层译成 4xx;内部批处理记录并跳过该条;数据修复记录并标记待复核。把 HTTP 语义或 System.exit 写进领域模型是最常见的污染。
6. 类型系统能保证的,不要运行时再查
编译期可推导的约束,运行时校验是纯浪费:它把不会发生的错误变成每次调用都付的分支。
| 约束 | 编译期手段 | 运行时校验必要性 |
|---|---|---|
| 非空引用 | 不可空类型(Optional、非空注解) |
低(边界仍需一次) |
| 枚举取值 | 枚举 / sealed |
低 |
| 数值范围 | 表达不了 | 高 |
| 跨字段关系 | 表达不了 | 高 |
真正需要运行时校验的只有两类:外部输入(类型系统管不到字符串内容)与无法用类型表达的值约束(范围、长度、格式)。其余更便宜的解法是加强类型。
record Money(long cents) { // 类型即"已校验"
Money {
if (cents < 0) throw new IllegalArgumentException("negative");
}
}
// 拿到 Money 之后,任何层都不必再查"是否为负"
代价是序列化与 ORM 适配(映射列、反序列化),这是值对象的固定成本,选型时要算进去。
7. 与幂等、灰度、配置化的三处交互
幂等:幂等键校验必须早于业务校验。否则重复提交的第二次先撞业务规则("库存不足")返回失败,调用方据此重试,形成放大。顺序:幂等键格式 → 命中即返回首次结果 → 业务校验。
灰度:新规则会拒绝存量请求。"金额必须 ≥ 1"对历史里 0.5 的记录是灾难,所以新增请求可直接生效,存量数据要迁移或给宽限期。
配置化:规则做成配置能动态调整,代价三条——无法静态检查(拼错的键静默不生效)、无法单测(组合爆炸)、行为不可复现(同代码不同配置语义不同)。
validation:
amount:
min: 1 # 写成 mni 也不报错,只是不生效
strict-mode: true # 关闭后同一请求行为改变
取舍:阈值类(限额、频率)适合配置化,结构性规则(必填、格式、状态机)不适合——前者变化频繁且需运营调整,后者变化即代码变更。
8. 前后端分工与四个度量
前端是体验(即时反馈、减少往返),后端是底线(不可绕过)。判据:前端可以漏,后端不能漏;后端规则变更时前端可滞后一个版本。
| 指标 | 采集 | 报警 |
|---|---|---|
| 校验失败率 | 按错误码分组 | 某码突增 → 前后端规则不一致 |
| 被拒分布 | 按字段/来源 | 集中某来源 → 该端未同步规则 |
| 领域异常占比 | 领域异常/总请求 | 上升 → 上游校验失效 |
| 重复提交命中 | 幂等键命中数 | 与校验失败同增 → 顺序有问题 |
第一个最有价值:失败率突增几乎总是"一处规则改了,另一处没改",不是用户突然开始乱传。
9. 排错:五种"校验做了但没用"
| 现象 | 真实原因 | 处置 |
|---|---|---|
| 接口 400 但数据已写入 | 校验在提交之后或异步链路里 | 校验前移到写之前 |
| 定时任务写入非法数据 | 只有控制器校验,旁路无校验 | 规则下沉到领域层 |
| 前端提示必填,接口仍收到空值 | 前端校验被绕过 | 后端同一规则必须存在 |
| 类级约束无法定位字段 | 跨字段规则写在类级注解 | 改字段级显式错误 |
| 改文案导致客户端解析失败 | 用文案当错误码 | 码与文案分离 |
10. 边界:可以只做不变量校验的场景
| 场景 | 说明 |
|---|---|
| 内部服务间调用 | 契约同一团队维护,格式校验边际收益低于开销 |
| 可信来源(同进程/同库) | 未跨信任边界,格式已在上游保证 |
| 来源可靠的批处理 | 输入己方导出、格式固定,重点是不变量 |
| schema 强约束的通道 | Protobuf/Avro 已保证结构 |
| 一次性脚本与内部工具 | 校验成本高于错误成本 |
共同点:信任边界不在这一层。校验的价值来自"不可信输入跨越边界",边界不存在时只剩不变量——而不变量仍必须做,它是正确性,不是防护。
11. 自测
- 规则该归哪一层,判据是什么?
- 什么样的重复校验可接受,什么属于漂移?
- 授权为什么必须出现两次,各在哪层?
- 注解式校验表达不了哪三类规则?
- 不变量能进构造期时,为什么一定要进?
- 幂等键校验与业务校验的顺序,错了会怎样?