本节目标:用校验分组解决「新增与修改规则冲突」的实战问题,掌握 @GroupSequence 的顺序控制、嵌套对象与集合元素的级联校验,并避开 @Valid 加错位置导致静默跳过校验的陷阱。
适用版本:Spring Boot 4.1.x(Java 21)
9.3 分组校验与嵌套校验
到这一节,图书服务的单个字段校验已经完备。但真实项目里有两类问题单靠字段注解解决不了:
- 同一个 DTO,新增与修改的规则相反。 新增时
id必须为空(由数据库生成),修改时id必须非空(否则不知道该改哪条)。给id加@Null还是@NotNull?两者冲突。 - 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/books | Create(继承 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 全局异常处理器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。