Note 13. 防御性编程:JSR-303 参数校验最佳实践 摘要 :“永远不要信任来自客户端的任何数据”——这是构建健壮系统的第一信条。任何未经校验的输入,都是潜在的安全漏洞和运行时炸弹。本章,我们将深入 Spring Boot 强大的声明式参数校验体系。我们将从引入 validation 依赖开始,系统学习 JSR-303 规范下的常用注解,并重点攻克 分组校验 、自定义校验注解 、嵌套对象校验 等高级技巧。通过本章学习,你将彻底告别 Service 层的 if-else 地狱,让参数校验逻辑以声明式的方式优雅地附着在数据模型之上。
本章学习路径
理念奠基 :理解 “快速失败” 原则,明确 JSR-303 规范与 Hibernate Validator 的实现关系,并完成环境搭建。入门实战 :从零创建第一个带校验的 DTO,体验声明式校验的魅力,观察校验失败时 Spring 的默认行为。注解武器库 :系统学习 @NotBlank、@Pattern、@Size、@Email 等常用注解,并通过对比表格掌握它们的细微差异。激活机制 :深度辨析 @Valid 与 @Validated 的区别,理解何时使用哪一个。高级技巧 :掌握分组校验、自定义注解、URL 参数校验、嵌套对象校验等企业级必备技能。实战总结 :通过场景化代码速查表和避坑指南,建立完整的参数校验知识体系。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 ("邮箱格式不正确" ); } 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 规范与实现的关系 :
核心设计哲学 :
关注点分离 :校验逻辑从业务代码中剥离,附着在数据模型上声明式编程 :通过注解声明约束,而非命令式的 if 判断可复用性 :同一个 DTO 可以在多个场景中复用,校验规则自动生效标准化 :遵循 JSR-303 规范,可以在不同框架间无缝迁移13.1.3. 引入依赖 自 Spring Boot 2.3 版本起,validation 不再是 web starter 的默认依赖,需要我们手动引入。
文件路径 :pom.xml
1 2 3 4 5 <dependency > <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(); } }
关键点 :
@Valid 注解是触发校验的开关,没有它,DTO 上的校验注解不会生效校验失败时,Spring 会自动抛出异常,阻止方法继续执行 只有校验全部通过,方法体内的代码才会执行 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" }
这个默认响应有以下问题:
错误信息不够具体,没有告诉前端具体哪个字段校验失败 格式不统一,与我们自定义的业务响应结构不一致 包含了一些技术细节(如对象名称),不适合直接展示给用户 本节核心要点 :
我们成功让 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; @NotNull(message = "订单状态不能为空") private OrderStatus status; }
@NotEmpty :
约束 :值不能为 null,且 size/length 必须大于 0允许 :只包含空格的字符串 " "(因为长度 > 0)适用场景 :字符串、集合、数组,当你关心 “是否有内容” 但不在乎空格时1 2 3 4 5 6 7 8 @Data public class UserDTO { @NotEmpty(message = "用户标签不能为空") private List<String> tags; @NotEmpty(message = "备注不能为空") private String remark; }
@NotBlank :
约束 :值不能为 null,且去除首尾空格后长度必须大于 0仅适用于 :String 类型适用场景 :几乎所有的字符串字段(用户名、密码、邮箱等),这是字符串校验的首选注解 1 2 3 4 5 @Data public class UserDTO { @NotBlank(message = "用户名不能为空") private String username; }
最佳实践 :
对于字符串字段,优先使用 @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; }
注意事项 :
@Size 对 null 值不校验,如果要同时校验非空,需要配合 @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; @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; }
@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; }
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; }
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) { } @PutMapping("/users") public String updateUser (@RequestBody @Validated(UpdateGroup.class) UserDTO dto) { }
场景三:嵌套对象校验
外层使用 @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 @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(UserCreateDTO 和 UserUpdateDTO),这会导致大量重复代码。分组校验正是为了解决这个问题。
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 {} }
设计说明 :
Create 和 Update 是纯净的分组标记CreateWithDefault 和 UpdateWithDefault 继承了 Default 分组,这样可以让未指定分组的通用约束(如 @NotBlank(message = "用户名不能为空"))也能生效这种设计既保证了灵活性,又避免了重复声明 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 { @Null(message = "新增用户时ID必须为空", groups = ValidationGroups.Create.class) @NotNull(message = "更新用户时ID不能为空", groups = ValidationGroups.Update.class) private Long id; @NotBlank(message = "用户名不能为空") @Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间") 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; @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 { @PostMapping public String createUser ( @RequestBody @Validated(ValidationGroups.CreateWithDefault.class) UserDTO userDTO ) { return "用户创建成功:" + userDTO.getUsername(); } @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" }
✅ 通过校验,id 为 null 符合要求
请求 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" }
✅ 通过校验,password 为 null 表示不修改密码
请求 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. 自定义注解的工作原理 自定义校验注解需要三个组件协同工作:
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 { 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> { private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$" ); @Override public void initialize (IsMobile constraintAnnotation) { } @Override public boolean isValid (String value, ConstraintValidatorContext context) { if (value == null || value.isEmpty()) { return true ; } return MOBILE_PATTERN.matcher(value).matches(); } }
设计要点 :
isValid 方法对 null 值返回 true,这是最佳实践
如果要求字段非空,应该额外加 @NotBlank 注解 这样做遵循了 “单一职责原则”:@IsMobile 只负责格式校验 使用预编译的 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) { 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; }
测试 :
✅ 通过校验
❌ 校验失败,提示 “用户 ID 必须为正数”
❌ 校验失败,提示 “用户 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" }
问题 :
HTTP 状态码是 500(服务器内部错误),但这其实是客户端参数错误,应该是 400 message 字段包含了方法名 getUserById.id,这是实现细节,不应该暴露给客户端格式与我们期望的统一响应结构不一致 解决方案 :
在下一章(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 @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" : "" } }
❌ 校验失败,提示:
“收货人姓名不能为空” “手机号格式不正确” “详细地址不能为空” 关键要点 :
嵌套对象字段上必须添加 @Valid 注解,这是启用嵌套校验的唯一方式 @Valid 会递归校验对象内部的所有字段如果嵌套层级很深(如三层、四层),每一层都需要加 @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; @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; }
文件路径 :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 层专注于业务逻辑。
核心收获 :
理解了声明式校验的优势:职责分离、可复用、可维护 掌握了 20+ 种常用校验注解的用法和适用场景 深度理解了 @Valid 与 @Validated 的区别 学会了通过分组校验优雅地处理同一 DTO 在不同场景下的规则冲突 具备了创建自定义校验注解的能力,可以封装复杂的业务规则 掌握了对 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; } @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 public interface ValidationGroups { interface Create {} interface Update {} interface CreateWithDefault extends Default , Create {} interface UpdateWithDefault extends Default , Update {} } @Data public class UserDTO { @Null(message = "新增时ID必须为空", groups = ValidationGroups.Create.class) @NotNull(message = "更新时ID不能为空", groups = ValidationGroups.Update.class) private Long id; @NotBlank(message = "用户名不能为空") 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; } @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 @Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = IsMobileValidator.class) public @interface IsMobile { String message () default "手机号码格式不正确" ; Class<?>[] groups() default {}; Class<? extends Payload >[] payload() default {}; } 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 ; } return MOBILE_PATTERN.matcher(value).matches(); } } @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 @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; } @Data public class OrderDTO { @NotNull(message = "商品ID不能为空") private Long productId; @Valid @NotNull(message = "收货地址不能为空") private AddressDTO address; } @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; } @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 中包含 AddressDTO,AddressDTO 的字段上有校验注解,但实际并未校验。
原因 :在 OrderDTO 中声明 AddressDTO 字段时,没有在该字段上添加 @Valid 注解。
对策 :在任何需要进行级联校验的复杂类型字段(对象、List 等)上,必须添加 @Valid 注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Data public class OrderDTO { @NotNull private AddressDTO address; } @Data public class OrderDTO { @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 错误。
原因 :
忘记在 Controller 类上添加 @Validated 注解,导致校验失败时抛出的是其他异常 URL 参数校验失败抛出的是 ConstraintViolationException,而不是 MethodArgumentNotValidException 对策 :
检查 Controller 类上是否有 @Validated 注解 在下一章的全局异常处理器中,同时处理 ConstraintViolationException 和 MethodArgumentNotValidException 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @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 @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 public interface ValidationGroups { interface Create {} } @Data public class UserDTO { @NotBlank private String username; @NotBlank(groups = ValidationGroups.Create.class) private String password; } @PostMapping public String create (@RequestBody @Validated(ValidationGroups.Create.class) UserDTO dto) { ... }public interface ValidationGroups { interface Create extends Default {} }
下一步预告 :
在本章中,我们已经成功构建了输入数据的第一道防线——参数校验。但我们也发现,当校验失败时,Spring 默认返回的错误响应格式并不统一,也不够友好。在下一章(Note 14)中,我们将学习 全局异常处理机制 ,通过 @RestControllerAdvice 统一捕获校验异常、业务异常、系统异常,并将它们转换为规范的、对前端极度友好的 JSON 响应。我们还将设计统一的 Result<T> 响应体,建立前后端之间清晰的 “通信契约”。