一个下单接口的请求体是 Order{ user: User{ phone: String }, items: List<Item{ qty: int }> },加了 @Valid。上线后发现"phone 为空也能通过"、"qty 为负也能通过"——因为校验不会自动进入嵌套对象与集合元素,除非每层都标了注解。
Bean Validation 的规则是"逐层显式":@Valid 是"请进去校验"的开关,这个"不传染"是设计选择:级联校验要"显式声明",因为"隐式进入"会带来"意外的性能与循环引用"——于是"漏标"成为最常见的错误。
而它不会"自动传染"。
1. 三种"不进去"的情形
| 情形 | 原因 |
|---|---|
| 嵌套对象字段 | 缺 @Valid |
| 集合元素 | 集合字段上缺 @Valid |
| Map 的值 | 同样缺(且类型擦除更麻烦) |
第二项最常漏:List<Item> 上不标 @Valid,元素里的约束全部不生效第二项的"隐蔽"在于"编译与启动都正常":注解存在、类也加载了,只是"运行时没进去"——于是它只能被"用例"发现。
——而 IDE 不会提示。
2. "逐层标注"的三种写法
| 写法 | 效果 |
|---|---|
每个嵌套字段标 @Valid |
有效(显式) |
类上加 @Validated |
不解决"嵌套" |
用 @Valid 在"集合字段"上 |
校验每个元素 |
第二项的"来源"是"名字相似":@Valid 与 @Validated 一字之差,职责不同——前者"触发级联",后者"启用 Spring 的方法级校验(含分组)"。
第二项是最常见的误解:"类上标 @Validated"只启用"方法级校验",它不改变"嵌套行为"。
// 正确的"逐层标注"(三层都标)
public record Order(
@Valid User user, // 进 user
@Valid List<Item> items // 进每个 item
) {}
public record User(@NotBlank String phone) {}
public record Item(@Min(1) int qty) {}
// 反例:只在外层参数上标 @Valid,而 user/items 上没标 → 里层不校验
3. "分组校验"的三种用法
| 用法 | 说明 |
|---|---|
| 创建组(必填) | @NotNull(groups = Create.class) |
| 更新组(可空) | 同一字段在不同场景不同约束 |
| 默认组 | 不写 groups 即默认组 |
第二项的价值在"同一个 DTO 两种场景":创建时 id 必须为空,更新时 id 必须存在第二项的"前提"是"入口要把分组传下去":@Validated(Create.class) 写在 Controller 方法参数上——而"写在类上"往往只影响"默认组"。
——而分组让"一个类"表达两种约束。
// 同一个字段、两套约束(靠分组切换)
public record Cmd(
@Null(groups = Create.class) @NotNull(groups = Update.class) Long id,
@NotBlank String name
) {}
// 调用:@Validated(Create.class) 与 @Validated(Update.class) 分别校验
"级联"到底走没走,可以用"故意违反里层约束"的用例验证:
// 一条"必过"的用例:里层为空 → 应当 400
@Test void nestedPhoneRequired() throws Exception {
mockMvc.perform(post("/api/orders").contentType(APPLICATION_JSON)
.content("{\"user\":{\"phone\":\"\"},\"items\":[]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("user.phone"));
}
// 若返回 200 → 说明 @Valid 没标到 user 上
4. "分组"的三个坑
| 坑 | 表现 |
|---|---|
| 嵌套对象不带分组 | 里层按"默认组"校验 |
| 分组接口没被 @Validated 指定 | 默认组生效 |
| 分组 + 级联混用 | 部分字段"永远不校验" |
第一项的"表现"是"部分字段永远不被校验":测试"创建场景"通过、而"更新场景"漏掉里层必填——于是"上线后才发现某字段可以为空"。
第一项最隐蔽:外层按 Update 校验,而里层按"默认组"校验——于是"里层的 Create 约束"被漏掉。
5. "错误信息"的三种组织
| 组织 | 效果 |
|---|---|
field: message(扁平) |
前端好定位 |
| 树形(按嵌套结构) | 精确定位,前端要递归 |
| 只给"第一条" | 最简单(但用户要改多次) |
第一项在"嵌套"上会"失去层级":phone 与 items[0].qty 都是 field第一项的"修法"是"用字段路径而不是字段名":user.phone、items[0].qty——于是前端能"定位到具体输入框",而不是"整个表单标红"。
——于是前端"不知道是哪个对象的 phone"。
"错误路径"要在"统一异常处理"里保住,否则前端拿不到字段:
// 把 MethodArgumentNotValidException 转成"字段路径 + 消息"
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onInvalid(MethodArgumentNotValidException e) {
var errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList();
return ProblemDetail.forStatusAndDetail(BAD_REQUEST, "校验失败") {{ setProperty("errors", errors); }};
}
// 关键:getField() 对嵌套会给出 "user.phone" 这样的路径
6. 排错:五种"校验不生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 里层字段通过 | 缺 @Valid |
逐层标注 |
| 集合元素不校验 | 集合字段缺 @Valid |
加在集合上 |
| 分组后部分失效 | 嵌套未带分组 | 统一分组 |
| 错误信息缺字段路径 | 扁平化 | 保留路径 |
| 只校验了第一层 | 方法参数未标 | 检查入口 |
"集合大小"与"元素约束"要分开想,否则会写出"永远过不了"的规则:
// 两类约束(都写在集合字段上,但语义不同)
@Size(max = 100) List<@Valid Item> items // 集合大小 ≤ 100
@Valid List<Item> items // 每个元素进 item 校验
// 组合写法:@Size(max=100) List<@Valid Item> items
// 反例:只在 Item 上写 @Size,指望"限制列表长度" → 不生效
7. 边界:什么情况不必用嵌套校验
| 场景 | 为什么 | 做法 |
|---|---|---|
| 扁平 DTO(无嵌套) | 无"进去"问题 | 单层标注 |
| 手工校验已足够(字段少) | 用注解更慢 | 手写 if |
| 校验在"领域层"更合适 | 约束依赖业务状态 | 领域校验 |
| 前端已强校验 + 后端兜底 | 只兜"关键几条" | 少标 |
判断口径一句话:"这个约束依赖"请求的形状"还是"系统的状态"。依赖形状(非空/长度/范围),用注解;依赖状态("这个用户能否下这个单"),用领域校验——后者塞不进注解。
还有一条"性能"事实:级联校验会在"深嵌套 + 大集合"上明显变慢——1 万条元素各校验 5 个约束,就是 5 万次校验。所以"大集合"应当"先限制大小(@Size),再校验元素";而"只做元素校验、不限制大小"是常见的疏漏——它让"恶意请求"用"10 万条元素"拖垮校验。
最后一个"实践"建议:把"校验"当成"接口契约",写进 API 文档——每个字段标"必填/长度/范围/分组"。因为"校验注解"只对"后端自己"可见,而"前端与第三方"要看的是"文档";两边不一致时,用户会看到"文档说可以、接口说不行"。
再记一个"最小例子":record 上的约束要写在"组件"上,而不是"构造参数的类型"上——写错位置时,约束会被静默忽略。
这一条最省事:先写用例,再标注解——用例会告诉你"哪一层漏了"。
8. 自测
- 为什么"里层字段没被校验"?
@Valid的作用是什么? - "类上标
@Validated"为什么不解决嵌套问题? List<Item>上不标@Valid会怎样?为什么 IDE 不提示?- 分组校验解决什么问题?"嵌套对象不带分组"会造成什么?
- "扁平错误信息"在嵌套结构上丢了什么?
- 判断"该用注解还是领域校验"的那个问题是什么?