你以为 Bean Validation 3.0 只会加几个注解就完事了?那为啥你项目里校验总是“该炸不炸、不该炸乱炸”?

举报
bug菌 发表于 2026/01/13 22:10:53 2026/01/13
【摘要】 🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。  本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。...

🏆本文收录于《滚雪球学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) 的几个“高级但高频翻车点”一次捋顺:

  1. 嵌套校验(@Valid vs @Validated
  2. 分组校验(新增/更新不同规则)
  3. 自定义校验注解(手机号为例)
  4. @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 -

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。