《Spring Boot 入门》9.1 Bean Validation 常用注解

本节讲清 Jakarta Validation 与 Hibernate Validator 9.0 的关系,演示如何用 spring-boot-starter-validation 给图书接口启用 @Valid,速查 @NotNull、@NotBlank、@NotEmpty 等常用注解,并贴出校验失败时的真实响应与 @Valid、@Validated 的区别。

本节目标:理解 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,但允许空字符串、空集合以为它能挡住 ""
@NotBlankCharSequence值非 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匹配正则正则写错导致全部通过
@EmailCharSequence邮箱格式(宽松匹配)以为它能做严格 RFC 校验
@Past / @Future日期时间必须是过去 / 未来未考虑时区,边界日容易误判
@Digits(integer,fraction)数值整数位与小数位上限常与金额字段搭配
@AssertTrue / @AssertFalseboolean必须为 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 自定义校验器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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