《Spring Boot 入门》9.3 分组校验与嵌套校验

同一个 DTO 在新增和修改时规则常常冲突:新增时 id 必须为空,修改时 id 必须非空。本节用图书 DTO 演示校验分组、@GroupSequence 组顺序、嵌套对象级联与集合元素校验,指出 @Valid 加错位置会静默跳过的陷阱,并以一个新增/修改共用 DTO 的完整示例收尾。

本节目标:用校验分组解决「新增与修改规则冲突」的实战问题,掌握 @GroupSequence 的顺序控制、嵌套对象与集合元素的级联校验,并避开 @Valid 加错位置导致静默跳过校验的陷阱。
适用版本:Spring Boot 4.1.x(Java 21)

9.3 分组校验与嵌套校验

到这一节,图书服务的单个字段校验已经完备。但真实项目里有两类问题单靠字段注解解决不了:

  1. 同一个 DTO,新增与修改的规则相反。 新增时 id 必须为空(由数据库生成),修改时 id 必须非空(否则不知道该改哪条)。给 id 加 @Null 还是 @NotNull?两者冲突。
  2. DTO 里嵌套了别的对象。 BookCreateRequest 里有个 Author author,给 author 的字段标了约束,结果完全不生效。

这两类问题分别对应校验分组与级联校验,是本节的两条主线。

9.3.1 分组的本质:给约束贴标签

校验分组(Group)本质是「给约束打个标签,校验时只检查某个标签下的约束」。分组用一个空的标记接口表示:

package com.example.library.validation;

public interface Create {
}

public interface Update {
}

它们没有任何方法,只是「标签」。接口名按惯例用 Create / Update 这样的语义名。Default 分组是框架内置的——所有没显式声明 groups 的约束都默认属于 Default 分组。

9.3.2 用分组解决 id 冲突

现在给图书 DTO 的 id 加上两个互斥规则,分别挂在两个分组上:

package com.example.library.web.dto;

import com.example.library.validation.Create;
import com.example.library.validation.Update;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;

public class BookRequest {

    @Null(groups = Create.class, message = "新增时不能指定 id")
    @NotNull(groups = Update.class, message = "修改时必须指定 id")
    private Long id;

    @NotBlank(message = "书名不能为空")
    @Size(max = 100, message = "书名不能超过 100 个字符")
    private String title;

    @NotBlank(message = "ISBN 不能为空")
    private String isbn;

    @NotNull(message = "价格不能为空")
    @PositiveOrZero(message = "价格不能为负")
    private BigDecimal price;
}

注意 title、isbn、price 没有写 groups,它们属于 Default 分组。这带来一个关键问题:当校验器只校验 Create 分组时,Default 分组的约束不会被执行。所以「新增时校验 title 不能为空」也会失效——这显然不是我们想要的。

9.3.3 在 Controller 上用 @Validated 指定分组

指定分组必须用 Spring 的 @Validated,@Valid 无法指定分组:

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public BookResponse create(@Validated(Create.class) @RequestBody BookRequest req) {
        return bookService.create(req);
    }

    @PutMapping("/{id}")
    public BookResponse update(@PathVariable Long id,
                               @Validated(Update.class) @RequestBody BookRequest req) {
        return bookService.update(id, req);
    }
}

到这里就有 9.3.2 留下的坑:create 只校验 Create 分组,title 的 @NotBlank(属于 Default)不会跑。要让它跑,得让分组接口继承 jakarta.validation.groups.Default:

public interface Create extends Default {
}

public interface Update extends Default {
}

继承后,校验 Create 分组时会连带校验 Default 分组的约束,Update 同理。这是一个非常实用、却常被忽略的技巧:不继承 Default 时,你会看到「只有 id 被校验、其它字段全放行」的诡异现象。

9.3.4 @GroupSequence:控制组的执行顺序

有时你希望先校验一组,通过后再校验下一组。典型场景:先用「基本格式」分组做廉价校验,全部通过后再做「昂贵校验」,避免无谓开销。这时用 @GroupSequence 定义顺序:

package com.example.library.validation;

import jakarta.validation.GroupSequence;

@GroupSequence({Default.class, Create.class, Update.class})
public interface BookValidationSequence {
}

它要求:校验 BookValidationSequence 时,先跑 Default,全通过再跑 Create,最后 Update。一旦某一组出现失败,后续组不再执行——这正是「快速失败」的语义。

用法是把 @Validated 的分组参数换成这个序列接口,即 @Validated(BookValidationSequence.class)。

需要注意的是,@GroupSequence 与分组继承是两种不同机制,别混用在同一处造成预期外的短路。

9.3.5 嵌套对象校验:@Valid 必须加在字段上

图书 DTO 里嵌一个作者对象是很自然的建模:

public class AuthorRequest {
    @NotBlank(message = "作者姓名不能为空")
    private String name;

    @Email(message = "作者邮箱格式不正确")
    private String email;
}

然后在 BookRequest 里引用它。反例先来——下面这种写法不会级联校验:

public class BookRequest {
    // 错误:只有 @NotNull,没有 @Valid
    @NotNull(message = "作者不能为空")
    private AuthorRequest author;
}

此时如果客户端传 {"author": {"name": "", "email": "not-an-email"}},author 本身非 null,@NotNull 通过,而 author.name 与 author.email 上的约束根本不会被执行——请求静默通过,非法数据落库。这就是本节开头说的第二个坑。

正确写法是在字段上补 @Valid:

public class BookRequest {
    @NotNull(message = "作者不能为空")
    @Valid
    private AuthorRequest author;
}

@Valid 加在字段上才表示「级联校验这个对象内部」。规则很直白:想校验嵌套对象,字段上必须有 @Valid(或 @Valid 的变体 @ConvertGroup 配合分组)。少写一个 @Valid,校验就整层失效,而且不报任何错。

写法效果
private AuthorRequest author;完全不校验
@NotNull private AuthorRequest author;只校验非空,内部字段不校验
@Valid private AuthorRequest author;级联校验内部字段
@NotNull @Valid private AuthorRequest author;既非空又级联,最常用

9.3.6 集合元素校验

嵌套的往往不是单个对象,而是一个列表。比如图书有多个标签:

public class BookRequest {
    @NotEmpty(message = "至少需要一个标签")
    @Size(max = 10, message = "标签不能超过 10 个")
    private List<@NotBlank(message = "标签不能为空") @Size(max = 20) String> tags;

    @Valid
    @NotEmpty(message = "至少需要一个作者")
    private List<AuthorRequest> authors;
}

这里有两层含义要分清:

  • List<@NotBlank String> 中的 @NotBlank 标在类型参数上,表示「校验每个元素」。这叫容器元素校验,是 JSR-380 引入的能力,注解写在泛型参数位置。
  • List<AuthorRequest> authors 前面加 @Valid,表示级联校验列表里的每个元素对象。不加 @Valid 同样会静默跳过。

两种写法的区别:

目标写法
校验集合里每个简单值(String、Integer)List<@NotBlank String>(注解在泛型参数)
校验集合里每个对象内部字段@Valid List<AuthorRequest>(注解在字段上)
二者兼有@Valid @NotEmpty List<@NotNull AuthorRequest>

List<@Valid Book> 这种写法在早期版本里并不总是生效(@Valid 对容器元素的支持依实现而异),可靠写法是把 @Valid 放在字段声明上,而不是放进泛型参数里。

9.3.7 @Valid 与 @Validated 在分组场景的差别

回顾 9.1.9 的对比,在分组场景下分工更明确:普通入参用 @Valid;要指定分组(@Validated(Create.class))或做 Service 方法级校验,只能用 @Validated,因为 @Valid 没有分组属性。

一条容易踩的坑:@Validated 用在字段上不会级联。有人以为字段写 @Validated 也能触发嵌套校验,结果无效。字段级联只认 @Valid。

9.3.8 分组与自定义校验器的结合

9.2 写的 @Isbn 同样能声明 groups。比如「只在新增时校验 ISBN 校验位,修改时允许暂时保留旧值」:

@NotBlank(message = "ISBN 不能为空")
@Isbn(groups = Create.class, message = "ISBN 格式不正确")
private String isbn;

自定义约束的分组语义与内置约束完全一致:约束所属的分组由注解上的 groups 决定,与校验器实现无关。校验器只管「值是否合法」,不关心自己属于哪个分组——分组过滤发生在校验器被调用之前。

同理,@GroupSequence 对自定义约束一样生效。这意味着 9.3.4 的序列可以同时覆盖内置与自定义约束,无需特殊处理。

9.3.9 完整示例:新增/修改共用 DTO

把本节所有内容串起来。先定义分组(注意继承 Default):

package com.example.library.validation;

import jakarta.validation.groups.Default;

public interface Create extends Default {
}

public interface Update extends Default {
}

共用的 DTO,嵌套作者、集合标签、分组约束一应俱全:

package com.example.library.web.dto;

import com.example.library.validation.Create;
import com.example.library.validation.Isbn;
import com.example.library.validation.Update;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;

public class BookRequest {

    @Null(groups = Create.class, message = "新增时不能指定 id")
    @NotNull(groups = Update.class, message = "修改时必须指定 id")
    private Long id;

    @NotBlank(message = "书名不能为空")
    @Size(max = 100, message = "书名不能超过 100 个字符")
    private String title;

    @NotBlank(message = "ISBN 不能为空")
    @Isbn(groups = Create.class, message = "ISBN 格式不正确")
    private String isbn;

    @NotNull(message = "价格不能为空")
    @PositiveOrZero(message = "价格不能为负")
    private BigDecimal price;

    @NotNull(message = "作者不能为空")
    @Valid
    private AuthorRequest author;

    @Valid
    @NotEmpty(message = "至少需要一个作者")
    private List<AuthorRequest> coAuthors;

    @NotEmpty(message = "至少需要一个标签")
    @Size(max = 10, message = "标签不能超过 10 个")
    private List<@NotBlank(message = "标签不能为空") String> tags;
}

Controller 与 9.3.3 完全一致,只是把分组参数换成 Create.class 与 Update.class。

行为对照:

请求校验分组id 规则ISBN 校验位嵌套/集合
POST /api/booksCreate(继承 Default)必须为 null校验级联
PUT /api/books/{id}Update(继承 Default)必须非 null跳过级联

一个容易忽略的细节:@PathVariable Long id 与请求体里的 id 是两回事。修改接口通常以路径里的 id 为准,此时可以把请求体里的 @NotNull(groups = Update.class) 去掉,避免调用方重复传两次。这里保留它,是为了演示「同一字段两套规则」这一核心机制。

9.3.10 常见坑速查

坑现象解决
分组接口没继承 Default只校验该分组的约束,其它字段全放行public interface Create extends Default {}
嵌套字段漏写 @Valid内层约束静默失效,不报错字段上补 @Valid
集合字段漏写 @Valid元素对象内部字段不校验字段上补 @Valid
用 @Valid 指定分组编译不过(没有该属性)换 @Validated(Create.class)
字段上用 @Validated 做级联无效字段级联只认 @Valid
@GroupSequence 与继承混用校验组顺序与预期不符二者语义不同,别在同一处叠加

小结

  • 校验分组用空的标记接口表示,本质是给约束打标签,校验时只跑指定标签(含继承来的)下的约束。
  • 让分组接口继承 jakarta.validation.groups.Default,才能在校验该分组时连带执行未声明分组的约束——这是最易漏的一步。
  • @GroupSequence 定义组的执行顺序,前一组失败则后续短路,可用于「快速失败」。
  • 嵌套对象与集合元素的级联校验,必须在字段上写 @Valid;漏写不会报错,只会静默跳过,是排查成本最高的坑。
  • 容器元素校验把注解写在泛型参数上(List<@NotBlank String>);对象集合则在字段上加 @Valid。
  • @Valid 负责常规与级联校验,@Validated 负责指定分组与 Service 方法级校验,二者不可互相替代。
  • 自定义校验器的分组语义与内置约束完全一致,可无缝配合分组与序列。

至此,图书服务的入参校验已经完整:注解覆盖常规字段,自定义校验器处理 ISBN 这类特殊格式,分组解决新增/修改的规则冲突。但这些校验失败后返回的响应体仍然不含字段详情。下一章我们就来解决这个问题——统一异常处理,把 MethodArgumentNotValidException 与 ConstraintViolationException 变成结构清晰、带字段错误的 400 响应。

阅读导航:上一节:9.2 自定义校验器 · 下一节:10.1 全局异常处理器 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计