全局异常处理与参数校验
概述
接口报错的时候,如果直接让异常往外抛,前端拿到的是一坨 Tomcat 默认的错误页面 HTML,或者一串乱七八糟的堆栈信息。前端没法判断"这是什么错、该提示用户什么"。
全局异常处理就是兜底:不管哪里出了什么错,最后返回给前端的始终是统一的 JSON 结构——有错误码、有错误信息,前端可以稳稳地拿这个做处理。
参数校验也一起搞了:Controller 里别写一堆if (xxx == null),靠注解声明规则,校验不通过直接抛异常,由全局处理器统一拦下来返回。
引入依赖
@ControllerAdvice、@ExceptionHandler这些来自spring-boot-starter-web,如果你已经用了 Web 模块就不用额外加了。
但是参数校验需要单独引入:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>Spring Boot 2.x 时代validation内嵌在spring-boot-starter-web里,3.x 拆出来了。不加这个依赖的话,@NotBlank、@Valid不会报编译错,但运行时校验不生效——加了注解等于白加,所有参数直接放行。
第一步:搭异常体系
common
我们需要两个基础的东西:异常接口 + 异常类。放在common模块,全项目都能用。
BaseExceptionInterface
publicinterfaceBaseExceptionInterface{StringgetErrorCode();StringgetErrorMessage();}就是一个接口,规定"异常必须包含错误码和错误信息"。后面我们的枚举实现它,BizException也依赖它。
BizException
BizException代表业务异常,每个模块都可能出现业务异常,所以定义在common模块里面。
@Getter@SetterpublicclassBizExceptionextendsRuntimeException{privateStringerrorCode;privateStringerrorMessage;publicBizException(BaseExceptionInterfacebaseExceptionInterface){this.errorCode=baseExceptionInterface.getErrorCode();this.errorMessage=baseExceptionInterface.getErrorMessage();}}继承RuntimeException,这样在业务代码里抛的时候不用在方法签名上throws,Spring 也默认对运行时异常做事务回滚。
构造参数只接收BaseExceptionInterface,不让你随便new BizException("随便写的错")——所有异常必须预先在枚举里定义好,方便统一管理。
具体业务模块
定义我这个模块会有什么异常
ResponseCodeEnum
@Getter@AllArgsConstructorpublicenumResponseCodeEnumimplementsBaseExceptionInterface{// 通用异常SYSTEM_ERROR("AUTH-10000","出错啦,后台小哥正在努力修复中..."),PARAM_NOT_VALID("AUTH-10001","参数错误"),// 业务异常往后加;privatefinalStringerrorCode;privatefinalStringerrorMessage;}这里其实很巧妙,这里是implements BaseExceptionInterface,但是这个接口定义的是getErrorCode和getErrorMessage两个方法,我这里定义了两个变量,加上@Getter刚好重写了接口的两个方法。
| 要点 | 说明 |
|---|---|
实现BaseExceptionInterface | 枚举实例可以直接丢进BizException的构造参数 |
| 错误码前缀 | AUTH代表这个模块,和其他模块的错误码区分开。比如订单模块用ORDER-20000 |
| 后面加什么 | 登录失败、用户不存在、权限不足……随着业务迭代,直接在枚举里加就行 |
抛一个业务异常就一句话
thrownewBizException(ResponseCodeEnum.SYSTEM_ERROR);三者关系
BizException的构造参数是BaseExceptionInterface(接口),不是ResponseCodeEnum(具体枚举)。所以:
BizException 构造参数 ↓ 依赖 BaseExceptionInterface(接口:规定必须有 errorCode + errorMessage) ↑ 实现 ResponseCodeEnum(枚举:集中管理所有错误码)- 接口起约定作用:不管哪个模块、哪个枚举,只要你实现了这两个 getter,就能丢进
BizException - 枚举起集中管理作用:所有错误码一处定义,不散落
- BizException只认接口不认枚举,auth 模块的
AuthResponseCodeEnum、order 模块的OrderResponseCodeEnum,只要实现了接口,都能用
如果直接用new BizException(String, String),错误码会散落在各个角落,改一个提示语得全项目搜,想看看系统里有哪些错误码也无从下手。
第二步:全局异常处理器
@ControllerAdvice@Slf4jpublicclassGlobalExceptionHandler{/** 业务异常 */@ExceptionHandler({BizException.class})@ResponseBodypublicResponse<Object>handleBizException(HttpServletRequestrequest,BizExceptione){log.warn("{} request fail, errorCode: {}, errorMessage: {}",request.getRequestURI(),e.getErrorCode(),e.getErrorMessage());returnResponse.fail(e);}/** 参数校验失败 */@ExceptionHandler({MethodArgumentNotValidException.class})@ResponseBodypublicResponse<Object>handleMethodArgumentNotValidException(HttpServletRequestrequest,MethodArgumentNotValidExceptione){StringerrorCode=ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();BindingResultbindingResult=e.getBindingResult();StringBuildersb=newStringBuilder();Optional.ofNullable(bindingResult.getFieldErrors()).ifPresent(errors->{errors.forEach(error->sb.append(error.getField()).append(" ").append(error.getDefaultMessage()).append(", 当前值: '").append(error.getRejectedValue()).append("'; "));});log.warn("{} request error, errorCode: {}, errorMessage: {}",request.getRequestURI(),errorCode,sb.toString());returnResponse.fail(errorCode,sb.toString());}/** 其他未捕获的异常——兜底 */@ExceptionHandler({Exception.class})@ResponseBodypublicResponse<Object>handleOtherException(HttpServletRequestrequest,Exceptione){log.error("{} request error",request.getRequestURI(),e);returnResponse.fail(ResponseCodeEnum.SYSTEM_ERROR);}}三个关键注解
| 注解 | 干什么的 |
|---|---|
@ControllerAdvice | 全局增强,所有 Controller 的异常都会被这里的@ExceptionHandler拦截 |
@ExceptionHandler | 声明这个方法专门处理哪种类型的异常 |
@ResponseBody | 返回的内容直接序列化为 JSON,不要走视图模板 |
三层兜底结构
Exception(其他所有异常) ← 第三层:保底,全拦住了 └─ MethodArgumentNotValidException ← 第二层:参数校验 └─ BizException ← 第一层:业务异常Spring 匹配规则是找最精确的。如果抛的是BizException,只走第一个 handler;抛的是MethodArgumentNotValidException,走第二个;其他任何没被前两个匹配到的异常,都掉进第三个兜底。三层覆盖,不会有漏网之鱼。
注意:
BizException用log.warn,因为它是业务"可预期"的错,不需要大惊小怪。最后那个Exception用log.error并打印了堆栈,因为这是真正该修 bug 的异常。
第三步:参数校验
上面那个MethodArgumentNotValidException是什么时候抛的?当加了@Valid的入参校验不通过时。
在实体类属性上加校验注解
@DatapublicclassUser{@NotBlank(message="昵称不能为空")privateStringnickName;privateLocalDateTimecreateTime;}在 Controller 方法参数里加 @Valid
@PostMapping("/test2")@ApiOperationLog(description="测试接口2")publicResponse<User>test2(@Valid@RequestBodyUseruser){returnResponse.success(user);}注意@Valid是加在方法参数上的,不是加在类上。二者缺一不可:
- 类上没有
@NotBlank→ 不会校验,什么都过得去 - 方法参数没有
@Valid→ 不会触发校验,注解成了摆设
校验错误信息是怎么拼出来的?
GlobalExceptionHandler里的handleMethodArgumentNotValidException遍历了bindingResult.getFieldErrors(),把每条错误的字段名、校验描述、实际收到的值拼接在一起:
sb: "nickName 昵称不能为空, 当前值: ''; "这样前端/开发人员一看就知道是哪个字段、为什么不通过、实际传了什么。
常用校验注解
| 注解 | 校验规则 |
|---|---|
@NotBlank | 不能为null且不能为空字符串(""不行," "也不行) |
@NotNull | 不能为null(但""可以) |
@NotEmpty | 不能为null且不能为空集合/空字符串 |
@Email | 必须是邮箱格式 |
@Size(min, max) | 字符串/集合长度范围 |
@Min/@Max | 数字最小值/最大值 |
@Pattern(regexp) | 自定义正则校验 |
选哪个要看业务场景:比如昵称不允许空字符串,用@NotBlank而不是@NotNull,因为@NotNull允许""通过。
完整流程串联
以一个参数校验失败的请求为例,整个链路:
- 前端 POST
/test2,body 是{ "nickName": "" } - Spring 序列化 JSON →
User对象 @Valid触发校验 →@NotBlank校验失败- Spring 抛出
MethodArgumentNotValidException @ControllerAdvice的handleMethodArgumentNotValidException拦住- 遍历错误字段,拼出
"nickName 昵称不能为空, 当前值: ''; " - 返回
Response.fail("AUTH-10001", "...")→ JSON
前端从头到尾拿到的都是统一结构,一个if (response.success)就能判断是否正常。
如何使用业务异常
你想手动中断一个请求、返回错误信息给前端:
// 不满足条件,直接抛if(user==null){thrownewBizException(ResponseCodeEnum.SYSTEM_ERROR);}在ResponseCodeEnum里加新的异常码:
// 通用异常SYSTEM_ERROR("AUTH-10000","出错啦,后台小哥正在努力修复中..."),PARAM_NOT_VALID("AUTH-10001","参数错误"),// 业务异常LOGIN_FAILED("AUTH-20001","用户名或密码错误"),USER_NOT_FOUND("AUTH-20002","用户不存在"),UNAUTHORIZED("AUTH-20003","没有访问权限"),;然后:
thrownewBizException(ResponseCodeEnum.LOGIN_FAILED);