3024 字
约 10 分钟
19
S07-A08 · 校验的分层:参数、领域与不变量各管什么

一个 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. 自测

  1. 规则该归哪一层,判据是什么?
  2. 什么样的重复校验可接受,什么属于漂移?
  3. 授权为什么必须出现两次,各在哪层?
  4. 注解式校验表达不了哪三类规则?
  5. 不变量能进构造期时,为什么一定要进?
  6. 幂等键校验与业务校验的顺序,错了会怎样?
S07-A08 · 校验的分层:参数、领域与不变量各管什么
https://www.clxhxhhr.top/posts/6237/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。