Java异常学习[特殊字符]

三板斧

我先不给你讲代码,我先给你讲场景 。假设你现在是一个外卖平台的后端开发,你写了一个"根据用户ID查订单"的接口。

  • 正常情况:返回一个订单JSON。

  • 异常情况1 :用户传的ID是负数,你忘了校验,程序抛出IllegalArgumentException

  • 异常情况2 :数据库连接突然断了,抛出SQLException

如果没有统一响应和全局异常处理,会发生什么?

前端小哥调用你的接口,收到的不再是JSON,而是一大串带着时间戳、包名、行号的Tomcat红字报错HTML页面 (500状态码)。前端无法解析HTML,APP直接闪退或白屏。这就是生产环境的"必崩点"

今天这一课,就是教你如何用三把钥匙把这扇"地狱之门"锁死。


第一把钥匙:统一响应对象 Result<T>(定义契约)

无论成功还是失败,给前端返回的格式必须长一模一样 。我们定义一个泛型类 Result<T>

java

复制代码
import com.fasterxml.jackson.annotation.JsonInclude;

// 泛型T,代表你要返回的具体数据(比如订单列表、用户信息)
@JsonInclude(JsonInclude.Include.NON_NULL) // 如果data为空,就不序列化这个字段
public class Result<T> {

    // 1. 状态码:200表示成功,其他表示各种失败
    private Integer code;
    
    // 2. 提示消息:给前端或者用户看的文字
    private String message;
    
    // 3. 具体的数据:泛型,可以是任何对象
    private T data;

    // 私有构造器,不允许外面new,必须通过静态方法创建
    private Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    // 静态方法:成功时调用(只需要传数据)
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }

    // 静态方法:成功但没有数据返回(比如删除接口)
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功", null);
    }

    // 静态方法:失败时调用(需要传错误码和消息)
    public static <T> Result<T> error(Integer code, String message) {
        return new Result<>(code, message, null);
    }

    // 下面省略 getter / setter / toString ...
}

重点理解code 不是HTTP状态码(那是网络传输用的),这是我们业务状态码 。我们约定:200 即一切正常,1001 参数错误,1002 未登录,1003 库存不足等。


第二把钥匙:自定义业务异常(弹药库)

你不能把所有异常(比如空指针)都直接抛给用户看,太Low了。我们需要自定义一个异常,专门代表"业务上出了岔子"。

java

复制代码
// 继承 RuntimeException,因为Spring事务回滚默认只认RuntimeException
public class BusinessException extends RuntimeException {

    private Integer code; // 业务错误码

    public BusinessException(Integer code, String message) {
        super(message); // 把消息交给父类
        this.code = code;
    }

    public Integer getCode() {
        return code;
    }

    // 为了方便,加一个快速抛出“参数错误”的静态方法
    public static BusinessException paramError(String msg) {
        return new BusinessException(1001, msg);
    }
}

为什么一定要继承 RuntimeException 因为受检异常(throws Exception)会污染你的接口声明,而运行时异常可以"无声无息"地往上冒,配合我们第三把钥匙直接截停。


第三把钥匙:全局异常处理器(终极拦截网)

这是最关键的核心------@RestControllerAdvice。它就像一个AOP切面(面向切面编程),包裹在所有Controller的外层。所有Controller抛出的异常,都会先经过这里。

java

复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice // 等同于 @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 1. 拦截我们自定义的业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        // 打印一下日志,方便排查问题
        log.warn("业务异常发生:code={}, msg={}", e.getCode(), e.getMessage());
        // 取出我们自定义异常里的code和message,封装成统一的Result返回
        return Result.error(e.getCode(), e.getMessage());
    }

    // 2. 拦截系统内置异常(比如参数校验失败、数字格式错误等)
    @ExceptionHandler(IllegalArgumentException.class)
    public Result<Void> handleIllegalArg(IllegalArgumentException e) {
        log.warn("参数非法:{}", e.getMessage());
        // 统一返回 1001 参数错误
        return Result.error(1001, "请求参数不合法:" + e.getMessage());
    }

    // 3. 终极兜底:拦截所有未知异常(Exception.class)
    // 注意:这里异常范围最大,必须写在最下面,否则会覆盖上面的细粒度拦截
    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        // 这种异常是程序员没预料到的,必须打印完整的错误堆栈,方便我们修复Bug
        log.error("系统未知异常:", e); 
        // 绝对不能把堆栈信息返回给前端,只给一个友好的模糊提示
        return Result.error(500, "系统繁忙,请稍后重试");
    }
}

完整运行流程推演(极其重要)

我们写一个Controller来实际跑一遍逻辑:

java

复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/get")
    public Result<User> getUser(Integer age) {
        // 假设业务要求年龄必须大于0
        if (age == null || age <= 0) {
            // 1. 手动抛出我们的自定义业务异常
            throw BusinessException.paramError("年龄必须大于0");
        }
        
        // 2. 假如你忘了判断,age传了个字符串"abc",Spring自动转int时会抛出 IllegalArgumentException
        // 3. 假如数据库挂了,会抛出 SQLException(被Exception兜底捕获)
        
        User user = new User("张三", age);
        return Result.success(user);
    }
}

前端调用 /user/get?age=-5

  1. Controller 走到 if,抛出 BusinessException(1001, "年龄必须大于0")

  2. Spring 检测到异常,寻找 @ExceptionHandler

  3. 找到 handleBusinessException,拦截。

  4. 返回 JSON:{"code":1001, "message":"年龄必须大于0", "data":null}

  5. 前端拿到规范的JSON,弹窗提示"年龄必须大于0",完美。

前端调用 /user/get?age=abc

  1. Spring 尝试将 "abc" 转为 Integer,失败,抛出 IllegalArgumentException

  2. 找到 handleIllegalArg

  3. 返回 JSON:{"code":1001, "message":"请求参数不合法:..."}

  4. 前端依然拿到规范JSON

假如代码里有个隐藏的空指针(user.getName().length() 但 user 是 null):

  1. 抛出 NullPointerException

  2. 前两个 @ExceptionHandler 不匹配,走最后的 handleException

  3. 后台打印完整的红字堆栈(方便运维看),返回 JSON:{"code":500, "message":"系统繁忙,请稍后重试", "data":null}

  4. 前端拿到JSON,显示"服务器开了个小差",页面不白屏


关于你的假期安排(回应图片里的建议)

图片里说"比 @Transactional 还优先",这里我要给你掰开揉碎讲清楚:

  • 并不是说"启动顺序"优先(Spring加载时事务和Advice各有各的初始化逻辑)。

  • 它的意思是学习优先级代码底线优先级

    • 你搭新项目,第一件事 就是建 ResultGlobalExceptionHandler。因为这决定了你整个项目的**"交流语言"**。

    • @Transactional 是业务逻辑层面的技术,如果连异常都拦不住、返回都不统一,事务写得再好,前端拿到的也是乱码,项目根本没法联调。所以,利用假期第一天,把这三把钥匙敲一遍,跑通一个带异常拦截的HelloWorld,这叫"打地基"。


进阶小贴士(面试加分项)

  1. 不要捕获 Throwable :它包含 Error(比如 OutOfMemoryError),这种错误程序无法恢复,不应该由业务层拦截。

  2. 参数校验框架(@Valid) :Spring Boot 结合 @Valid 校验 Bean 时,会抛出 MethodArgumentNotValidException,你需要在全局异常处理器里单独加一个拦截方法,把校验失败的具体字段信息取出来,拼到 message 里。

  3. 日志一定要分级别 :自定义业务异常用 log.warn(运维不需要看堆栈),未知异常用 log.error(必须打印堆栈,否则出Bug你不知道哪里错了)。

好了,今天的课就到这里。课后作业 :把你之前写的所有Controller的 try-catch 全部删掉,用这三把钥匙重构一遍。你会发现,你的Controller里只剩下纯粹的"业务逻辑",再也不用写恶心的 try-catch 了------这就是"全局异常处理"优雅的地方

下课!有不懂的随时举手提问。根据你提到的"统一响应"和"全局异常处理"问题,我以课堂形式为你完整拆解了这三把核心钥匙:Result<T>统一响应对象、自定义BusinessException、以及@RestControllerAdvice全局拦截器。

这三者配合后,你的所有接口异常都会被拦截成规范的JSON,Tomcat的错误页再也不会抛给前端。

缺一不可

我把它称为**"异常处理铁三角"**,缺了任何一个,你的接口防线都会出现致命漏洞。

为了让你心服口服,我们来做一次**"缺一不可"的破坏性实验**,你看看少了某个组件会发生什么:


1. 如果只写 Result<T>,不写 GlobalExceptionHandler(缺了拦截网)

  • 你确实定义了统一的返回格式,但没人用它

  • Controller里你写了 return Result.success(data); 正常时没问题。

  • 一旦抛出异常 (比如空指针),代码直接跳过后面的return,Tomcat拿到异常后找不到任何 Result 对象,直接把默认的HTML错误页抛给前端。

  • 结果 :前端依然收到白屏或500 HTML页面,你的 Result 成了摆设。

2. 如果写了 Result<T>GlobalExceptionHandler,但不写 BusinessException(缺了弹药库)

  • 你的拦截器确实能把所有异常拦下来,返回 {"code":500, "message":"系统繁忙"}

  • 问题来了 :如果只是"用户年龄传了负数"这种业务小错误,前端收到的也是 code:500(系统错误)。前端无法区分"这个弹窗是警告用户输入错误"还是"服务器真的挂了"。

  • 因为你只能抛 NullPointerExceptionIllegalArgumentException,全局拦截器只能根据异常类型粗略处理,无法携带业务自定义的错误码(如1001)业务提示语

  • 结果 :前端只能对所有异常统一弹"系统错误",用户体验极差,而且后端排查日志时,满屏都是 ERROR 日志,无法区分业务警告和系统Bug。

3. 如果写了 BusinessExceptionGlobalExceptionHandler,但不写 Result<T>(缺了统一格式)

  • 你在拦截器里可以返回数据了,比如 return Map.of("code", e.getCode(), "msg", e.getMessage());

  • 问题 :成功时 Controller 返回的是 User 对象({"name":"张三","age":18}),失败时拦截器返回的是 Map{"code":1001,"msg":"年龄错误"})。

  • 前端小哥得写两套解析逻辑:如果是 Map 就取 msg,如果是 User 就直接渲染。而且字段名一会儿叫 msg 一会儿叫 message,维护起来想打人。

  • 结果:响应格式不统一,接口文档无法标准化,联调效率极低。


正确的"铁三角"分工图(帮你刻在脑子里)

组件 角色定位 核心职责 能不能省?
Result<T> 通讯契约 定义成功/失败返回的统一外壳(code/message/data) 绝对不能,否则格式不统一
BusinessException 弹药/信使 在业务层发现错误时,携带专属错误码和提示语向上抛出 绝对不能,否则无法区分业务错误和系统Bug
GlobalExceptionHandler 拦网/交警 在系统最外层统一拦截 所有异常,决定用什么Result壳子包裹返回 绝对不能,否则异常直接逃逸到Tomcat

编写的顺序建议(假期实操步骤)

虽然是"铁三角",但你敲键盘得有个先后顺序,我建议你按这个步骤来,逻辑最顺:

  1. 先写 Result<T>(定义好标准外壳长什么样)。

  2. 再写 BusinessException(定义好弹药怎么生产)。

  3. 最后写 GlobalExceptionHandler (这是组装车间,把前面两个组合起来,并把Spring自带的异常也映射成我们的Result)。

GlobalExceptionHandler 时,一定要把兜底的 @ExceptionHandler(Exception.class) 放在最后 ,因为Spring匹配拦截器是按子类优先 原则(越具体越先匹配),如果你把 Exception.class 写在最上面,那所有异常都被它拦了,下面的 BusinessException 就永远无法触发。


敲黑板总结

只有三个类同时存在且正确配置,你的项目才算真正穿上了"防弹衣"。

这是一个标准化的工业级方案,不是可选项,是必选项。在任何一个成熟的开源项目(如若依、Spring Cloud Alibaba)里,你都能看到这三者的身影。

假期第一天,别急着搞 @Transactional 事务,先把这三个类敲出来,跑通一个带 BusinessException 的测试接口,看到前端稳稳收到 {"code":1001, "message":"..."} 时,你的地基就打牢了。

代码书写

第一类:Java语言自带的"硬骨头"(固定,必须这么写)

这些是JDK(Java开发工具包)里祖宗定好的类名,你改一个字母IDE(集成开发环境)就会报红,没得商量。

  • Exception:所有异常的根类,必须这么写。

  • IllegalArgumentException:JDK自带的"非法参数异常"类,必须这么写。

  • RuntimeException:运行时异常的根类,继承它才能保证事务回滚,必须这么写。

你的心态:把它们当成汉字里的"你、我、他",不是你自己发明的,是语言本身就有的,直接拿来用就行。


第二类:框架(Spring)规定的"固定格式"(固定,必须这么写)

这些是Spring框架规定好的注解类名,你改了Spring就不认识你了。

  • @RestControllerAdvice:注解名,一个字都不能错。

  • @ExceptionHandler:注解名,一个字都不能错。

  • org.slf4j.LoggerLoggerFactory:这是日志门面(SLF4J)的固定API(应用程序接口)。

注意 :虽然类名固定,但获取它的写法套路是固定的 ,你直接复制粘贴下面这行,不需要理解为什么 ,背下来当万能公式:

private static final Logger log = LoggerFactory.getLogger(你的类名.class);


第三类:日志方法的名字(半固定,但你只用常用的那几个)

  • log.warn()log.error()log.info() :这是Logger对象自带的方法名 ,你不能改成 log.warning()log.err(),否则报错。

  • 但是 ,你不需要全部记住!你只要记住:

    • 业务异常(自己抛的)用 warn(警告)。

    • 系统未知异常(Bug)用 error(错误)。

    • 调试信息用 info(信息)。

  • 怎么偷懒 :敲 log. 之后,IDE会弹出一长串列表(warn, error, info, debug...),你用鼠标点或者上下键选就行,根本不用背全名


第四类:你完全可以自己起名的"活名字"(不固定,随便改)

这是你最担心的部分,但恰恰是最自由 的部分!你原来代码里的 handleException,完全可以改成任何你喜欢的名字。

  • handleBusinessException :你可以改成 dealBuzExcatchMyErrorhelloWorld 都行!

  • 唯一绑定关系 :只要 @ExceptionHandler(异常类名.class) 括号里的异常类写对了,方法名随便你怎么起

举个例子,下面两种写法完全等价(功能一模一样):

java

复制代码
// 写法1(我起的名字)
@ExceptionHandler(BusinessException.class)
public Result<Void> myLittlePony(BusinessException e) { ... }

// 写法2(你起的名字)
@ExceptionHandler(BusinessException.class)
public Result<Void> 随便叫啥都行(BusinessException e) { ... }

唯一不能改的是括号里的 BusinessException.class,因为那是告诉Spring:"当出现这种异常时,来找我这个方法。"


攻克"自己写就不会"的终极心法

你觉得自己不会写,是因为你在凭记忆敲代码真正的开发高手,从来不背API! 他们的秘诀是:

  1. 搭好骨架模板(复制粘贴) :把 GlobalExceptionHandler 这个类建好,里面的 @ExceptionHandler 空方法体全部敲一遍(或者从我给你的答案里复制过来)。这个骨架一辈子只用敲一次,以后所有项目直接复制这个类过去微调。

  2. 写业务逻辑时只关注 throw new :你在Service层只需要写 throw new BusinessException(1001, "年龄不对")。至于谁去捕获、谁去返回JSON,根本不用管,那是全局异常处理器自动干的脏活累活。

  3. 利用IDE的补全(快捷键) :你敲 @ExceptionHan,按一下 TabEnter,IDEA自动帮你补全完整的 @ExceptionHandler(Exception.class)。你只需要把括号里的 Exception 改成 BusinessException 就行。


给你的"傻瓜式"开工指令(照抄就行)

下次新建项目,直接把这坨代码原封不动 粘进去,只改类名,其他一个字都别动:

java

复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); // 这一行固定死

    // 这里的方法名你随便改,但括号里的 BusinessException.class 不能改
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBiz(BusinessException e) {
        log.warn("业务报错:{}", e.getMessage()); // warn是固定的,但你可以不背,敲log.选它
        return Result.error(e.getCode(), e.getMessage());
    }

    // 这里的方法名你也可以随便改,但括号里的 Exception.class 不能改
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        log.error("系统炸了", e); // error是固定的,敲log.选它
        return Result.error(500, "系统繁忙");
    }
}

记住一句话 :在Java里,只有 classinterfaceenum@interface 关键字、以及注解的名字和括号里的异常类型是死命令,其他方法名、变量名全是你的自由发挥空间。

疑问🤔

如何结合@Valid参数校验,处理MethodArgumentNotValidException,把校验错误细节也统一返回。

或者聊一聊@ControllerAdvice与@RestControllerAdvice的区别,以及多个异常处理器之间的执行顺序

@Valid参数校验

这个问题问到点子上了!@Valid 参数校验 + MethodArgumentNotValidException 处理,是实际开发中 最高频的异常拦截场景,没有之一。

我现在就带你一步步把这块硬骨头啃下来。学完这一课,你会彻底告别在Controller里写几十行 if (xxx == null) 的"体力活"。


第一步:先检查你的"弹药库"(依赖)

如果你用的是 Spring Boot 2.x / 3.x@Valid 注解不在 spring-boot-starter-web 里,它在一个专门的依赖里。

千万注意 :如果你导入 @Valid 时发现只能导入 javax.validationjakarta.validation 但代码不生效,大概率是缺依赖。

Maven 用户 (在 pom.xml 里加上):

xml

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Gradle 用户

implementation 'org.springframework.boot:spring-boot-starter-validation'

为什么强调这个? 很多新手在这卡了半天,以为是代码写错了,结果是依赖没引进来。这是第一道门槛。


第二步:定义接收参数的实体类(DTO)

我们不再写 String name, Integer age 这种散装参数了,而是封装成一个实体类,并在字段上贴上校验注解。

java

复制代码
import jakarta.validation.constraints.*; // 注意:Spring Boot 3 用 jakarta,Boot 2 用 javax

public class UserRegisterDTO {

    @NotNull(message = "用户ID不能为空")      // 不能为 null
    @Min(value = 1, message = "用户ID必须大于0")
    private Integer userId;

    @NotBlank(message = "用户名不能为空")     // 不能是 null / 空字符串 / 纯空格
    @Size(min = 2, max = 10, message = "用户名长度必须在2-10之间")
    private String name;

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

    // 省略 getter / setter / toString
}

这是固定的"语法糖"@NotNull@NotBlank@Size 这些注解的名字是固定死 的,但括号里的 message 属性你可以随便改文字。


第三步:在 Controller 里标记 @Valid(触发校验的"开关")

这是最关键的一步 :如果你的 Controller 参数忘了写 @Valid,那么你上面写的那些 @NotNull 注解完全不会生效,形同虚设。

java

复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    // 注意:@Valid 必须写在 @RequestBody 旁边!
    @PostMapping("/register")
    public Result<String> register(@Valid @RequestBody UserRegisterDTO dto) {
        // 如果校验通过,代码才会走到这里
        System.out.println("校验通过:" + dto.getName());
        return Result.success("注册成功");
    }
}

公式@Valid + @RequestBody = 自动触发校验。


第四步:在全局异常处理器中精准拦截(敲黑板,核心代码)

当校验失败时,Spring 不会抛 BusinessException,而是抛出一个专门的异常:MethodArgumentNotValidException

我们需要在 GlobalExceptionHandler 里新增一个"专案组"来拦截它,并把校验失败的具体字段细节扒出来 ,塞进我们的 Result 里。

工业级标准写法(把错误细节放在 data 里返回)

java

复制代码
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.validation.FieldError;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // ... 拦截 BusinessException 和 Exception 的代码保持不变 ...

    /**
     * 专门拦截 @Valid 校验失败的异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Map<String, String>> handleValidationException(MethodArgumentNotValidException e) {
        
        log.warn("参数校验失败:{}", e.getMessage());

        // 1. 获取所有的字段错误(FieldError)
        //    FieldError 包含了:哪个字段(field)、错误消息(defaultMessage)
        Map<String, String> errorMap = new HashMap<>();
        
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            // 把 "字段名" 和 "错误提示" 放进 Map
            errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
        }

        // 2. 返回统一的 Result,code 可以定为 400(客户端参数错误)
        //    把错误细节放在 data 字段里,前端拿到后可以高亮显示每个输入框的错误
        return Result.error(400, "请求参数不合法", errorMap);
    }
}

如果你不想返回 Map,只想返回一句完整的提示语给前端弹窗(偷懒写法):

java

复制代码
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
    // 取出第一个错误提示,拼成一句话
    String message = e.getBindingResult().getFieldErrors().stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.joining(";"));
    return Result.error(400, message);
}

我强烈推荐第一种返回 Map 的方式,因为前端可以精准知道"哪个输入框红了",用户体验更好,这才是企业级做法。


第五步:完整的"铁三角 + @Valid"串联演示

假设前端传了一个错误的 JSON:

json

复制代码
{
    "userId": -5,
    "name": "张",
    "email": "abc"
}

运行流程推演:

  1. Spring 接收到请求,将 JSON 转为 UserRegisterDTO 对象。

  2. 发现参数上有 @Valid,开始逐个校验字段。

    • userId=-5,违反 @Min(1) → 生成 FieldError(字段=userId,消息="用户ID必须大于0")

    • name="张",长度为1,违反 @Size(min=2) → 生成 FieldError

    • email="abc",不满足邮箱格式 → 生成 FieldError

  3. 校验失败,Spring 立即抛出 MethodArgumentNotValidException不会进入 Controller 的业务代码。

  4. 全局异常处理器中的 handleValidationException 捕获它。

  5. 遍历 e.getBindingResult().getFieldErrors(),组装成 Map。

  6. 返回 JSON 给前端:

json

复制代码
{
    "code": 400,
    "message": "请求参数不合法",
    "data": {
        "userId": "用户ID必须大于0",
        "name": "用户名长度必须在2-10之间",
        "email": "邮箱格式不正确"
    }
}

前端拿到这个 JSON,直接遍历 data 对象,在每个输入框底下显示对应的红色提示文字。完美!


特别补充:@Valid@Validated 的区别(面试/笔试必问)

注解 来源 作用范围 能不能用分组校验
@Valid Java标准(JSR-303) 方法参数、属性(嵌套校验) ❌ 不支持
@Validated Spring 提供 类、方法、参数 ✅ 支持(Groups)

对于 Controller 层的单层参数校验,用 @Valid 就够了。 如果遇到嵌套对象校验(比如 UserDTO 里有个 AddressDTO,需要校验 AddressDTO 的内部字段),需要在嵌套对象上也加上 @Valid


给你的"傻瓜式"终极整合模板(直接复制到你的项目里)

java

复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 1. 业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusiness(BusinessException e) {
        log.warn("业务异常:{}", e.getMessage());
        return Result.error(e.getCode(), e.getMessage());
    }

    // 2. @Valid 校验异常(重点!)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Map<String, String>> handleValid(MethodArgumentNotValidException e) {
        log.warn("参数校验失败:{}", e.getMessage());
        Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,        // key = 字段名
                        FieldError::getDefaultMessage, // value = 错误消息
                        (old, newVal) -> newVal     // 如果有重复key,取新的
                ));
        return Result.error(400, "参数校验失败", errors);
    }

    // 3. 系统内置异常(兜底)
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        log.error("系统未知异常", e);
        return Result.error(500, "系统繁忙,请稍后重试");
    }
}

注意 :如果你用的 Spring Boot 2,import 路径是 javax.validationjavax.validation.constraints;如果是 Spring Boot 3,则是 jakarta.validation。复制代码时留意红线的导包提示,IDEA 会帮你自动修正。

分组校验:

这个问题问得很好,它触及了 @Valid@Validated 一个核心的区别:分组校验(Validation Groups)

简单来说,@Valid 注解本身不支持 分组。要实现"按需校验",我们需要把 @Valid 换成 Spring 提供的 @Validated 注解。

分组校验的核心思想是:给校验规则打上"标签"(分组),然后在需要的时候,只触发带有特定"标签"的规则 。一个字段上的注解可以属于一个或多个组,当 Controller 中的 @Validated 指定了某个组时,只有属于该组的校验注解才会生效。

下面我们通过三个步骤来解决你的"进阶挑战":


步骤一:定义分组接口(创建"标签")

首先,我们需要创建两个空的 Java 接口,作为我们的"标签"。你可以把它们放在 UserRegisterDTO 类的同一个文件里,或者单独创建。

java

复制代码
// 1. 定义一个“只要UserId”的分组,作为我们的“标签”
public interface OnlyUserIdValidation {
}

// 2. (可选)为了更好的可读性,你也可以定义一个包含所有校验的“默认”分组[reference:6]
// 通常不显式定义也没问题,不指定分组就默认是 Default.class

步骤二:给校验注解贴上"标签"

然后,修改你的 UserRegisterDTO 类。在 @NotNull@Min 注解中,通过 groups 属性指定它们属于哪个组。

java

复制代码
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.groups.Default; // 引入默认分组

public class UserRegisterDTO {

    // 1. 给 userId 的校验注解贴上 "OnlyUserIdValidation" 标签
    //    同时,也让它属于默认组 (Default.class),这样不指定分组时它也会校验
    @NotNull(message = "用户ID不能为空", groups = {OnlyUserIdValidation.class, Default.class})
    @Min(value = 1, message = "用户ID必须大于0", groups = {OnlyUserIdValidation.class, Default.class})
    private Integer userId;

    // 2. name 和 email 的校验注解只保留默认组,或者不写 groups (默认就是 Default.class)
    //    这样,它们就不会属于 "OnlyUserIdValidation" 组
    @NotBlank(message = "用户名不能为空") // 默认属于 Default.class
    @Size(min = 2, max = 10, message = "用户名长度在2-10之间") // 默认属于 Default.class
    private String name;

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

    // ... 省略 getter/setter ...
}

步骤三:在 Controller 中指定要使用的"标签"

最后,在 Controller 的方法参数上,把 @Valid 替换成 @Validated ,并传入我们刚定义的 OnlyUserIdValidation.class 作为参数。

java

复制代码
import org.springframework.validation.annotation.Validated; // 注意引入的包
// ... 其他 import

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

    // 把 @Valid 换成 @Validated,并指定分组
    @PostMapping("/register")
    public Result<String> register(@Validated(OnlyUserIdValidation.class) @RequestBody UserRegisterDTO dto) {
        // 你的业务逻辑...
        if (dto.getName().contains("admin")) {
            throw new BusinessException(1001, "用户名不能包含敏感词 'admin'");
        }
        // ...
        return Result.success("注册成功,用户ID:" + dto.getUserId());
    }
}

最终效果

经过以上三步,你的 register 接口现在的行为是:

  1. 校验 userId :因为它上面的 @NotNull@Min 注解属于 OnlyUserIdValidation 组,与 Controller 中指定的分组匹配,所以会生效

  2. 跳过 nameemail :它们上面的校验注解(如 @NotBlank)没有指定 groups,默认属于 Default.class 组。由于 Controller 指定的分组是 OnlyUserIdValidation.class,并不包含 Default.class,因此这些校验会被跳过

这样,你就成功实现了"只校验 userId,不改动 DTO 代码"的目标。

需要注意的几点

  1. @Valid vs @Validated :记住,@Valid 是 Java 标准注解,不支持分组;@Validated 是 Spring 提供的,支持分组。在需要分组的场景下,必须使用 @Validated

  2. Default 分组 :所有没有显式指定 groups 的校验注解,都默认属于 Default.class 分组。这是一个非常重要的概念,理解它才能准确预测校验行为。

  3. 分组继承 :你的自定义分组可以继承 Default.class,这样当指定该自定义分组时,Default 组的校验规则也会生效。这在某些场景下能简化配置。

相关推荐
葡萄城技术团队2 小时前
InfluxDB 2\.x 深度解析:核心架构、Flux 函数与制造业落地指南(三)
java·开发语言·架构
Super 含2 小时前
Android 启动优化(五):线程、GC 与 IO 为什么会拖慢启动?
java·服务器·数据库
counting money2 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
wuyk5553 小时前
98.C语言易混难点:字符数组与字符串指针的底层差异
c语言·开发语言·c++·stm32·嵌入式硬件·算法
峥无3 小时前
从0到1手撕红黑树:封装实现 my_map 与 my_set(SGI-STL 源码级深度解析)
开发语言·c++·笔记·算法·stl
波特率1152003 小时前
C++新特性---属性说明符与标准属性
开发语言·c++
程序员小八7773 小时前
上海百度B端java后端日常实习一面
java·开发语言
wp123_13 小时前
IPX8 防水 Type‑C连接器:安费诺 124018792112A 与 TONEVEE TY48087‑24A 技术梳理
c语言·开发语言
不会代码的小猴4 小时前
3. 控件学习1
开发语言·c++·笔记·qt·算法