Java 后端
参数校验与全局异常处理
如何进行参数校验与全局异常处理
参数校验与全局异常处理
一、为什么要做参数校验与全局异常处理
首先要明白:本文所说的参数校验是基于spring-boot-starter-validation引入的注解校验,相对于传统的 if-else 判空、校验数值,仅用一个注解就能免去许多烦恼;而做全局异常处理,核心是为了让后端出错时也有统一、可控、好理解的返回。比如说,接口正常成功时返回 Result.success(...),失败时也应该统一返回类似:
{
"code": 400,
"message": "文章标题不能为空",
"data": null
}
而不是有的接口返回字符串,有的返回 Spring 默认错误 JSON,有的甚至返回 HTML 错误页。同时,如果不做全局异常处理,每个 Controller 都要写 try-catch:
try {
...
} catch (Exception e) {
return Result.failed(e.getMessage());
}
这样代码会非常啰嗦,而且容易漏处理。如果有全局异常处理,只需要在业务层直接:
throw new BusinessException("文章不存在");
然后全局异常处理器统一转换成前端能识别的错误响应。
二、参数校验:分层落地
参数校验主要靠 Spring Validation + DTO 注解 + Controller 的 @Valid 自动完成。
整理流程如下:
前端传参
-> Spring 把 JSON/query/form 绑定成 DTO
-> 看到 Controller 参数上有 @Valid
-> 按 DTO 字段上的校验注解检查
-> 校验失败时抛异常
-> GlobalExceptionHandler 统一返回错误结果
首先,DTO 里会绑定各种注解,比如分页查询对象:
@Data
public class PageQuery {
@Min(value = 1, message = "页码不能小于 1")
private Long pageNum = 1L;
@Min(value = 1, message = "每页数量不能小于 1")
@Max(value = 100, message = "每页数量不能超过 100")
private Long pageSize = 10L;
}
再比如文章保存请求:
@Data
public class ArticleSaveRequest {
@NotBlank(message = "文章标题不能为空")
private String title;
@NotBlank(message = "文章 slug 不能为空")
private String slug;
@NotBlank(message = "文章内容不能为空")
private String contentMd;
@NotBlank(message = "文章状态不能为空")
private String status;
xxxx xxxx xxxx;
}
可以看到,通过spring-boot-starter-validation引入的注解@Max,@Min,@NotBlank等,将参数规范绑定在了 DTO,这样当前端传参被 Spring 校验时就可以知道是否符合要求了。这里注解中的message是自定义消息,它负责当参数校验失败时返回你指定的信息,比如说@NotBlank(message = "文章内容不能为空"):如果这个字段不满足 NotBlank 规则,就把错误提示设置为 "文章内容不能为空",方便前端直接展示。
Controller 层传参有两种方法:一种是前端以 JSON 格式传参,这时候需要加@RequestBody注解,负责将 JSON 请求体转换为 DTO 对象1;还有一种是 url 参数,这时候就不用加@RequestBody,Spring 会自动将参数绑定到 DTO 上。
然后,在 Controller 里用 @Valid 或者 @Validated 注解触发参数校验:
@Operation(summary = "分页获取已发布文章")
@GetMapping
public Result<PageResult<ArticleListVO>> page(@Valid ArticlePublicQueryRequest request) {
return Result.success(articleService.getPublicPage(request));
}
这里要注意, Controller 传参必须要加@Valid 或者 @Validated,否则参数校验根本不会进行!
三、全局异常处理器
异常类
我们知道,异常是导致程序的正常流程被中断的事件,当我们的代码在校验业务逻辑时遇到了不符合要求的情况(比如某个字段为空,某项数值超出规范),此时就不能走正常的返回,应该给前端一个明确的错误提示;通过我们自定义的异常,很轻松就能做到。
要自定义异常,我们首先要继承RuntimeException类,如下:
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
这里选择继承RuntimeException的原因有很多,最主要的一点是继承它以后,调用者不强制 try-catch,也不强制在方法上写 throws,使用时直接抛出异常:
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
return user;
}
这里不用写public User getUserById(Long id) throws BusinessException,也不用在 Controller 里强制 try-catch,所以它很适合配合 Spring Boot 的全局异常处理器。
它的构造方法调用了super(message),这说明它将报错信息赋给了父类;点开RuntimeException可以看到:
public RuntimeException(String message) {
super(message);
}
这说明RuntimeException又将报错赋给了它的父类;追本溯源可以发现,在Throwable中,这条消息最终被赋给了其私有变量detailMessage;这说明异常的层次如下:
Throwable
├── Error
└── Exception
├── RuntimeException
└── 其他 Exception
在这个博客后端系统中,除了前文提到的业务异常BusinessException,还定义了一个异常UnauthorizedException,顾名思义,其意义为未授权异常,它表示认证失败/未登录/token 无效/token 过期:
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}
由于它们都继承 RuntimeException,所以 service 或 interceptor 里可以直接 throw,不需要方法签名上写 throws。
其他异常如参数校验时的ConstraintViolationException,文件大小超限MaxUploadSizeExceededException等等都已经写好了,只需借鉴前人智慧拿来用即可。
异常处理时机
了解了异常的一些基本知识,我们想知道:异常究竟是什么时候被处理的呢?
全局异常处理器的触发时机是:当 Controller、Service、Mapper 等调用链中抛出了异常,并且这个异常没有被自己 try-catch 处理掉时,Spring MVC 会接管这个异常,然后去找匹配的 @ExceptionHandler 方法处理,这里的@ExceptionHandler注解留到后文全局异常处理器章节讲解。
正常情况下,请求GET /users/1的流程如下:
请求进入
↓
DispatcherServlet // DispatcherServlet 可以理解成 Spring MVC 的总调度器,所有请求基本都要先经过它。
↓
Controller
↓
Service
↓
Mapper
↓
返回结果
↓
Spring 转成 JSON
↓
响应给前端
然而,如果 Service 抛出 BusinessException:
public User getUserById(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在");
}
return user;
}
Controller:
@GetMapping("/{id}")
public Result<User> getUser(@PathVariable Long id) {
return Result.success(userService.getUserById(id));
}
还会return Result.success();吗?肯定不会!
当代码执行到:
throw new BusinessException("用户不存在");
后,当前方法会立刻中断,然后异常会一层一层往外抛:
Service
↓
Controller
↓
DispatcherServlet
↓
GlobalExceptionHandler
最后执行:
handleBusinessException(BusinessException e)
返回:
{
"code": 400,
"message": "用户不存在",
"data": null
}
整体流程如下:
请求进入
↓
DispatcherServlet
↓
Controller 调用 Service
↓
Service 抛出 BusinessException
↓
Controller 没有 try-catch
↓
异常继续往外抛
↓
DispatcherServlet 捕获到异常
↓
交给异常解析器 HandlerExceptionResolver
↓
找到 @ExceptionHandler(BusinessException.class)
↓
执行全局异常处理方法
↓
返回统一 JSON
全局异常处理器
直接上代码:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return buildResponse(ResultCode.BAD_REQUEST, message, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(BindException.class)
public ResponseEntity<Result<Void>> handleBindException(BindException ex) {
String message = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return buildResponse(ResultCode.BAD_REQUEST, message, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusinessException(BusinessException ex) {
return buildResponse(ResultCode.BAD_REQUEST, ex.getMessage(), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Result<Void>> handleUnauthorizedException(UnauthorizedException ex) {
return buildResponse(ResultCode.UNAUTHORIZED, ex.getMessage(), HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<Void>> handleException(Exception ex) {
log.error("Unhandled exception", ex);
return buildResponse(ResultCode.ERROR, "System error, please try again later.", HttpStatus.INTERNAL_SERVER_ERROR);
}
private ResponseEntity<Result<Void>> buildResponse(ResultCode resultCode, String message, HttpStatus httpStatus) {
return ResponseEntity.status(httpStatus).body(Result.failed(resultCode, message));
}
}
我们首先来看类上的注解@RestControllerAdvice:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
它会让 Spring 自动扫描这个类,然后当 Controller 执行过程中出现异常时,Spring 会来这里找合适的 @ExceptionHandler方法处理,可以理解成:给所有 Controller 配一个“全局异常处理助手”。
@RestControllerAdvice 本质上约等于 @ControllerAdvice + @ResponseBody ,
也就是说:
@ControllerAdvice:对 Controller 做全局增强,配合@ExceptionHandler,可以统一处理 Controller 层及 Spring MVC 请求处理过程中抛出的异常@ResponseBody:方法返回值直接写入响应体,通常序列化成 JSON
GlobalExceptionHandler类的方法有许多,我们挑了几个重要的讲解。首先来看每个方法上都有的注解@ExceptionHandler,它是 Spring MVC 提供的异常处理器注解,它的作用是指定某个方法专门处理某一种或多种异常。我们可以从字面意思上理解:既然是异常处理器,那肯定要处理某一种或几种异常,所以要把异常类写进来,比如说@ExceptionHandler(BusinessException.class)。这个注解要搭配前面讲过的@RestControllerAdvice,可以这么记:
@RestControllerAdvice:声明全局异常处理类@ExceptionHandler:声明具体处理哪种异常
当然,@ExceptionHandler可以一次声明多个异常,不过实际项目中,通常建议分开写,便于返回不同错误码和提示。
接下来我们了解一下异常处理方法的统一返回体:ResponseEntity<Result<Void>>,这是一个嵌套结构,可以拆成两层来理解:外层的ResponseEntity<>管 HTTP 响应,内层的Result<>管项目自己的统一响应体。ResponseEntity 是 Spring 提供的响应包装类,可以控制:
- HTTP 状态码
HttpStatusCode,比如 400、401、500 - 响应头
header,比如 Content-Type - 响应体,也就是
body
在buildResponse()方法中,我们可以看到它的创建方法:
return ResponseEntity.status(httpStatus).body(Result.failed(resultCode, message));
这说明它支持链式创建,由HttpStatusCode和body组成,这里响应头header并没有传入,说明返回的是默认响应头,最终效果可能如下所示:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": 400,
"message": "文章不存在",
"data": null
}
拆解完外部,我们接下来看看内部Result<Void>,它很好理解:不返回任何数据。这里可能有人会有疑惑:为什么泛型是Void而不是void?道理其实很简单:void不能当作泛型,void 表示方法没有返回值;Void 是 Java 类型,用在泛型里,表示这个 Result 不携带数据。
既然讲到了返回体,那么当然就要讲构造返回体的方法buildResponse(),这个方法负责统一构造错误响应,它做的事情其实就一句话:**把异常信息包装成统一的 HTTP 响应。**它的三个参数分别是:
ResultCode resultCode
控制项目统一返回体里的业务状态码,比如 BAD_REQUEST、UNAUTHORIZED、ERROR。
String message
控制返回给前端的错误提示。
HttpStatus httpStatus
控制真正的 HTTP 状态码,比如 400、401、500。
接下来我们看函数逻辑:
ResponseEntity.status(httpStatus)
这部分设置 HTTP 状态码。比如:HttpStatus.BAD_REQUEST表示 HTTP 状态码是 400。
然后:
.body(Result.failed(resultCode, message))
这部分设置响应体,也就是返回给前端的 JSON 内容。
Result.failed(resultCode, message) 会生成类似这样的对象:
{
"code": 400,
"message": "文章不存在",
"data": null
}
要注意的是,第一,ResultCode 和 HttpStatus 尽量保持语义一致。比如 UNAUTHORIZED 对应 HttpStatus.UNAUTHORIZED,这样前端好判断。第二,Result<Void> 表示错误响应不携带业务数据,data 通常就是 null。异常处理里一般用它很合适。
最后,我们来看异常处理器中的方法,在了解了前面的知识后,这些方法变得容易理解许多;我们简要介绍一下:
handleMethodArgumentNotValid:JSON 请求体参数错handleBindException:query/form 参数错handleBusinessException:业务规则不通过handleUnauthorizedException:没登录或 token 不对handleException:系统未知错误兜底
其中handleException作为兜底异常处理很关键,因为任何没被前面几个方法匹配到的未知异常,都会走这里。因此,它必须写在所有异常捕获方法的最后一个作兜底处理。
四、总结
总的来说,参数校验主要分为以下3个步骤:
- 依赖引入 —
spring-boot-starter-validation - DTO 层声明校验规则
- Controller 层触发校验
而全局异常处理负责将业务层抛出的异常捕获并设置返回格式,最终返回给前端一个清晰可读的错误信息。
Footnotes
-
谁负责转换?主要是 Spring MVC 里的 HTTP 消息转换器(HttpMessageConverter) 完成的,在 Spring Boot 里,默认通常使用 Jackson 来处理 JSON 和 Java 对象之间的转换。 ↩
