你以为 Bean Validation 3.0 只会加几个注解就完事了?那为啥你项目里校验总是“该炸不炸、不该炸乱炸”?
🏆本文收录于《滚雪球学SpringBoot 3》:
https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】https://blog.csdn.net/weixin_43970743/article/details/151115907,你想学习的都被收集在内,快速投入学习!!两不误。
若还想学习更多,可直接前往《滚雪球学SpringBoot(全版本合集)》:https://blog.csdn.net/weixin_43970743/category_11599389.html,涵盖SpringBoot所有版本教学文章。
演示环境说明:
- 开发工具:IDEA 2021.3
- JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
- Spring Boot版本:3.5.4(于25年7月24日发布)
- Maven版本:3.8.2 (或更高)
- Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
- 操作系统:Windows 11
先问你一句(不耽误你继续看):你现在用的是 Spring Boot 3.x(jakarta.*) 还是老项目 Boot 2.x(javax.*)?
我下面会按 Boot 3 / Spring 6+ / Jakarta Bean Validation 3.0 来写(因为 BV 3.0 已经整体迁到jakarta.*命名空间了)。
前言:别笑,你的“参数校验”可能一直在演你 😅
我见过太多后端同学把校验当成“贴膏药”——Controller 入参上糊一个 @Valid,然后心里默念:“从此世界和平。”
结果线上还是各种抽象:
- 嵌套对象里字段明明空的,怎么没报错?
- 更新接口明明允许部分字段不传,结果 @NotBlank 把你按在地上摩擦
- 手机号校验写了个
@Pattern,到处复制粘贴,最后每个地方还不一样…… - 最绝的是:异常一抛,前端拿到一坨看不懂的字段路径,骂你“返回像绕口令”🙃
今天我们就按你给的大纲,把 Bean Validation 3.0(Jakarta Validation 3.0) 的几个“高级但高频翻车点”一次捋顺:
- 嵌套校验(
@Validvs@Validated) - 分组校验(新增/更新不同规则)
- 自定义校验注解(手机号为例)
@ControllerAdvice全局接住MethodArgumentNotValidException
0. 先把“地基”说清楚:BV 3.0 到底变了啥?
Bean Validation 3.0 本质上是 Jakarta EE 9 时代的版本:最大的变化就是包名从 javax.* 迁到了 jakarta.*,并且声明“没有破坏性变更”。
所以你在 Spring Boot 3 里写校验,大概率是:
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
而不是:
import javax.validation.Valid;
1. 嵌套校验:@Valid vs @Validated,别把俩当“同义词”用
1.1 你真正需要记住的一句话
“嵌套对象要不要继续往下校验,决定权在 @Valid。”
Bean Validation 规范里明确:@Valid 用来声明 级联校验(cascaded validation),并且是递归的、会避免无限循环。
也就是说:父对象校验通过 ≠ 子对象就安全了。
你得显式告诉校验器:“这个字段是个对象,请继续校验它里面的约束。”
1.2 一个最容易踩坑的例子(不夸张,十个项目八个踩)
DTO:用户创建入参(带地址)
public class CreateUserReq {
@NotBlank(message = "用户名不能为空")
private String username;
// ✅ 关键:没有 @Valid,下面 Address 里的约束基本等于摆设
@Valid
@NotNull(message = "地址不能为空")
private Address address;
// getter/setter ...
}
public class Address {
@NotBlank(message = "省不能为空")
private String province;
@NotBlank(message = "市不能为空")
private String city;
// getter/setter ...
}
Controller:只写 @Valid 的版本
@PostMapping("/users")
public String create(@Valid @RequestBody CreateUserReq req) {
return "ok";
}
✅ 这会触发:
CreateUserReq.username的校验CreateUserReq.address非空校验- 并且由于 address 上有
@Valid,会继续校验 Address.province/city(递归级联)
❌ 如果你把 CreateUserReq.address 上的 @Valid 去掉:
- 只会检查 address 是否为 null
- Address 内部字段不会被验证
- 然后你就会看到线上出现 “city 为空也能创建成功”的玄学场景(其实一点也不玄,是你没告诉它往下校验😅)
1.3 那 @Validated 是干啥的?它不是“更高级的 @Valid”
@Validated 是 Spring 提供的注解(不是 BV 规范本身),它的核心价值是:指定校验分组。Spring 的 @Validated javadoc 写得很直白:它用于指定一个或多个 validation groups。
Spring Framework 参考文档也强调:要让 Spring 驱动的方法校验生效,目标类通常要标 @Validated,并且可以顺带声明 groups。
简单粗暴总结:
@Valid:负责“级联/嵌套校验”(让对象图继续往下验)@Validated:负责“分组/方法级校验触发”(Spring 语境里更常用)
组合用法(常见于 Controller 分组校验 + 嵌套继续校验)
@PostMapping("/users")
public String create(@Validated(Create.class) @RequestBody @Valid CreateUserReq req) {
return "ok";
}
看着像“叠 buff”,但逻辑很清楚:
@Validated(Create.class):告诉 Spring 这次按 Create 组规则跑@Valid:告诉 BV 对象图要级联(address 继续往下验)
2. 分组校验(Validation Groups):新增和更新,规则不一样才正常
2.1 痛点:同一个 DTO,在“创建”和“更新”时约束不同
你如果不用分组,常见结局是:
- 创建必须传 password
- 更新允许不传 password
- 结果你为了“让更新通过”,把
@NotBlank删了 - 然后创建接口就开始裸奔……
这不是你不努力,是你没把规则表达清楚。
2.2 定义分组接口(最朴素、最好用)
public interface Create {}
public interface Update {}
2.3 在约束上挂组
public class UserReq {
@NotNull(groups = Update.class, message = "更新时 id 不能为空")
private Long id;
@NotBlank(groups = {Create.class, Update.class}, message = "用户名不能为空")
private String username;
@NotBlank(groups = Create.class, message = "创建时密码不能为空")
@Size(groups = Create.class, min = 8, message = "创建时密码至少 8 位")
private String password;
// 更新时允许不传 password,所以不挂 Update 组
}
2.4 Controller:创建/更新走不同组
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public String create(@Validated(Create.class) @RequestBody UserReq req) {
return "created";
}
@PutMapping("/{id}")
public String update(@PathVariable Long id,
@Validated(Update.class) @RequestBody UserReq req) {
return "updated";
}
}
这里的关键点来自 Spring @Validated 的设计目的:指定要应用的 validation groups。
小吐槽一句:
分组这玩意儿一开始看着“像形式主义”,
但你线上维护两个月就会真香——因为它让“规则差异”变成了可读代码,而不是口头约定。
3. 自定义校验注解:手机号校验别再复制 20 份 @Pattern 了 😭
3.1 为什么要自定义?
@Pattern可用,但可读性差:每次看到一长串正则都像在读甲骨文- message、null 处理、国际区号、运营商段位变化……你迟早要集中治理
- 最重要的是:业务语义应该体现在注解名字上,而不是藏在正则里
Hibernate Validator 是 BV 的主流实现之一,官方也明确它是参考实现/兼容实现方向。
3.2 写一个 @Phone 注解(BV 标准写法)
注解定义
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/**
* 可选:是否允许为空。
* 为空时一般交给 @NotBlank/@NotNull 管,这里给你留个开关,省得你到处写两个注解
*/
boolean nullable() default false;
}
Validator 实现
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class PhoneValidator implements ConstraintValidator<Phone, String> {
private boolean nullable;
// ⚠️ 示例:仅演示中国大陆 11 位手机号的“形状”校验
// 生产建议:规则抽到配置/字典服务,或至少集中维护
private static final Pattern CN_MOBILE = Pattern.compile("^1\\d{10}$");
@Override
public void initialize(Phone constraintAnnotation) {
this.nullable = constraintAnnotation.nullable();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return nullable; // false 表示不允许空
}
return CN_MOBILE.matcher(value).matches();
}
}
使用方式(干净、语义清晰)
public class BindPhoneReq {
@Phone(message = "请输入正确的手机号", nullable = false)
private String phone;
// getter/setter ...
}
这时候你再看入参字段:
“@Phone” 一眼就知道你要校验什么,比正则友好太多了。
4. @ControllerAdvice 全局处理 MethodArgumentNotValidException:别把“错误信息”当垃圾扔给前端
4.1 先搞清楚:为什么会抛这个异常?
在 Spring MVC 场景:
@Valid/@Validated校验失败- 绑定结果里有 field errors
- Spring 通常抛
MethodArgumentNotValidException
而这个异常在 Spring Framework 的 javadoc 里也强调:它可以提供响应体(ProblemDetail,匹配 RFC 9457)等信息。
说人话:
你不处理,它就给你一个默认错误结构;
你处理得好,前端就能“按字段精准提示”,不需要猜谜。
4.2 一个“前端看了不想打人”的统一返回结构
我们定个统一错误格式:
{
"code": "VALIDATION_ERROR",
"message": "参数校验失败",
"errors": [
{ "field": "address.city", "message": "市不能为空" }
]
}
4.3 全局异常处理代码(Spring Boot 3 友好版)
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.*;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handle(MethodArgumentNotValidException ex) {
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
// 同一个字段可能多个错误,这里按你喜好聚合/保留
.map(this::toErr)
.collect(Collectors.toList());
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", "VALIDATION_ERROR");
body.put("message", "参数校验失败");
body.put("errors", errors);
return body;
}
private Map<String, String> toErr(FieldError fe) {
Map<String, String> m = new LinkedHashMap<>();
m.put("field", fe.getField());
m.put("message", Optional.ofNullable(fe.getDefaultMessage()).orElse("字段不合法"));
return m;
}
}
4.4 顺带提一句:方法级校验异常别漏了
如果你做了 Service 方法参数校验(比如在 Service 上标了 @Validated),Spring 参考文档说明:默认会抛 jakarta.validation.ConstraintViolationException,也可配置改为 MethodValidationException。
Spring Boot 参考文档也提到:只要类路径上有 JSR-303 实现(如 Hibernate Validator),方法校验能力就会被自动启用相关支持。
你可以再补一个处理(示例):
import jakarta.validation.ConstraintViolationException;
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handle(ConstraintViolationException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", "CONSTRAINT_VIOLATION");
body.put("message", "参数校验失败");
body.put("errors", ex.getConstraintViolations().stream().map(v -> {
Map<String, String> m = new LinkedHashMap<>();
m.put("field", String.valueOf(v.getPropertyPath()));
m.put("message", v.getMessage());
return m;
}).toList());
return body;
}
5. 把四件事串起来:一个“创建/更新 + 嵌套 + 手机号 + 全局异常”的小闭环
你最终会得到这样的开发体验:
-
创建用户:
@Validated(Create.class)- username 必填
- password 必填且长度 >= 8
- address 必填且 city/province 必填(
@Valid级联)
-
更新用户:
@Validated(Update.class)- id 必填
- username 必填
- password 可不传
-
绑定手机号:
@Phone语义化校验 -
任何校验失败:统一返回
VALIDATION_ERROR,字段路径清晰
你写接口不需要“祈祷”,前端也不用“猜”。
结尾:你现在还觉得“校验就是贴 @Valid”吗?🙂
如果你今天只带走一个结论,我希望是这个:
@Valid解决“对象图要不要往下验”(级联校验,递归,避免死循环)@Validated解决“这次按哪个规则集合验”(groups,方法校验触发)- 分组校验让“创建/更新差异”变成代码事实,而不是口头约定
- 自定义注解让业务语义从“正则地狱”里解放出来(并可集中治理)
- 全局异常处理让错误信息可消费(别再丢给前端一坨难看的默认结构)
🧧福利赠与你🧧
无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」,bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。
ps:本文涉及所有源代码,均已上传至Gitee:
https://gitee.com/bugjun01/SpringBoot-demo开源,供同学们一对一参考 Gitee传送门https://gitee.com/bugjun01/SpringBoot-demo,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗
🫵 Who am I?
我是 bug菌:
- 热活跃于 CSDN:
https://blog.csdn.net/weixin_43970743| 掘金:https://juejin.cn/user/695333581765240| InfoQ:https://www.infoq.cn/profile/4F581734D60B28/publish| 51CTO:https://blog.51cto.com/u_15700751| 华为云:https://bbs.huaweicloud.cn/community/usersnew/id_1582617489455371| 阿里云:https://developer.aliyun.com/profile/uolxikq5k3gke| 腾讯云:https://cloud.tencent.com/developer/user/10216480/articles等技术社区; - CSDN 博客之星 Top30、华为云多年度十佳博主&卓越贡献奖、掘金多年度人气作者 Top40;
- 掘金、InfoQ、51CTO 等平台签约及优质作者;
- 全网粉丝累计 30w+。
更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看:https://bbs.csdn.net/topics/612438251 👈️
硬核技术公众号 「猿圈奇妙屋」https://bbs.csdn.net/topics/612438251 期待你的加入,一起进阶、一起打怪升级。
- End -
- 点赞
- 收藏
- 关注作者
评论(0)