三板斧
我先不给你讲代码,我先给你讲场景 。假设你现在是一个外卖平台的后端开发,你写了一个"根据用户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:
-
Controller 走到
if,抛出BusinessException(1001, "年龄必须大于0")。 -
Spring 检测到异常,寻找
@ExceptionHandler。 -
找到
handleBusinessException,拦截。 -
返回 JSON:
{"code":1001, "message":"年龄必须大于0", "data":null}。 -
前端拿到规范的JSON,弹窗提示"年龄必须大于0",完美。
前端调用 /user/get?age=abc:
-
Spring 尝试将 "abc" 转为 Integer,失败,抛出
IllegalArgumentException。 -
找到
handleIllegalArg。 -
返回 JSON:
{"code":1001, "message":"请求参数不合法:..."}。 -
前端依然拿到规范JSON。
假如代码里有个隐藏的空指针(user.getName().length() 但 user 是 null):
-
抛出
NullPointerException。 -
前两个
@ExceptionHandler不匹配,走最后的handleException。 -
后台打印完整的红字堆栈(方便运维看),返回 JSON:
{"code":500, "message":"系统繁忙,请稍后重试", "data":null}。 -
前端拿到JSON,显示"服务器开了个小差",页面不白屏。
关于你的假期安排(回应图片里的建议)
图片里说"比 @Transactional 还优先",这里我要给你掰开揉碎讲清楚:
-
并不是说"启动顺序"优先(Spring加载时事务和Advice各有各的初始化逻辑)。
-
它的意思是学习优先级 和代码底线优先级。
-
你搭新项目,第一件事 就是建
Result和GlobalExceptionHandler。因为这决定了你整个项目的**"交流语言"**。 -
@Transactional是业务逻辑层面的技术,如果连异常都拦不住、返回都不统一,事务写得再好,前端拿到的也是乱码,项目根本没法联调。所以,利用假期第一天,把这三把钥匙敲一遍,跑通一个带异常拦截的HelloWorld,这叫"打地基"。
-
进阶小贴士(面试加分项)
-
不要捕获
Throwable:它包含Error(比如OutOfMemoryError),这种错误程序无法恢复,不应该由业务层拦截。 -
参数校验框架(@Valid) :Spring Boot 结合
@Valid校验 Bean 时,会抛出MethodArgumentNotValidException,你需要在全局异常处理器里单独加一个拦截方法,把校验失败的具体字段信息取出来,拼到message里。 -
日志一定要分级别 :自定义业务异常用
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(系统错误)。前端无法区分"这个弹窗是警告用户输入错误"还是"服务器真的挂了"。 -
因为你只能抛
NullPointerException或IllegalArgumentException,全局拦截器只能根据异常类型粗略处理,无法携带业务自定义的错误码(如1001) 和业务提示语。 -
结果 :前端只能对所有异常统一弹"系统错误",用户体验极差,而且后端排查日志时,满屏都是
ERROR日志,无法区分业务警告和系统Bug。
3. 如果写了 BusinessException 和 GlobalExceptionHandler,但不写 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 |
编写的顺序建议(假期实操步骤)
虽然是"铁三角",但你敲键盘得有个先后顺序,我建议你按这个步骤来,逻辑最顺:
-
先写
Result<T>(定义好标准外壳长什么样)。 -
再写
BusinessException(定义好弹药怎么生产)。 -
最后写
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.Logger和LoggerFactory:这是日志门面(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:你可以改成dealBuzEx、catchMyError、helloWorld都行! -
唯一绑定关系 :只要
@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! 他们的秘诀是:
-
搭好骨架模板(复制粘贴) :把
GlobalExceptionHandler这个类建好,里面的@ExceptionHandler空方法体全部敲一遍(或者从我给你的答案里复制过来)。这个骨架一辈子只用敲一次,以后所有项目直接复制这个类过去微调。 -
写业务逻辑时只关注
throw new:你在Service层只需要写throw new BusinessException(1001, "年龄不对")。至于谁去捕获、谁去返回JSON,根本不用管,那是全局异常处理器自动干的脏活累活。 -
利用IDE的补全(快捷键) :你敲
@ExceptionHan,按一下Tab或Enter,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里,只有 class、interface、enum、@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.validation 或 jakarta.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"
}
运行流程推演:
-
Spring 接收到请求,将 JSON 转为
UserRegisterDTO对象。 -
发现参数上有
@Valid,开始逐个校验字段。-
userId=-5,违反@Min(1)→ 生成 FieldError(字段=userId,消息="用户ID必须大于0") -
name="张",长度为1,违反@Size(min=2)→ 生成 FieldError -
email="abc",不满足邮箱格式 → 生成 FieldError
-
-
校验失败,Spring 立即抛出
MethodArgumentNotValidException,不会进入 Controller 的业务代码。 -
全局异常处理器中的
handleValidationException捕获它。 -
遍历
e.getBindingResult().getFieldErrors(),组装成 Map。 -
返回 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.validation 和 javax.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 接口现在的行为是:
-
校验
userId:因为它上面的@NotNull和@Min注解属于OnlyUserIdValidation组,与 Controller 中指定的分组匹配,所以会生效。 -
跳过
name和email:它们上面的校验注解(如@NotBlank)没有指定groups,默认属于Default.class组。由于 Controller 指定的分组是OnlyUserIdValidation.class,并不包含Default.class,因此这些校验会被跳过。
这样,你就成功实现了"只校验 userId,不改动 DTO 代码"的目标。
需要注意的几点
-
@Validvs@Validated:记住,@Valid是 Java 标准注解,不支持分组;@Validated是 Spring 提供的,支持分组。在需要分组的场景下,必须使用@Validated。 -
Default 分组 :所有没有显式指定
groups的校验注解,都默认属于Default.class分组。这是一个非常重要的概念,理解它才能准确预测校验行为。 -
分组继承 :你的自定义分组可以继承
Default.class,这样当指定该自定义分组时,Default组的校验规则也会生效。这在某些场景下能简化配置。