1950 字
约 6 分钟
16
S07-A23 · 嵌套校验:为什么"里层字段没被校验"

一个下单接口的请求体是 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. 自测

  1. 为什么"里层字段没被校验"?@Valid 的作用是什么?
  2. "类上标 @Validated"为什么不解决嵌套问题?
  3. List<Item> 上不标 @Valid 会怎样?为什么 IDE 不提示?
  4. 分组校验解决什么问题?"嵌套对象不带分组"会造成什么?
  5. "扁平错误信息"在嵌套结构上丢了什么?
  6. 判断"该用注解还是领域校验"的那个问题是什么?
S07-A23 · 嵌套校验:为什么"里层字段没被校验"
https://www.clxhxhhr.top/posts/6635/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。