本节目标:理解 Jakarta Validation 与 Hibernate Validator 的关系,掌握在 Controller 入参上启用声明式校验的方法,并记住最常用的一批约束注解及其区别。
适用版本:Spring Boot 4.1.x(Java 21)
9.1 Bean Validation 常用注解
从这一章开始,我们给前几章搭起来的「图书管理服务」补上第一道防线:入参校验。一个创建图书的接口 POST /api/books,如果调用方送来 title 为空、price 为负数、isbn 是乱码,业务代码不应该去处理这些垃圾数据——它们应该在进入 Service 之前就被拦下。
这一节先解决「用什么拦、怎么拦」,下一节解决「标准注解不够用时怎么办」,9.3 解决「同一个 DTO 在新增和修改时规则不同怎么办」。
9.1.1 先看手写校验有多难看
假设不用任何框架,一个创建图书的 Controller 大概是这样的:
@PostMapping("/api/books")
public Book create(@RequestBody BookCreateRequest req) {
if (req.getTitle() == null || req.getTitle().isBlank()) {
throw new IllegalArgumentException("书名不能为空");
}
if (req.getTitle().length() > 100) {
throw new IllegalArgumentException("书名不能超过 100 个字符");
}
if (req.getIsbn() == null || req.getIsbn().isBlank()) {
throw new IllegalArgumentException("ISBN 不能为空");
}
if (req.getPrice() == null || req.getPrice().signum() < 0) {
throw new IllegalArgumentException("价格不能为负");
}
if (req.getPublishDate() != null && req.getPublishDate().isAfter(LocalDate.now())) {
throw new IllegalArgumentException("出版日期不能是未来");
}
// ……真正开始干活
return bookService.create(req);
}
这段代码有三个毛病:
| 毛病 | 说明 |
|---|---|
| 与业务耦合 | 校验逻辑挤在 Controller 里,DTO 一多,每个接口都要抄一遍 |
| 抛出的异常不统一 | IllegalArgumentException 会让客户端收到 500,而不是语义正确的 400 |
| 无法复用 | 同一个 DTO 在别处使用时,没人保证你会再抄一遍这些 if |
正确做法是把校验规则声明在 DTO 的字段上,让框架在方法执行前自动执行。这就是 Bean Validation。
9.1.2 三个名字:JSR-380、Jakarta Validation、Hibernate Validator
初学者最容易被这几个名字绕晕。它们的关系是「规范」与「实现」:
| 名称 | 是什么 | 说明 |
|---|---|---|
| Bean Validation | 规范的名字 | 定义约束注解(@NotNull 等)与 Validator 接口的标准 |
| JSR-380 | 规范的一次版本号 | Bean Validation 2.0 对应的 JSR 编号,引入了容器元素校验、@Email 等 |
| Jakarta Validation | 规范的包名/API | 规范迁到 Jakarta EE 后,包名从 javax.validation 变成 jakarta.validation |
| Hibernate Validator | 规范的参考实现 | 真正干活的库,还额外提供 @URL、@Range、@Length 等扩展注解 |
一句话记法:你写的是 Jakarta Validation 的注解,跑起来的是 Hibernate Validator 这个实现。
Spring Boot 4.x 的口径要记准:
- Jakarta EE 11 / Bean Validation 3.1,包名一律是
jakarta.validation.*(旧代码里的javax.validation.*全部失效)。 - 内置的 Hibernate Validator 版本是 9.0。
- 所以
import jakarta.validation.constraints.NotNull;是正确的,import javax.validation.constraints.NotNull;会编译不过。
9.1.3 引入依赖
校验支持不在 Web starter 里,需要单独加一个 starter。注意 4.x 的 Web starter 已改名为 spring-boot-starter-webmvc:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
这个 starter 会传递引入 jakarta.validation-api 与 hibernate-validator。不需要自己写版本号,BOM 会钉好 9.0。
一个必须记住的点:Spring Boot 3.x/4.x 的 Web starter 不再默认带上校验实现。如果你只引了 spring-boot-starter-webmvc 而忘了 spring-boot-starter-validation,@Valid 会静默失效——不报错、不校验,接口照常接收非法数据。这是新手最常见的一号坑。
9.1.4 在 DTO 上声明约束
把 9.1.1 的 if 全部搬到 DTO 上:
package com.example.library.web.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
public class BookCreateRequest {
@NotBlank(message = "书名不能为空")
@Size(max = 100, message = "书名不能超过 100 个字符")
private String title;
@NotBlank(message = "ISBN 不能为空")
@Size(min = 10, max = 17, message = "ISBN 长度应为 10 到 17 个字符")
private String isbn;
@NotNull(message = "价格不能为空")
@PositiveOrZero(message = "价格不能为负")
private BigDecimal price;
@Past(message = "出版日期必须是过去的日期")
private LocalDate publishDate;
@Email(message = "邮箱格式不正确")
private String contactEmail;
// getter / setter 省略
}
注意每个注解都带了 message。不写 message 时框架会用默认英文提示(如 must not be blank),对中文产品不友好。自定义消息与国际化在 9.2 展开。
9.1.5 在 Controller 上启用校验
DTO 上标了注解还不够,必须告诉 Spring MVC「进入这个方法前先校验它」。做法是在参数前加 @Valid:
package com.example.library.web;
import com.example.library.web.dto.BookCreateRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@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(@Valid @RequestBody BookCreateRequest req) {
return bookService.create(req);
}
}
两个要点:
@Valid必须和@RequestBody同时出现。只有@RequestBody不会触发校验。@Valid来自jakarta.validation.Valid,不是 Spring 自己的注解。
9.1.6 常用约束注解速查表
下面这张表覆盖入门阶段 90% 的场景,值得背下来:
| 注解 | 适用类型 | 语义 | 常见误用 |
|---|---|---|---|
@NotNull | 任意 | 值不能为 null,但允许空字符串、空集合 | 以为它能挡住 "" |
@NotBlank | CharSequence | 值非 null 且去掉首尾空白后长度 > 0 | 用在数字字段上(类型不匹配) |
@NotEmpty | 字符串、集合、Map、数组 | 非 null 且长度/元素数 > 0 | 用在数字字段上 |
@Size(min,max) | 字符串、集合、Map、数组 | 长度或元素个数在区间内 | 以为它能校验数字大小 |
@Min(n) / @Max(n) | 整数类型 | 数值下界 / 上界 | 用在 BigDecimal 上精度受限 |
@DecimalMin / @DecimalMax | 数值、字符串 | 支持小数与字符串形式 | 忘了 inclusive=false 表示不含边界 |
@Positive / @PositiveOrZero | 数值 | 大于 0 / 大于等于 0 | 与 @Min 混用造成重复提示 |
@Pattern(regexp) | CharSequence | 匹配正则 | 正则写错导致全部通过 |
@Email | CharSequence | 邮箱格式(宽松匹配) | 以为它能做严格 RFC 校验 |
@Past / @Future | 日期时间 | 必须是过去 / 未来 | 未考虑时区,边界日容易误判 |
@Digits(integer,fraction) | 数值 | 整数位与小数位上限 | 常与金额字段搭配 |
@AssertTrue / @AssertFalse | boolean | 必须为 true / false | 用于「同意协议」勾选 |
9.1.7 @NotNull、@NotBlank、@NotEmpty 的区别(重点)
这三个是入门阶段最容易混淆的一组。用一张表钉死:
| 值 | @NotNull | @NotEmpty | @NotBlank |
|---|---|---|---|
null | ✗ | ✗ | ✗ |
"" | ✓ | ✗ | ✗ |
" "(纯空格) | ✓ | ✓ | ✗ |
"a" | ✓ | ✓ | ✓ |
选择口诀:
- 字段是字符串且语义是「内容不能没有」→ 用
@NotBlank(它是唯一能挡住纯空格的)。 - 字段是集合/数组/Map且要求非空 → 用
@NotEmpty。 - 字段是对象引用且只要非
null(比如外键 ID)→ 用@NotNull。
一个反面例子:给 title 只加 @NotNull,客户端传 " "(三个空格)就能通过,随后在数据库里留下一条「空白书名」。正确写法是 @NotBlank。
另外要注意类型限制:@NotBlank 与 @NotEmpty 不能用在 Integer、BigDecimal 这类字段上,写了会抛 UnexpectedTypeException。数字字段的非空判断只能用 @NotNull。
9.1.8 校验失败时会发生什么
当 @Valid 校验不通过,Spring MVC 会抛出 MethodArgumentNotValidException,并直接返回 400,不会进入你的 Controller 方法体。服务端日志里能看到异常与逐字段的错误:
2026-09-18T14:03:11.204+08:00 WARN 51204 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.library.web.dto.BookResponse com.example.library.web.BookController.create(com.example.library.web.dto.BookCreateRequest) with 2 errors: [Field error in object 'bookCreateRequest' on field 'title': rejected value [ ]; codes [NotBlank.bookCreateRequest.title,NotBlank.title,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookCreateRequest.title,title]; arguments []; default message [书名不能为空]]; default message [书名不能为空]] [Field error in object 'bookCreateRequest' on field 'price': rejected value [null]; codes [NotNull.bookCreateRequest.price,NotNull.price,NotNull.java.math.BigDecimal,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookCreateRequest.price,price]; arguments []; default message [价格不能为空]]; default message [价格不能为空]] ]
而默认的 HTTP 响应体是这个样子(Spring Boot 的通用错误页,注意它不含具体哪个字段错了):
{
"timestamp": "2026-09-18T06:03:11.204+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/books"
}
这就是为什么第 10 章要专门讲统一异常处理:默认响应把「书名不能为空」这种有用信息丢掉了,客户端拿不到任何可用的提示。改造前的默认行为先记住两点——HTTP 状态码是 400(不是 500),异常是 MethodArgumentNotValidException。
顺带一提,Spring Boot 提供 spring.mvc.problemdetails.enabled=true 开关,打开后会改用 RFC 7807 的 ProblemDetail 结构返回。但即便打开,字段级错误仍需自己填充,第 10 章会给出完整做法。
9.1.9 @Valid 与 @Validated 的区别
这两个注解都能触发校验,但来源和用途不同:
| 维度 | @Valid | @Validated |
|---|---|---|
| 来源 | jakarta.validation.Valid(规范) | org.springframework.validation.annotation.Validated(Spring) |
| 作用位置 | 方法参数、字段、方法返回值 | 类、方法参数 |
| 能否指定分组 | 不能 | 能(@Validated(Create.class)) |
| 嵌套级联 | 加在字段上可级联校验 | 不用于字段级联 |
| 典型用途 | Controller 入参、DTO 字段 | Service 类上做方法级校验、指定分组 |
入门阶段的实用结论:
- Controller 入参用
@Valid就够,这是最标准的写法。 - 想用校验分组(9.3 的主题),必须换成
@Validated,因为@Valid无法指定分组。 - 在 Service 类上标注
@Validated,可以让该类的方法参数也被校验,这属于 9.2 的内容。
9.1.10 三个常见坑
| 坑 | 现象 | 解决 |
|---|---|---|
忘加 spring-boot-starter-validation | @Valid 完全不起作用,非法数据照收 | 补依赖;这是静默失效,不会报错 |
@Valid 加在了字段上但类里没嵌套对象 | 无影响,但没有级联 | 级联校验见 9.3 |
用 @NotNull 挡字符串空值 | "" 与 " " 都能通过 | 字符串用 @NotBlank |
第一条尤其危险:很多人以为「加了 @Valid 就会校验」,实际上缺了实现依赖时,Spring 会直接跳过校验。排查方法是看 mvn dependency:tree 里有没有 hibernate-validator。
小结
- 声明式校验把规则写在 DTO 字段上,取代 Controller 里成堆的
if,让非法数据在进入业务前被拦截。 - 关系链是:Jakarta Validation 是规范(包名
jakarta.validation.*),Hibernate Validator 9.0 是实现,4.x 对应 Jakarta EE 11。 - 启用三件套:加
spring-boot-starter-validation依赖、DTO 上标约束注解、Controller 参数前加@Valid(必须与@RequestBody同现)。 @NotNull只挡null,@NotEmpty还挡空串/空集合,@NotBlank连纯空格也挡;字符串字段首选@NotBlank。- 校验失败抛
MethodArgumentNotValidException,默认返回 400 且响应体不含字段详情——这正是第 10 章要解决的问题。 @Valid用于常规入参,@Validated用于分组与方法级校验。
下一节我们处理标准注解覆盖不了的场景:写一个自定义的 ISBN 校验器,并让错误消息支持中文国际化。
阅读导航:上一节:8.3 RESTful 设计约定 · 下一节:9.2 自定义校验器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。