Note 13. Springboot3 参数校验:JSR-303 参数校验最佳实践

Note 13. 防御性编程:JSR-303 参数校验最佳实践

摘要:“永远不要信任来自客户端的任何数据”——这是构建健壮系统的第一信条。任何未经校验的输入,都是潜在的安全漏洞和运行时炸弹。本章,我们将深入 Spring Boot 强大的声明式参数校验体系。我们将从引入 validation 依赖开始,系统学习 JSR-303 规范下的常用注解,并重点攻克 分组校验自定义校验注解嵌套对象校验 等高级技巧。通过本章学习,你将彻底告别 Service 层的 if-else 地狱,让参数校验逻辑以声明式的方式优雅地附着在数据模型之上。

本章学习路径

  1. 理念奠基:理解 “快速失败” 原则,明确 JSR-303 规范与 Hibernate Validator 的实现关系,并完成环境搭建。
  2. 入门实战:从零创建第一个带校验的 DTO,体验声明式校验的魅力,观察校验失败时 Spring 的默认行为。
  3. 注解武器库:系统学习 @NotBlank@Pattern@Size@Email 等常用注解,并通过对比表格掌握它们的细微差异。
  4. 激活机制:深度辨析 @Valid@Validated 的区别,理解何时使用哪一个。
  5. 高级技巧:掌握分组校验、自定义注解、URL 参数校验、嵌套对象校验等企业级必备技能。
  6. 实战总结:通过场景化代码速查表和避坑指南,建立完整的参数校验知识体系。

13.1. 从 If-Else 地狱到声明式契约

13.1.1. 痛点回顾:防御性代码的困境

在引入校验框架前,我们的 Service 层往往充斥着大量这样的 “卫语句”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void register(String username, String password, String email) {
if (username == null || username.isBlank()) {
throw new RuntimeException("用户名不能为空");
}
if (username.length() < 4 || username.length() > 20) {
throw new RuntimeException("用户名长度需在4-20字符之间");
}
if (password == null || password.length() < 6) {
throw new RuntimeException("密码长度不能少于6位");
}
if (email == null || !email.contains("@")) {
throw new RuntimeException("邮箱格式不正确");
}
// ... 可能还有 10 个以上的 if 判断

// 真正核心的业务逻辑,可能只有寥寥数行
User user = new User();
user.setUsername(username);
user.setPassword(password);
user.setEmail(email);
userMapper.insert(user);
}

这种手动校验的方式,存在诸多弊端:

问题维度具体表现影响
代码臃肿大量的校验逻辑与核心业务逻辑混杂在一起严重影响代码可读性和维护性,核心业务逻辑被淹没
职责不清参数的基础格式校验本质上属于 Web 层职责违反单一职责原则,Service 层被迫承担不该有的责任
重复劳动相同的校验逻辑(如校验用户名格式)需要在多个方法中重复编写增加维护成本,修改规则需要改多处代码
错误信息不统一每个开发者可能用不同的提示语描述同一个错误用户体验差,前端难以统一处理
缺乏国际化支持硬编码的中文提示无法适配多语言场景限制系统的扩展性

13.1.2. 解决方案:JSR-303 与声明式校验

为了解决这个问题,Java 社区推出了 JSR-303(Bean Validation) 规范,它定义了一套基于注解的 API,允许我们以一种 声明式 的方式,直接在数据对象(如 DTO)的字段上定义其约束。

JSR-303 规范与实现的关系

mermaid-diagram (2)

核心设计哲学

  1. 关注点分离:校验逻辑从业务代码中剥离,附着在数据模型上
  2. 声明式编程:通过注解声明约束,而非命令式的 if 判断
  3. 可复用性:同一个 DTO 可以在多个场景中复用,校验规则自动生效
  4. 标准化:遵循 JSR-303 规范,可以在不同框架间无缝迁移

13.1.3. 引入依赖

自 Spring Boot 2.3 版本起,validation 不再是 web starter 的默认依赖,需要我们手动引入。

文件路径pom.xml

1
2
3
4
5
<dependency>
<!-- 引入 Spring Boot 的 Validation Starter,它会传递引入 hibernate-validator 等核心库 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

依赖传递关系

引入 spring-boot-starter-validation 后,会自动传递以下核心依赖:

依赖作用
jakarta.validation-apiJSR-303 规范的接口定义(如 @NotNull@Size 等注解)
hibernate-validatorHibernate 提供的 JSR-303 参考实现,包含校验引擎
jakarta.el表达式语言支持,用于复杂的校验消息模板

13.2. 从零开始:第一个校验案例

在深入学习各种注解之前,我们先通过一个完整的案例来体验声明式校验的工作流程。这个案例将帮助你建立对整个校验体系的感性认知。

13.2.1. 业务场景设定

我们正在开发一个用户管理系统,需要实现用户注册功能。注册时,用户需要提交用户名、密码和邮箱三个字段。我们的校验需求如下:

字段校验规则
用户名不能为空,长度在 4-20 字符之间
密码不能为空,必须包含大小写字母和数字,长度 8-16 位
邮箱不能为空,必须符合邮箱格式

13.2.2. 创建数据传输对象(DTO)

文件路径src/main/java/com/example/demo/dto/UserRegisterDTO.java

我们先创建一个最简单的版本,暂时不添加任何校验注解:

1
2
3
4
5
6
7
8
9
10
package com.example.demo.dto;

import lombok.Data;

@Data
public class UserRegisterDTO {
private String username;
private String password;
private String email;
}

现在,让我们为这个 DTO 添加校验约束。我们将逐个字段添加注解,并详细解释每个注解的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class UserRegisterDTO {

@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间")
private String username;

@NotBlank(message = "密码不能为空")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$",
message = "密码必须8-16位,且包含大小写字母和数字"
)
private String password;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
}

注解解析

字段注解作用说明
username@NotBlank确保字符串不为 null,且去除首尾空格后长度大于 0
@Size(min=4, max=20)限制字符串长度在 4-20 之间
password@NotBlank确保密码不为空
@Pattern使用正则表达式校验密码复杂度
email@NotBlank确保邮箱不为空
@Email校验邮箱格式是否合法

正则表达式解读

密码的正则 ^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$ 分解如下:

  • ^:字符串开始
  • (?=.*[a-z]):前瞻断言,确保至少包含一个小写字母
  • (?=.*[A-Z]):前瞻断言,确保至少包含一个大写字母
  • (?=.*\\d):前瞻断言,确保至少包含一个数字
  • [\\s\\S]{8,16}:匹配任意字符(包括空白字符),长度 8-16
  • $:字符串结束

13.2.3. 创建 Controller 接口

文件路径src/main/java/com/example/demo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.controller;

import com.example.demo.dto.UserRegisterDTO;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

@PostMapping("/register")
public String register(@RequestBody @Valid UserRegisterDTO registerDTO) {
// 如果代码执行到这里,说明所有校验都通过了
return "注册成功:" + registerDTO.getUsername();
}
}

关键点

  1. @Valid 注解是触发校验的开关,没有它,DTO 上的校验注解不会生效
  2. 校验失败时,Spring 会自动抛出异常,阻止方法继续执行
  3. 只有校验全部通过,方法体内的代码才会执行

13.2.4. 测试校验效果

启动项目后,使用 Postman 或 cURL 发送以下请求:

测试用例 1:所有字段都合法

1
2
3
4
5
6
7
8
POST http://localhost:8080/users/register
Content-Type: application/json

{
"username": "testuser",
"password": "Test1234",
"email": "test@example.com"
}

预期结果:返回 注册成功:testuser

测试用例 2:用户名为空

1
2
3
4
5
6
7
8
POST http://localhost:8080/users/register
Content-Type: application/json

{
"username": "",
"password": "Test1234",
"email": "test@example.com"
}

预期结果:Spring 返回 400 错误,错误信息中包含 用户名不能为空

测试用例 3:密码不符合复杂度要求

1
2
3
4
5
6
7
8
POST http://localhost:8080/users/register
Content-Type: application/json

{
"username": "testuser",
"password": "12345678",
"email": "test@example.com"
}

预期结果:返回 密码必须8-16位,且包含大小写字母和数字

13.2.5. 观察默认的错误响应

当校验失败时,Spring Boot 会返回类似下面的 JSON 结构:

1
2
3
4
5
6
7
{
"timestamp": "2025-12-17T09:31:46.123+00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for object='userRegisterDTO'. Error count: 1",
"path": "/users/register"
}

这个默认响应有以下问题:

  1. 错误信息不够具体,没有告诉前端具体哪个字段校验失败
  2. 格式不统一,与我们自定义的业务响应结构不一致
  3. 包含了一些技术细节(如对象名称),不适合直接展示给用户

本节核心要点

  • 我们成功让 DTO 的校验注解生效,体验了声明式校验的便捷性
  • 发现了 Spring 默认错误响应的不足
  • 在下一章(Note 14)中,我们将学习如何通过全局异常处理器,将这些校验异常转换为对前端友好的统一格式

13.3. 注解武器库:常用校验注解详解

JSR-303 提供了丰富的内置注解来满足各种校验需求。本节我们将系统学习这些注解的用法和适用场景。

13.3.1. 空值校验三剑客:@NotNull、@NotEmpty、@NotBlank

这三个注解是最容易混淆的,很多初学者不清楚它们的区别。让我们通过对比表格和实际案例来理解它们的细微差异。

对比表格

注解适用类型null“”" "“abc”空集合非空集合
@NotNull任何类型
@NotEmptyString, Collection, Map, Array
@NotBlank仅 String--

详细说明

@NotNull

  • 约束:值不能为 null
  • 允许:空字符串 ""、只包含空格的字符串 " "、空集合 []
  • 适用场景:基本类型包装类(Integer、Long)、枚举类型、日期类型
1
2
3
4
5
6
7
8
@Data
public class OrderDTO {
@NotNull(message = "订单ID不能为空")
private Long orderId; // null -> ❌,0 -> ✅

@NotNull(message = "订单状态不能为空")
private OrderStatus status; // null -> ❌,任何枚举值 -> ✅
}

@NotEmpty

  • 约束:值不能为 null,且 size/length 必须大于 0
  • 允许:只包含空格的字符串 " "(因为长度 > 0)
  • 适用场景:字符串、集合、数组,当你关心 “是否有内容” 但不在乎空格时
1
2
3
4
5
6
7
8
@Data
public class UserDTO {
@NotEmpty(message = "用户标签不能为空")
private List<String> tags; // null -> ❌,[] -> ❌,["tag1"] -> ✅

@NotEmpty(message = "备注不能为空")
private String remark; // null -> ❌,"" -> ❌," " -> ✅
}

@NotBlank

  • 约束:值不能为 null,且去除首尾空格后长度必须大于 0
  • 仅适用于:String 类型
  • 适用场景:几乎所有的字符串字段(用户名、密码、邮箱等),这是字符串校验的首选注解
1
2
3
4
5
@Data
public class UserDTO {
@NotBlank(message = "用户名不能为空")
private String username; // null -> ❌,"" -> ❌," " -> ❌,"abc" -> ✅
}

最佳实践

  • 对于字符串字段,优先使用 @NotBlank,因为它能过滤掉 “看似有值实则无效” 的纯空格输入
  • 对于集合字段,使用 @NotEmpty
  • 对于数值、日期、枚举等非字符串字段,使用 @NotNull

13.3.2. 长度与范围校验

@Size

用于校验字符串、集合、数组的长度或大小。

1
2
3
4
5
6
7
8
9
10
11
@Data
public class UserDTO {
@Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间")
private String username;

@Size(min = 1, max = 5, message = "用户标签数量需在1-5个之间")
private List<String> tags;

@Size(max = 200, message = "个人简介不能超过200字")
private String bio; // 只限制最大长度,不限制最小长度
}

注意事项

  • @Sizenull 值不校验,如果要同时校验非空,需要配合 @NotBlank@NotEmpty
  • 对字符串,min/max 表示字符数量(不是字节数)
  • 对集合,min/max 表示元素个数

@Min 和 @Max

用于校验数值类型的大小范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class ProductDTO {
@Min(value = 0, message = "价格不能为负数")
@Max(value = 999999, message = "价格不能超过999999")
private BigDecimal price;

@Min(value = 1, message = "库存数量至少为1")
private Integer stock;

@Min(value = 18, message = "年龄必须年满18岁")
@Max(value = 120, message = "年龄不能超过120岁")
private Integer age;
}

适用类型byte, short, int, long 及其包装类,BigDecimal, BigInteger

13.3.3. 格式校验

@Pattern

使用正则表达式进行自定义格式校验,是最灵活但也最复杂的注解。

1
2
3
4
5
6
7
8
9
10
11
@Data
public class UserDTO {
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String mobile;

@Pattern(regexp = "^[a-zA-Z0-9_-]{4,16}$", message = "用户名只能包含字母、数字、下划线和连字符")
private String username;

@Pattern(regexp = "^\\d{6}$", message = "验证码必须是6位数字")
private String verifyCode;
}

常用正则表达式

校验场景正则表达式说明
中国手机号^1[3-9]\\d{9}$以 1 开头,第二位是 3-9,后面 9 位数字
身份证号^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$18 位身份证
邮政编码^\\d{6}$6 位数字
IP 地址^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$IPv4 地址
网址^https?://[\\w-]+(\\.[\\w-]+)+.*$http 或 https 开头的 URL

@Email

专门用于校验邮箱格式的注解,底层使用了复杂的正则表达式。

1
2
3
4
5
6
7
8
9
10
@Data
public class UserDTO {
@Email(message = "邮箱格式不正确")
private String email;

// 可以配合 regexp 自定义更严格的规则
@Email(regexp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$",
message = "邮箱格式不正确")
private String workEmail;
}

13.3.4. 日期时间校验

@Past 和 @Future

1
2
3
4
5
6
7
8
@Data
public class UserDTO {
@Past(message = "生日必须是过去的日期")
private LocalDate birthday;

@Future(message = "预约时间必须是未来的日期")
private LocalDateTime appointmentTime;
}

@PastOrPresent 和 @FutureOrPresent

1
2
3
4
5
6
7
8
@Data
public class OrderDTO {
@PastOrPresent(message = "订单创建时间不能晚于当前时间")
private LocalDateTime createTime;

@FutureOrPresent(message = "配送时间不能早于当前时间")
private LocalDateTime deliveryTime;
}

适用类型Date, Calendar, Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime, ZonedDateTime

13.3.5. 数值精度校验

@Digits

用于校验数值的整数位数和小数位数。

1
2
3
4
5
@Data
public class ProductDTO {
@Digits(integer = 6, fraction = 2, message = "价格格式不正确,整数部分最多6位,小数部分最多2位")
private BigDecimal price; // 允许:123456.78,不允许:1234567.78 或 123.456
}

@DecimalMin 和 @DecimalMax

类似于 @Min@Max,但支持小数和字符串表示的数值。

1
2
3
4
5
6
7
8
@Data
public class ProductDTO {
@DecimalMin(value = "0.01", message = "价格必须大于0")
private BigDecimal price;

@DecimalMax(value = "100.00", inclusive = false, message = "折扣率必须小于100%")
private BigDecimal discountRate; // inclusive = false 表示不包含边界值
}

13.3.6. 布尔值校验

@AssertTrue 和 @AssertFalse

1
2
3
4
5
6
7
8
9
10
11
@Data
public class UserRegisterDTO {
@NotBlank
private String username;

@NotBlank
private String password;

@AssertTrue(message = "必须同意用户协议才能注册")
private Boolean agreedToTerms; // 必须为 true 才能通过校验
}

13.4. 激活校验:@Valid vs @Validated 深度对比

在 Controller 中,我们需要在方法参数前添加触发校验的注解。这里有两个选择:@Valid@Validated。很多开发者对这两者的区别感到困惑,本节我们将彻底厘清它们的差异。

13.4.1. 来源与标准

@Valid

  • 来源:JSR-303 规范定义的标准注解,位于 jakarta.validation.Valid
  • 特点:是 Java 官方标准,可以在任何支持 JSR-303 的框架中使用
  • 功能:纯粹的校验触发器,不支持分组校验

@Validated

  • 来源:Spring 框架提供的注解,位于 org.springframework.validation.annotation.Validated
  • 特点:是 @Valid 的超集,增加了分组校验功能
  • 功能:支持分组校验,且可以用在类级别

13.4.2. 功能对比表

对比维度@Valid@Validated
定义者JSR-303 标准Spring 框架
所属包jakarta.validation.Validorg.springframework.validation.annotation.Validated
使用位置方法参数、字段、构造器参数方法参数、类级别
支持分组校验❌ 不支持✅ 支持
嵌套对象校验✅ 支持(需在嵌套字段上也加 @Valid)❌ 不支持(嵌套字段仍需 @Valid)
跨框架使用✅ 可用于任何支持 JSR-303 的框架❌ 仅限 Spring 环境

13.4.3. 使用场景建议

场景一:简单的单对象校验

两者均可,推荐使用 @Validated(功能更强大):

1
2
3
4
@PostMapping("/register")
public String register(@RequestBody @Validated UserRegisterDTO dto) {
return "注册成功";
}

场景二:需要分组校验

必须使用 @Validated

1
2
3
4
5
6
7
8
9
@PostMapping("/users")
public String createUser(@RequestBody @Validated(CreateGroup.class) UserDTO dto) {
// 只校验标记为 CreateGroup 的约束
}

@PutMapping("/users")
public String updateUser(@RequestBody @Validated(UpdateGroup.class) UserDTO dto) {
// 只校验标记为 UpdateGroup 的约束
}

场景三:嵌套对象校验

外层使用 @Validated,嵌套字段必须用 @Valid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class OrderDTO {
@NotNull
private Long orderId;

@Valid // 必须加 @Valid,否则 addressDTO 内部的校验不会生效
@NotNull
private AddressDTO addressDTO;
}

@PostMapping("/orders")
public String createOrder(@RequestBody @Validated OrderDTO dto) {
return "订单创建成功";
}

场景四:校验 URL 参数

必须在类级别使用 @Validated

1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/users")
@Validated // 必须加在类上
public class UserController {

@GetMapping("/{id}")
public String getUser(@PathVariable @Min(1) Long id) {
return "用户ID:" + id;
}
}

最佳实践总结

  • Controller 层统一使用 @Validated,因为它功能更全面
  • 嵌套对象字段使用 @Valid,这是启用嵌套校验的唯一方式
  • 如果项目可能迁移到非 Spring 框架,考虑使用 @Valid 保持标准化

13.5. 高级技巧一:分组校验解决场景冲突

在实际开发中,同一个 DTO 可能在不同场景下需要不同的校验规则。例如,用户信息的 DTO 在 “新增” 和 “更新” 两个场景下,对 id 字段的要求是矛盾的:

  • 新增时id 必须为 null(由数据库自动生成)
  • 更新时id 不能为 null(需要指定要更新的用户)

如果不使用分组校验,我们可能需要创建两个几乎相同的 DTO(UserCreateDTOUserUpdateDTO),这会导致大量重复代码。分组校验正是为了解决这个问题。

13.5.1. 定义分组标识接口

我们创建两个空接口来代表不同的校验场景。这些接口不需要任何方法,仅作为 “标记” 使用。

文件路径src/main/java/com/example/demo/validation/ValidationGroups.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.demo.validation;

import jakarta.validation.groups.Default;

/**
* 校验分组定义
*/
public interface ValidationGroups {

/**
* 新增操作分组
*/
interface Create {}

/**
* 更新操作分组
*/
interface Update {}

/**
* 查询操作分组
*/
interface Query {}

/**
* 新增操作分组(包含默认分组)
* 让未指定分组的约束也能生效
*/
interface CreateWithDefault extends Default, Create {}

/**
* 更新操作分组(包含默认分组)
*/
interface UpdateWithDefault extends Default, Update {}
}

设计说明

  1. CreateUpdate 是纯净的分组标记
  2. CreateWithDefaultUpdateWithDefault 继承了 Default 分组,这样可以让未指定分组的通用约束(如 @NotBlank(message = "用户名不能为空"))也能生效
  3. 这种设计既保证了灵活性,又避免了重复声明

13.5.2. 在 DTO 上应用分组

现在,我们为同一个 DTO 的不同字段指定不同的分组:

文件路径src/main/java/com/example/demo/dto/UserDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.demo.dto;

import com.example.demo.validation.ValidationGroups;
import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class UserDTO {

// id 字段:新增时必须为 null,更新时不能为 null
@Null(message = "新增用户时ID必须为空", groups = ValidationGroups.Create.class)
@NotNull(message = "更新用户时ID不能为空", groups = ValidationGroups.Update.class)
private Long id;

// username 字段:新增和更新时都不能为空(未指定分组,属于 Default 组)
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间")
private String username;

// password 字段:新增时必填,更新时选填(为 null 表示不修改密码)
@NotBlank(message = "新增用户时密码不能为空", groups = ValidationGroups.Create.class)
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$",
message = "密码必须8-16位,且包含大小写字母和数字",
groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}
)
private String password;

// email 字段:新增和更新时都不能为空
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
}

分组规则解读

字段新增时(Create)更新时(Update)
id必须为 null必须不为 null
username必须不为空,长度 4-20必须不为空,长度 4-20
password必须不为空,符合复杂度要求可以为 null(不修改),如果不为 null 则必须符合复杂度要求
email必须不为空,格式正确必须不为空,格式正确

13.5.3. 在 Controller 中按需激活分组

文件路径src/main/java/com/example/demo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.demo.controller;

import com.example.demo.dto.UserDTO;
import com.example.demo.validation.ValidationGroups;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

/**
* 新增用户接口
* 使用 CreateWithDefault 分组,会校验:
* 1. id 必须为 null
* 2. username 不能为空且长度 4-20(Default 组)
* 3. password 不能为空且符合复杂度
* 4. email 不能为空且格式正确(Default 组)
*/
@PostMapping
public String createUser(
@RequestBody @Validated(ValidationGroups.CreateWithDefault.class) UserDTO userDTO
) {
return "用户创建成功:" + userDTO.getUsername();
}

/**
* 更新用户接口
* 使用 UpdateWithDefault 分组,会校验:
* 1. id 不能为 null
* 2. username 不能为空且长度 4-20(Default 组)
* 3. password 如果不为 null,则必须符合复杂度
* 4. email 不能为空且格式正确(Default 组)
*/
@PutMapping
public String updateUser(
@RequestBody @Validated(ValidationGroups.UpdateWithDefault.class) UserDTO userDTO
) {
return "用户更新成功:" + userDTO.getUsername();
}
}

13.5.4. 测试分组校验效果

测试新增接口

请求 1(合法):

1
2
3
4
5
6
POST /users
{
"username": "testuser",
"password": "Test1234",
"email": "test@example.com"
}

✅ 通过校验,idnull 符合要求

请求 2(不合法):

1
2
3
4
5
6
7
POST /users
{
"id": 123,
"username": "testuser",
"password": "Test1234",
"email": "test@example.com"
}

❌ 校验失败,提示 “新增用户时 ID 必须为空”

测试更新接口

请求 1(合法):

1
2
3
4
5
6
PUT /users
{
"id": 123,
"username": "testuser",
"email": "newemail@example.com"
}

✅ 通过校验,passwordnull 表示不修改密码

请求 2(不合法):

1
2
3
4
5
6
PUT /users
{
"username": "testuser",
"password": "Test1234",
"email": "newemail@example.com"
}

❌ 校验失败,提示 “更新用户时 ID 不能为空”


13.6. 高级技巧二:自定义校验注解

当 JSR-303 提供的内置注解无法满足复杂的业务校验规则时(例如,校验中国手机号码格式、校验身份证号、校验银行卡号等),我们就需要自定义注解。

13.6.1. 自定义注解的工作原理

自定义校验注解需要三个组件协同工作:

mermaid-diagram (3)

13.6.2. 创建自定义注解 @IsMobile

我们以校验中国手机号为例,创建一个 @IsMobile 注解。

文件路径src/main/java/com/example/demo/validation/IsMobile.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.demo.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

/**
* 自定义手机号校验注解
*/
@Target({ElementType.FIELD, ElementType.PARAMETER}) // 可以用在字段和方法参数上
@Retention(RetentionPolicy.RUNTIME) // 运行时生效
@Constraint(validatedBy = IsMobileValidator.class) // 指定由哪个类来执行校验逻辑
@Documented
public @interface IsMobile {

/**
* 校验失败时的提示信息
* 可以使用占位符,如 "{com.example.validation.IsMobile.message}"
*/
String message() default "手机号码格式不正确";

/**
* 分组校验
*/
Class<?>[] groups() default {};

/**
* 负载信息(通常用于传递元数据,实际开发中很少用)
*/
Class<? extends Payload>[] payload() default {};
}

注解元素说明

元素是否必须作用
message定义校验失败时的错误提示信息
groups支持分组校验,JSR-303 规范要求必须提供
payload用于传递元数据,JSR-303 规范要求必须提供

13.6.3. 实现校验器类

文件路径src/main/java/com/example/demo/validation/IsMobileValidator.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.demo.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

/**
* 手机号校验器实现
* 泛型参数:<要校验的注解类型, 要校验的字段类型>
*/
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

/**
* 中国大陆手机号正则表达式
* 规则:1 开头 + 第二位是 3-9 + 后面 9 位数字
*/
private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

/**
* 初始化方法(可选)
* 如果注解有自定义属性,可以在这里获取
*/
@Override
public void initialize(IsMobile constraintAnnotation) {
// 可以在这里获取注解的属性值,进行预处理
}

/**
* 核心校验逻辑
* @param value 要校验的字段值
* @param context 校验上下文(通常不需要用到)
* @return true 表示校验通过,false 表示校验失败
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 重要:如果值为空,不进行校验,由 @NotBlank 等注解处理
// 这样做的好处是职责分离:@IsMobile 只负责格式校验,不负责非空校验
if (value == null || value.isEmpty()) {
return true;
}

// 执行正则表达式匹配
return MOBILE_PATTERN.matcher(value).matches();
}
}

设计要点

  1. isValid 方法对 null 值返回 true,这是最佳实践

    • 如果要求字段非空,应该额外加 @NotBlank 注解
    • 这样做遵循了 “单一职责原则”:@IsMobile 只负责格式校验
  2. 使用预编译的 Pattern 对象提升性能

    • 正则表达式编译是耗时操作,定义为 static final 可以避免重复编译

13.6.4. 在 DTO 中使用自定义注解

文件路径src/main/java/com/example/demo/dto/UserDTO.java

1
2
3
4
5
6
7
8
9
10
11
@Data
public class UserDTO {

@NotBlank(message = "手机号不能为空")
@IsMobile // 使用我们自定义的注解
private String mobile;

// 也可以自定义错误提示
@IsMobile(message = "请输入正确的中国大陆手机号")
private String contactPhone;
}

13.6.5. 测试自定义校验

1
2
3
4
POST /users
{
"mobile": "13800138000"
}

✅ 通过校验

1
2
3
4
POST /users
{
"mobile": "12345678901"
}

❌ 校验失败,提示 “手机号码格式不正确”(第二位不是 3-9)

1
2
3
4
POST /users
{
"mobile": "138001380"
}

❌ 校验失败,提示 “手机号码格式不正确”(长度不足 11 位)

13.6.6. 扩展:带参数的自定义注解

有时候,我们希望自定义注解能够接收参数。例如,创建一个通用的手机号校验注解,可以指定校验哪个国家/地区的手机号。

文件路径src/main/java/com/example/demo/validation/Mobile.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {

String message() default "手机号格式不正确";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

/**
* 自定义属性:国家/地区代码
*/
Region region() default Region.CHINA;

/**
* 地区枚举
*/
enum Region {
CHINA, // 中国大陆
HONG_KONG, // 中国香港
TAIWAN, // 中国台湾
USA // 美国
}
}

校验器实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MobileValidator implements ConstraintValidator<Mobile, String> {

private static final Map<Mobile.Region, Pattern> PATTERNS = new HashMap<>();

static {
PATTERNS.put(Mobile.Region.CHINA, Pattern.compile("^1[3-9]\\d{9}$"));
PATTERNS.put(Mobile.Region.HONG_KONG, Pattern.compile("^[5-9]\\d{7}$"));
PATTERNS.put(Mobile.Region.TAIWAN, Pattern.compile("^09\\d{8}$"));
PATTERNS.put(Mobile.Region.USA, Pattern.compile("^\\d{10}$"));
}

private Pattern pattern;

@Override
public void initialize(Mobile annotation) {
// 在初始化时获取注解的 region 属性,选择对应的正则
this.pattern = PATTERNS.get(annotation.region());
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true;
}
return pattern.matcher(value).matches();
}
}

使用方式

1
2
3
4
5
6
7
8
9
@Data
public class UserDTO {

@Mobile(region = Mobile.Region.CHINA, message = "请输入正确的中国大陆手机号")
private String mobile;

@Mobile(region = Mobile.Region.USA, message = "请输入正确的美国手机号")
private String usaMobile;
}

13.7. 高级技巧三:校验 URL 参数

前面我们学习的校验都是针对请求体(@RequestBody)中的 JSON 数据。但在 RESTful API 中,我们经常需要校验 URL 中的路径变量(@PathVariable)和查询参数(@RequestParam)。

13.7.1. URL 参数校验的特殊性

对 URL 参数的校验与 @RequestBody 有本质区别:

对比项@RequestBody 校验URL 参数校验
校验对象DTO 对象的字段方法的单个参数
触发方式在参数前加 @Valid@Validated在类级别加 @Validated,参数前加约束注解
失败异常MethodArgumentNotValidExceptionConstraintViolationException

13.7.2. 第一步:在 Controller 类上添加 @Validated

文件路径src/main/java/com/example/demo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.demo.controller;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Email;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
@Validated // 必须加在类级别,否则方法参数上的约束注解不会生效
public class UserController {

// 接口方法...
}

重要提示:如果忘记在类上加 @Validated,方法参数上的校验注解会完全失效,且不会有任何编译错误或警告!

13.7.3. 校验路径变量(@PathVariable)

1
2
3
4
5
6
@GetMapping("/{id}")
public String getUserById(
@PathVariable @Min(value = 1, message = "用户ID必须为正数") Long id
) {
return "查询用户ID:" + id;
}

测试

1
GET /users/1

✅ 通过校验

1
GET /users/0

❌ 校验失败,提示 “用户 ID 必须为正数”

1
GET /users/-5

❌ 校验失败,提示 “用户 ID 必须为正数”

13.7.4. 校验查询参数(@RequestParam)

1
2
3
4
5
6
7
@GetMapping("/search")
public String searchUsers(
@RequestParam @NotBlank(message = "用户名不能为空") String username,
@RequestParam(required = false) @Min(value = 1, message = "页码必须大于0") Integer page
) {
return "搜索用户:" + username + ",页码:" + page;
}

测试

1
GET /users/search?username=test&page=1

✅ 通过校验

1
GET /users/search?username=&page=1

❌ 校验失败,提示 “用户名不能为空”

1
GET /users/search?username=test&page=0

❌ 校验失败,提示 “页码必须大于 0”

13.7.5. 组合使用多个约束

可以在同一个参数上叠加多个约束注解:

1
2
3
4
5
6
7
8
9
@GetMapping("/by-email")
public String getUserByEmail(
@RequestParam
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
String email
) {
return "查询邮箱:" + email;
}

测试

1
GET /users/by-email?email=test@example.com

✅ 通过校验

1
GET /users/by-email?email=invalid-email

❌ 校验失败,提示 “邮箱格式不正确”

13.7.6. 校验失败的默认响应

当 URL 参数校验失败时,Spring 会抛出 ConstraintViolationException 异常。默认的错误响应格式如下:

1
2
3
4
5
6
7
{
"timestamp": "2025-12-17T09:31:46.123+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "getUserById.id: 用户ID必须为正数",
"path": "/users/0"
}

问题

  1. HTTP 状态码是 500(服务器内部错误),但这其实是客户端参数错误,应该是 400
  2. message 字段包含了方法名 getUserById.id,这是实现细节,不应该暴露给客户端
  3. 格式与我们期望的统一响应结构不一致

解决方案

在下一章(Note 14)中,我们将学习如何通过全局异常处理器来捕获 ConstraintViolationException,并将其转换为统一的、对前端友好的错误响应格式。


13.8. 高级技巧四:嵌套对象校验与集合元素校验

在实际项目中,DTO 往往不是扁平结构,而是包含嵌套对象或集合。例如,订单 DTO 中包含收货地址对象,用户 DTO 中包含标签列表。本节我们将学习如何对这些复杂结构进行校验。

13.8.1. 嵌套对象校验

场景:订单提交时,需要同时校验订单信息和收货地址信息。

第一步:定义嵌套的 DTO

文件路径src/main/java/com/example/demo/dto/AddressDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

@Data
public class AddressDTO {

@NotBlank(message = "收货人姓名不能为空")
private String receiverName;

@NotBlank(message = "收货人电话不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String receiverPhone;

@NotBlank(message = "省份不能为空")
private String province;

@NotBlank(message = "城市不能为空")
private String city;

@NotBlank(message = "详细地址不能为空")
private String detailAddress;
}

第二步:在外层 DTO 中引用,并添加 @Valid

文件路径src/main/java/com/example/demo/dto/OrderDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;

@Data
public class OrderDTO {

@NotNull(message = "商品ID不能为空")
private Long productId;

@Positive(message = "购买数量必须大于0")
private Integer quantity;

/**
* 关键点:必须加 @Valid 注解,否则 AddressDTO 内部的校验不会生效
*/
@Valid
@NotNull(message = "收货地址不能为空")
private AddressDTO address;
}

第三步:Controller 中使用

1
2
3
4
@PostMapping("/orders")
public String createOrder(@RequestBody @Validated OrderDTO orderDTO) {
return "订单创建成功";
}

测试请求

1
2
3
4
5
6
7
8
9
10
11
12
POST /orders
{
"productId": 100,
"quantity": 2,
"address": {
"receiverName": "张三",
"receiverPhone": "13800138000",
"province": "广东省",
"city": "深圳市",
"detailAddress": "南山区科技园"
}
}

✅ 通过校验

1
2
3
4
5
6
7
8
9
10
11
12
POST /orders
{
"productId": 100,
"quantity": 2,
"address": {
"receiverName": "",
"receiverPhone": "138001380",
"province": "广东省",
"city": "深圳市",
"detailAddress": ""
}
}

❌ 校验失败,提示:

  • “收货人姓名不能为空”
  • “手机号格式不正确”
  • “详细地址不能为空”

关键要点

  1. 嵌套对象字段上必须添加 @Valid 注解,这是启用嵌套校验的唯一方式
  2. @Valid 会递归校验对象内部的所有字段
  3. 如果嵌套层级很深(如三层、四层),每一层都需要加 @Valid

13.8.2. 集合元素校验

场景:用户注册时可以选择多个兴趣标签,需要确保标签列表不为空,且每个标签内容都不能为空。

文件路径src/main/java/com/example/demo/dto/UserRegisterDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.demo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;
import java.util.List;

@Data
public class UserRegisterDTO {

@NotBlank(message = "用户名不能为空")
private String username;

@NotBlank(message = "密码不能为空")
private String password;

/**
* 集合元素校验的两个关键点:
* 1. 集合本身不能为空:@NotEmpty
* 2. 集合内的每个元素不能为空:在泛型上加 @NotBlank
*/
@NotEmpty(message = "至少需要选择一个兴趣标签")
private List<@NotBlank(message = "标签内容不能为空") String> tags;
}

测试请求

1
2
3
4
5
6
POST /users/register
{
"username": "testuser",
"password": "Test1234",
"tags": ["编程", "音乐", "旅游"]
}

✅ 通过校验

1
2
3
4
5
6
POST /users/register
{
"username": "testuser",
"password": "Test1234",
"tags": []
}

❌ 校验失败,提示 “至少需要选择一个兴趣标签”

1
2
3
4
5
6
POST /users/register
{
"username": "testuser",
"password": "Test1234",
"tags": ["编程", "", "旅游"]
}

❌ 校验失败,提示 “标签内容不能为空”

13.8.3. 复杂对象集合校验

如果集合中的元素是对象而非基本类型,需要在泛型上加 @Valid

文件路径src/main/java/com/example/demo/dto/OrderDTO.java

1
2
3
4
5
6
@Data
public class OrderDTO {

@NotEmpty(message = "订单项不能为空")
private List<@Valid OrderItemDTO> items; // @Valid 加在泛型上
}

文件路径src/main/java/com/example/demo/dto/OrderItemDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class OrderItemDTO {

@NotNull(message = "商品ID不能为空")
private Long productId;

@Positive(message = "购买数量必须大于0")
private Integer quantity;

@Positive(message = "商品单价必须大于0")
private BigDecimal price;
}

测试请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /orders
{
"items": [
{
"productId": 100,
"quantity": 2,
"price": 99.99
},
{
"productId": 200,
"quantity": 0,
"price": 199.99
}
]
}

❌ 校验失败,提示 “购买数量必须大于 0”(第二个订单项的 quantity 为 0)


13.9. 本章总结与参数校验速查

13.9.1. 摘要回顾

本章我们系统学习了 Spring Boot 的声明式参数校验体系。我们从传统的 if-else 校验地狱出发,理解了 JSR-303 规范的设计理念,掌握了从基础的字段约束注解到高级的分组校验、自定义注解、嵌套对象校验等全套技能。通过本章学习,你已经可以构建起一道坚固的 “输入防火墙”,将绝大部分不合法的数据拦截在 Controller 层,让 Service 层专注于业务逻辑。

核心收获

  1. 理解了声明式校验的优势:职责分离、可复用、可维护
  2. 掌握了 20+ 种常用校验注解的用法和适用场景
  3. 深度理解了 @Valid@Validated 的区别
  4. 学会了通过分组校验优雅地处理同一 DTO 在不同场景下的规则冲突
  5. 具备了创建自定义校验注解的能力,可以封装复杂的业务规则
  6. 掌握了对 URL 参数、嵌套对象、集合元素的校验技巧

当前阶段的不足

我们发现,虽然校验注解能够成功拦截非法数据,但 Spring 默认的错误响应格式并不友好,缺乏统一性和可读性。在下一章(Note 14)中,我们将学习全局异常处理机制,构建一套统一的、对前端极度友好的错误响应体系,让参数校验的错误信息能够精确地反馈到对应的表单字段。

13.9.2. 场景化速查:

遇到以下 6 种参数校验场景时,请直接 Copy 下方的标准代码模版

场景一:基础字段校验(字符串、数值、邮箱)

需求:校验用户注册表单,包括用户名、密码、邮箱、年龄。

方案:使用内置注解组合。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Data
public class UserRegisterDTO {

@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间")
private String username;

@NotBlank(message = "密码不能为空")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$",
message = "密码必须8-16位,且包含大小写字母和数字"
)
private String password;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

@NotNull(message = "年龄不能为空")
@Min(value = 18, message = "必须年满18岁")
@Max(value = 120, message = "年龄不能超过120岁")
private Integer age;
}

// Controller
@PostMapping("/register")
public String register(@RequestBody @Validated UserRegisterDTO dto) {
return "注册成功";
}

场景二:分组校验(新增和更新使用同一 DTO)

需求:用户 DTO 在新增时 ID 必须为空,更新时 ID 必须不为空,密码在新增时必填,更新时选填。

方案:定义 Create 和 Update 分组接口,在注解上指定 groups 属性。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 1. 定义分组接口
public interface ValidationGroups {
interface Create {}
interface Update {}
interface CreateWithDefault extends Default, Create {}
interface UpdateWithDefault extends Default, Update {}
}

// 2. DTO 中应用分组
@Data
public class UserDTO {

@Null(message = "新增时ID必须为空", groups = ValidationGroups.Create.class)
@NotNull(message = "更新时ID不能为空", groups = ValidationGroups.Update.class)
private Long id;

@NotBlank(message = "用户名不能为空") // 未指定分组,属于 Default 组
private String username;

@NotBlank(message = "新增时密码不能为空", groups = ValidationGroups.Create.class)
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$",
message = "密码必须8-16位,且包含大小写字母和数字",
groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}
)
private String password;
}

// 3. Controller 中按需激活分组
@PostMapping("/users")
public String create(@RequestBody @Validated(ValidationGroups.CreateWithDefault.class) UserDTO dto) {
return "创建成功";
}

@PutMapping("/users")
public String update(@RequestBody @Validated(ValidationGroups.UpdateWithDefault.class) UserDTO dto) {
return "更新成功";
}

场景三:自定义校验注解(手机号格式)

需求:校验中国大陆手机号格式。

方案:创建 @IsMobile 注解和对应的 Validator 实现类。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 1. 定义注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IsMobileValidator.class)
public @interface IsMobile {
String message() default "手机号码格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

// 2. 实现校验器
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 非空校验由 @NotBlank 负责
}
return MOBILE_PATTERN.matcher(value).matches();
}
}

// 3. 使用
@Data
public class UserDTO {

@NotBlank(message = "手机号不能为空")
@IsMobile
private String mobile;
}

场景四:校验 URL 路径变量和查询参数

需求:校验 GET /users/{id} 中的 id 必须大于 0,GET /users/search?username=xxx 中的 username 不能为空。

方案:在 Controller 类上添加 @Validated,在方法参数上添加约束注解。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("/users")
@Validated // 必须加在类级别
public class UserController {

@GetMapping("/{id}")
public String getUser(
@PathVariable @Min(value = 1, message = "用户ID必须为正数") Long id
) {
return "用户ID:" + id;
}

@GetMapping("/search")
public String search(
@RequestParam @NotBlank(message = "用户名不能为空") String username
) {
return "搜索用户:" + username;
}
}

场景五:嵌套对象校验(订单包含收货地址)

需求:提交订单时,需要同时校验订单信息和收货地址信息。

方案:在嵌套对象字段上添加 @Valid 注解。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 定义嵌套的 AddressDTO
@Data
public class AddressDTO {
@NotBlank(message = "收货人姓名不能为空")
private String receiverName;

@NotBlank(message = "收货人电话不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String receiverPhone;

@NotBlank(message = "详细地址不能为空")
private String detailAddress;
}

// 2. 在外层 DTO 中使用,必须加 @Valid
@Data
public class OrderDTO {
@NotNull(message = "商品ID不能为空")
private Long productId;

@Valid // 关键:必须加 @Valid,否则 AddressDTO 内部的校验不会生效
@NotNull(message = "收货地址不能为空")
private AddressDTO address;
}

// 3. Controller
@PostMapping("/orders")
public String createOrder(@RequestBody @Validated OrderDTO dto) {
return "订单创建成功";
}

场景六:集合元素校验(标签列表)

需求:用户注册时可以选择多个兴趣标签,确保标签列表不为空,且每个标签内容都不能为空。

方案:在集合字段上使用 @NotEmpty,在泛型上添加约束注解。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
public class UserRegisterDTO {

@NotBlank(message = "用户名不能为空")
private String username;

// 集合元素校验的关键:在泛型上加约束注解
@NotEmpty(message = "至少需要选择一个兴趣标签")
private List<@NotBlank(message = "标签内容不能为空") String> tags;
}

// 如果集合元素是对象,需要在泛型上加 @Valid
@Data
public class OrderDTO {
@NotEmpty(message = "订单项不能为空")
private List<@Valid OrderItemDTO> items;
}

@Data
public class OrderItemDTO {
@NotNull(message = "商品ID不能为空")
private Long productId;

@Positive(message = "购买数量必须大于0")
private Integer quantity;
}

13.9.3. 常用校验注解速查表

注解适用类型核心约束常见用途
@NotNull任何类型值不能为 null数值、日期、枚举等非字符串字段
@NotEmptyString, Collection, Map, Array不能为 null 且 size/length > 0集合、数组
@NotBlankString不能为 null 且去除空格后 length > 0几乎所有字符串字段(首选)
@Size(min, max)String, Collection, Map, Array长度或大小在 min-max 之间用户名长度、标签数量限制
@Min(value)数值类型值 >= value年龄、库存、价格下限
@Max(value)数值类型值 <= value年龄上限、数量上限
@DecimalMin(value)BigDecimal, String值 >= value(支持小数)价格、金额下限
@DecimalMax(value)BigDecimal, String值 <= value(支持小数)折扣率上限
@Digits(integer, fraction)BigDecimal, String整数部分最多 integer 位,小数部分最多 fraction 位价格精度控制
@Positive数值类型值 > 0商品数量、金额
@PositiveOrZero数值类型值 >= 0库存(允许为 0)
@Negative数值类型值 < 0退款金额(负数)
@NegativeOrZero数值类型值 <= 0-
@Pattern(regexp)String匹配正则表达式手机号、身份证号、邮政编码
@EmailString符合邮箱格式邮箱地址
@Past日期时间类型必须是过去的日期生日、入职日期
@PastOrPresent日期时间类型过去或当前日期订单创建时间
@Future日期时间类型必须是未来的日期预约时间、到期时间
@FutureOrPresent日期时间类型未来或当前日期配送时间
@AssertTrueBoolean必须为 true用户协议勾选
@AssertFalseBoolean必须为 false-
@Null任何类型必须为 null新增时 ID 必须为空
@Valid对象、集合元素触发嵌套校验订单中的地址对象、订单项列表

13.9.4. 核心避坑指南

避坑一:校验完全不生效

现象:明明在 DTO 字段上加了 @NotBlank,但发送空字符串的请求仍然能通过。

原因:Controller 方法参数前忘记加 @Validated@Valid 注解。

对策:形成条件反射,只要参数是需要校验的 DTO,其前面必须紧跟 @Validated

1
2
3
4
5
6
7
// ❌ 错误:校验不会生效
@PostMapping("/users")
public String create(@RequestBody UserDTO dto) { ... }

// ✅ 正确
@PostMapping("/users")
public String create(@RequestBody @Validated UserDTO dto) { ... }

避坑二:嵌套对象的校验失效

现象OrderDTO 中包含 AddressDTOAddressDTO 的字段上有校验注解,但实际并未校验。

原因:在 OrderDTO 中声明 AddressDTO 字段时,没有在该字段上添加 @Valid 注解。

对策:在任何需要进行级联校验的复杂类型字段(对象、List 等)上,必须添加 @Valid 注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 错误:AddressDTO 内部的校验不会生效
@Data
public class OrderDTO {
@NotNull
private AddressDTO address;
}

// ✅ 正确
@Data
public class OrderDTO {
@Valid // 必须加 @Valid
@NotNull
private AddressDTO address;
}

避坑三:@NotNull、@NotEmpty、@NotBlank 用错

现象:用 @NotNull 校验字符串,结果空字符串 "" 能通过校验。

原因@NotNull 只能校验值不为 null,对空字符串无效。

对策

  • 字符串字段优先使用 @NotBlank(能同时拦截 null""" "
  • 集合字段使用 @NotEmpty(能同时拦截 null 和空集合 []
  • 数值、日期、枚举等非字符串字段使用 @NotNull
1
2
3
4
5
6
7
// ❌ 错误:空字符串能通过
@NotNull
private String username;

// ✅ 正确
@NotBlank
private String username;

避坑四:URL 参数校验失败返回 500 错误

现象:校验 @PathVariable 失败后,返回了 HTTP 500 错误。

原因

  1. 忘记在 Controller 类上添加 @Validated 注解,导致校验失败时抛出的是其他异常
  2. URL 参数校验失败抛出的是 ConstraintViolationException,而不是 MethodArgumentNotValidException

对策

  1. 检查 Controller 类上是否有 @Validated 注解
  2. 在下一章的全局异常处理器中,同时处理 ConstraintViolationExceptionMethodArgumentNotValidException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 错误:忘记在类上加 @Validated
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public String getUser(@PathVariable @Min(1) Long id) { ... }
}

// ✅ 正确
@RestController
@RequestMapping("/users")
@Validated // 必须加
public class UserController {
@GetMapping("/{id}")
public String getUser(@PathVariable @Min(1) Long id) { ... }
}

避坑五:正则表达式转义问题

现象@Pattern 的正则表达式在 Java 代码中总是报错或不生效。

原因:Java 字符串中的 \ 需要转义为 \\,正则表达式中的 \d 在 Java 字符串中要写成 \\d

对策:记住常用的转义规则,或使用在线正则工具(如 regex101.com)测试后再复制到代码中。

1
2
3
4
5
6
7
// ❌ 错误:\d 没有转义
@Pattern(regexp = "^\d{6}$")
private String code;

// ✅ 正确
@Pattern(regexp = "^\\d{6}$")
private String code;

避坑六:分组校验时忘记继承 Default

现象:使用分组校验后,未指定分组的通用约束(如 @NotBlank(message = "用户名不能为空"))不再生效。

原因:当在 @Validated 中指定了分组后,只有标记为该分组的约束会生效,未标记分组的约束(属于 Default 组)会被忽略。

对策:定义分组接口时,让它继承 Default 接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 错误:使用 Create 分组后,username 的校验不会生效
public interface ValidationGroups {
interface Create {}
}

@Data
public class UserDTO {
@NotBlank // 属于 Default 组
private String username;

@NotBlank(groups = ValidationGroups.Create.class)
private String password;
}

// Controller
@PostMapping
public String create(@RequestBody @Validated(ValidationGroups.Create.class) UserDTO dto) { ... }

// ✅ 正确:让分组继承 Default
public interface ValidationGroups {
interface Create extends Default {}
}

下一步预告

在本章中,我们已经成功构建了输入数据的第一道防线——参数校验。但我们也发现,当校验失败时,Spring 默认返回的错误响应格式并不统一,也不够友好。在下一章(Note 14)中,我们将学习 全局异常处理机制,通过 @RestControllerAdvice 统一捕获校验异常、业务异常、系统异常,并将它们转换为规范的、对前端极度友好的 JSON 响应。我们还将设计统一的 Result<T> 响应体,建立前后端之间清晰的 “通信契约”。