Spring Boot统一异常处理心得(JSR303参数校验 + 常见异常)
一、前言
我在网上看过很多讲统一异常处理的,但是感觉很多人在使用过程中会有一些问题,所以讲一下自己的理解(不是很深),讲的不对的地方,望各位大佬海涵,并指正,共同进步,各位转载的时候也希望能注明出处,附上链接,谢谢
开篇先上点代码,大家可以看下自己开发过程中是不是有这种处理异常的代码,或者校验入参。
// 这是controller的异常处理 这里提醒一下
public void test(){
try {
//这里有异常
} catch (NullPointerException e) {
//异常处理 并且打印堆栈信息
//注意这里如果使用e.printStackTrace()控制台打印日志,并且如果使用logback等打印日志,对日志进行分片处理的话,分片日志是不会有堆栈信息的,开发调试的时候可以这样写,但是服务器上
e.printStackTrace();
} catch (Exception e){
//异常处理 并且打印堆栈信息
}
}
// 这是一般controller的参数校验 不使用jsr303注解的
public SysRes<Void> testBindingResult(@RequestBody PhoenixSaveDTO phoenixSaveDTO,
BindingResult bindingResult){
// 非空判断
if( phoenixSaveDTO.getXXX() ){
//抛出异常
}
if( phoenixSaveDTO.getXXX() ){
//抛出异常
}
return null;
}
// 以下是使用JSR303校验注解的controller的参数校验 只写了一些简单的
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 保存DTO
*
* @author 伍六柒
* @since 2021/8/31 19:56
*/
@Data
public class PhoenixSaveDTO {
@NotNull(message = "id不能为空")
private Long id;
@Future(message = "需要一个将来日期") // 只能是将来的日期
@DateTimeFormat(pattern = "yyyy-MM-dd") // 日期格式化转换
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")//格式化接收的日期
private LocalDate date;
@DecimalMin(value = "0.1") // 最小值0.1元
@DecimalMax(value = "10000.00") // 最大值10000元
private BigDecimal doubleValue;
@Min(value = 0, message = "最小值为0") // 最小值为1
@Max(value = 127, message = "最大值为127") // 最大值88
private Integer integer;
@Range(min = 1, max = 100, message = "范围为1至100") // 限定范围
private Long range;
// 邮箱验证
@Email(message = "邮箱格式错误")
private String email;
@Size(min = 10, max = 16, message = "字符串长度要求10到16之间。")
private String size;
}
// 注意这里使用了@Valid 以及 BindingResult
public SysRes<Void> testBindingResult(@RequestBody @Valid PhoenixSaveDTO phoenixSaveDTO,
BindingResult bindingResult){
if( bindingResult.hasErrors() ){
List<String> messageList = bindingResult.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.toList());
String message = String.valueOf(messageList);
log.error("[参数校验异常:{}", message);
return SysRes.fail(SysCode.PARAMS_EXCEPTION, message);
}
return null;
}
以上代码是我们常用的一些异常处理(JSR参数校验)方法,在我们平时开发中,如果controller的每个方法都做一遍try{ }catch{ }处理或者使用if来做参数非空判断,无疑会浪费很多时间,并且要写大量业务代码,所以为了减少以上问题,方便开发,我们需要对常用的异常做统一处理(如果非要在业务代码中try,那么也应该只在可能出现异常的地方使用try,而不是try整个业务代码),使我们的开发更加高效。
二、JSR303参数校验
2.1 为什么要说这个
在任何时候,当你要处理一个应用程序的业务逻辑,数据校验是你必须
要考虑和面对的事情。应用程序必须通过某种手段来确保输入进来的数据从语义上来讲是正确的。所以便有了前言的使用if
或者封装一些方法来校验入参,但是这依然会有问题:
- 造成大量代码冗余,并且需要通过注释来知道每个入参的约束是什么(否则别人怎么看得懂)
- 每个人做参数验证的方式不一样,参数验证不通过抛出的异常也不一样(后期几乎没法维护)
所以我们先介绍一个最简洁有效的校验方法:JSR303注解校验
2.2 JSR303基本校验规则
注解 | 作用类型 | 解释 |
---|---|---|
@NotNull | 任何类型 | 属性不能为null |
@NotEmpty | 集合 | 集合不能为null,且size大于0 |
@NotBlanck | 字符串、字符 | 字符类不能为null,且去掉空格之后长度大于0 |
@AssertTrue | Boolean、boolean | 布尔属性必须是true |
@Min | 数字类型(原子和包装) | 限定数字的最小值(整型) |
@Max | 同@Min | 限定数字的最大值(整型) |
@DecimalMin | 同@Min | 限定数字的最小值(字符串,可以是小数) |
@DecimalMax | 同@Min | 限定数字的最大值(字符串,可以是小数) |
@Range | 数字类型(原子和包装) | 限定数字范围(长整型) |
@Length | 字符串 | 限定字符串长度 |
@Size | 集合 | 限定集合大小 |
@Past | 时间、日期 | 必须是一个过去的时间或日期 |
@Future | 时期、时间 | 必须是一个未来的时间或日期 |
字符串 | 必须是一个邮箱格式 | |
@Pattern | 字符串、字符 | 正则匹配字符串 |
2.3 Spring Boot中的应用
首先根据经验,和JCache类似Java只提供了规范,并没有提供实现,所以我们可以先找到它的API包然后导入依赖validation-api
,如下图所示。
有了规范,那么我们就需要实现他,所以便有了Hibernate Validation
,导入了hibernate-validator
就没必要再自己导入Java Bean Validation
API了,因此建议不用再手动导入API,交给内部来管理依赖。但是我们使用的Spring Boot非常的贴心,他为我们也封装了一个实现spring-boot-starter-validation
,我们只需要导入spring-boot-starter-web
即可(相当于白说,因为做开发的没有不导这个的把)。
这里有一个坑,说明一下:我们在新建一个Spring Boot 工程的时候,首先第一步pom中引入spring-boot-starter
坐标或者继承spring-boot-starter-parent
工程(推荐这个),然后第二步引入spring-boot-starter-web
声明这是个web工程。但是在Spring Boot3.0.x
以上版本中,spring-boot-starter-web
这个依赖里面移除了spring-boot-starter-validation
,所以我们需要手动导入。这里附上上面所说的依赖的pom。
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.17.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<!-- 不需要做版本管理,springboot已经做了版本管理了-->
</dependency>
校验方式前言代码部分已附,这里就不重新写了,在使用@Valid
或者 @Validated
进行参数校验的时候,必须使用BindingResult
对象(作用是将所有的异常信息存起来),并且@Valid用在参数前,BindingResult作为校验结果绑定返回。
2.4 BindingResult
上面说过了,在使用@Valid
或者 @Validated
进行参数校验的时候,必须使用BindingResult
对象将所有异常信息存起来,注意,这里说的是异常信息。为什么是异常信息呢?大家可以试一下,我这里只以json格式举例:如果只使用@Valid校验注解而不加BindingResult
则会出现MethodArgumentNotValidException
异常,如下图。
JSON:
@PostMapping(value = "/testJavaBeanJson", consumes = MediaType.APPLICATION_JSON_VALUE)
public SysRes<Void> testJavaBeanJson(@Valid @RequestBody PhoenixSaveDTO dttt){
log.info("进来了,{}", dttt);
return SysRes.success();
}
form -data:
@PostMapping(value = "/testJavaBeanFrom", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public SysRes<Void> testJavaBeanFrom(@Valid PhoenixSaveDTO dttt){
log.info("进来了,{}", dttt);
return SysRes.success();
}
所以我们需要使用BindingResult
把异常信息绑定,然后自定义输出。
三、全局统一异常处理(入参校验)
看过了上面内容,我们基本上对JSR303
注解校验有了一定的理解,但是还是有一个问题,在实际开发中,我们基本每个接口都有入参校验,所以如果每个方法都使用前言(JSR303
)这种校验方式,虽然相对于if
要简洁不少,但是这还是不够的,我们还有更加简洁的方法。
从上文我们了解到,@Valid
或者 @Validated
进行参数校验的时候,如果不加BindingResult
那么会抛出异常,如果加上了,那么异常会封装进BindingResult
对象中,所以大家想我们是不是可以通过不加BindingResult
,然后对异常进行统一捕获处理从而达到简化的效果呢??
3.1 @ControllerAdvice 与 @RestControllerAdvice
在spring 3.2中,新增了@ControllerAdvice
注解,学名是Controller增强器,作用是给Controller控制器添加统一的操作或处理,可以用于定义@ExceptionHandler
、@InitBinder
、@ModelAttribute
,并应用到所有@RequestMapping
中。这里对这些不做详解了,可以参考spring的@ControllerAdvice注解 - yanggb - 博客园 (cnblogs.com)。
简单地说,@RestControllerAdvice
与@ControllerAdvice
的区别就和@RestController
与@Controller
的区别类似,@RestControllerAdvice
注解包含了@ControllerAdvice
注解和@ResponseBody
注解。
如果全部异常处理返回json,那么可以使用@RestControllerAdvice
代替 @ControllerAdvice
,这样在方法上就可以不需要添加 @ResponseBody
。
综上,我们可以通过SpringBoot提供的@RestControllerAdvice
和@ControllerAdvice
结合@ExceptionHandler
使用完成异常统一处理,需要捕获什么异常通过@ExceptionHandler
来指定对应异常类就可以了这里原则是按照从小到大异常进行依次执行。
通俗来讲就是当小的异常没有指定捕获时,大的异常包含了此异常就会被执行比如Exception
异常包含了所有异常类,是所有异常超级父类,当出现没有指定异常时此时对应捕获了Exception异常的方法会执行。
3.2 统一入参校验处理(JavaBean)
这里就不多说了,相信看了上面的内容,大家都能理解,所以这里就直接贴代码了。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* JavaBean参数校验
*
* @param e BindException
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/8/31 19:42
*/
@ExceptionHandler(BindException.class)
public SysRes<Void> handlerBindException(BindException e, HttpServletRequest request) {
String requestUri = request.getRequestURI();
List<String> messageList = e.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.toList());
String message = String.valueOf(messageList);
// 参数校验不需要打印过多信息 比如e,只需要知道是哪些有问题即可
log.error("[统一异常处理]请求地址:{}, 参数校验异常:{}", requestUri, message);
return SysRes.fail(SysCode.PARAMS_EXCEPTION, message);
}
}
这个代码和网上很多的不太一样,下面我来解释一下。
对于JavaBean的校验网上有的用MethodArgumentNotValidException
有的用 BindException
, 有的两个都捕获了,相信大家都很迷惑为什么同样的东西使用的方法不一样。
POST
请求参数有form data、JSON等。所以相对于JSON格式的传参,form-data抛出的异常肯定是不一样的,同样的入参校验,如果是JSON格式的POST请求,那么会抛出MethodArgumentNotValidException
,如果是form-data,则会抛出BindException
(2.4有图,大家可以仿着试一下),所以得出初步结论MethodArgumentNotValidException
校验 @RequestBody
的json对象数据,BindException
校验formData数据。
但是这里需要注意,在springboot2.3.0以上版本中,MethodArgumentNotValidException
extends BindException
,BindException extends Exception implements BindingResult
,在2.3.0以下的版本中MethodArgumentNotValidException extends Exception
,BindException extends Exception implements BindingResult
,所以会有form-data入参校验与Json入参校验分开捕获,form-data入参校验与Json入参统一使用bindingresult
捕获两种写法。其实这两种都对,大家根据自己的版本来选择合适的写法即可。
3.3 统一入参校验处理(单参数)
如果参数不满足要求,那么会抛出ConstraintViolationException异常,这个异常只有在单一参数校验的时候抛出,如果你的参数是JavaBean,那么就不是这个异常了
/**
* requestParam单参数校验(需要在类上面加校验注解 方法上面加不管用的)
* ConstraintViolationException extends ValidationException
*
* @param exception ValidationException
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/8/31 18:02
*/
@ExceptionHandler(ConstraintViolationException.class)
public SysRes<Void> handlerValidationException(ConstraintViolationException exception, HttpServletRequest request) {
String requestUri = request.getRequestURI();
Set<ConstraintViolation<?>> constraintViolations = exception.getConstraintViolations();
String validateMsg = String.valueOf(constraintViolations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.toList()));
log.error("[统一异常处理]请求地址:{}, 参数校验异常:{}", requestUri, validateMsg);
return SysRes.fail(SysCode.PARAMS_EXCEPTION, validateMsg);
}
以下是controller校验代码
/**
* 测试
*
* @author 伍六柒
* @since 2021/8/31 19:54
*/
@Slf4j
@Validated //单参数校验(需要在类上面加校验注解 方法上面加不管用的)
@RestController
@RequestMapping("/test")
public class TestController {
@PostMapping(value = "/testJavaBeanJson", consumes = MediaType.APPLICATION_JSON_VALUE)
public SysRes<Void> testJavaBeanJson(@Valid @RequestBody PhoenixSaveDTO dttt){
log.info("进来了,{}", dttt);
return SysRes.success();
}
@PostMapping(value = "/testJavaBeanFrom", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public SysRes<Void> testJavaBeanFrom(@Valid PhoenixSaveDTO dttt){
log.info("进来了,{}", dttt);
return SysRes.success();
}
@GetMapping("/testParams")
public SysRes<Void> testParams(@RequestParam(value = "id") @NotBlank(message = "id为空") String id){
log.info("进来了,{}",id);
return SysRes.success();
}
}
四、全局统一异常处理(其余异常)
有了上面的经验,那么我们处理其他自定义异常就游刃有余了。下面我只举几个我自己常用的,大家如果有特殊需求可以自己封装。
/**
* 全局异常处理
*
* 1.@RestControllerAdvice( @ResponseBody + @ControllerAdvice ):
* 如果使用@ControllerAdvice 则需要给每个方法添加@ResponseBody
* 它是一个Controller增强器,可对controller中被@RequestMapping注解的方法加一些逻辑处理。最常用的就是异常处理
* 需要配合@ExceptionHandler使用。当将异常抛到controller时,可以对异常进行统一处理,规定返回的json格式或是跳转到一个错误页面
* 2.@ExceptionHandler:
* (1)用来统一处理防范抛出的异常
* (2)被@ExceptionHandler注解的方法就会处理被@RequestMapping注解抛出的异常。
* (3)可添加参数:某个异常类的class,代表该方法专门处理该异常类
* (4)就近原则:
* 比如:NumberFormatException,这个异常有父类RuntimeException,
* RuntimeException还有父类Exception,如果我们分别定义异常处理方法,
* @ ExceptionHandler分别使用这三个异常作为参数,会依次去匹配对应的异常处理类
* (5)返回值类型和处理@RequestMapping的方法是统一的,我们也可以添加@ResponseBody注解,直接返回字符串,
* 否则默认返回Spring的ModelAndView对象,这时的String是ModelAndView的路径,而不是字符串本身。
* (6)使用@ExceptionHandler时尽量不要使用相同的注解参数,即同样的异常不要用两个专门的方法去处理。
* 编译可以通过,但是当抛出该异常的时候,spring会报错:
* java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [class java.lang.NumberFormatException]:
* {public java.lang.String TestController.handlerException(java.lang.Exception),
* public java.lang.String TestController.handlerException2(java.lang.Exception)}
* 3.异常体系:
* (1)Object
* (2)Throwable
* 2.1: Error
* 2.2:Exception
*
* @author 伍六柒
* @since 2021/8/31 15:06
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 请求路径异常,这里有坑,需要修改springMVC静态资源的默认路径
*
* @param e NoHandlerFoundException
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/9/1 14:17
*/
@ExceptionHandler(NoHandlerFoundException.class)
public SysRes<Void> noHandlerFoundException(NoHandlerFoundException e) {
log.error("[统一异常捕获处理][请求资源不存在]请求地址:{}", e.getRequestURL());
return SysRes.fail(SysCode.NOT_FOUND_EXCEPTION, e.getRequestURL());
}
/**
* 请求方法不支持
*
* @param e HttpRequestMethodNotSupportedException
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/8/31 17:43
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public SysRes<Void> handlerHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException e,
HttpServletRequest request) {
String requestUri = request.getRequestURI();
log.error("[统一异常捕获处理][不支持该请求方法]请求地址:{},请求方式:{},接口支持方式:{}",
requestUri, e.getMethod(), e.getSupportedMethods());
return SysRes.fail(SysCode.METHOD_NOT_ALLOWED_EXCEPTION, e.getMethod());
}
/**
* 请求类型错误Content-Type/Accept
*
* @param e HttpMessageNotReadableException
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/8/31 17:43
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public SysRes<Void> handlerHttpMessageNotReadable(HttpMediaTypeNotSupportedException e, HttpServletRequest request) {
String requestUri = request.getRequestURI();
String requestContentType = String.valueOf(e.getContentType());
log.error("[统一异常处理]请求地址:{}, 请求类型:{}, 接口支持的类型:{}",
requestUri, requestContentType, e.getSupportedMediaTypes());
return SysRes.fail(SysCode.BAD_REQUEST_EXCEPTION, requestContentType);
}
/**
* 请求参数不可读异常
* (HttpMessageNotReadableException 和 TypeMismatchException 都继承了 NestedRuntimeException)
*
* @param e TypeMismatchException
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/8/31 17:45
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public SysRes<Void> handlerTypeMismatch(HttpMessageNotReadableException e, HttpServletRequest request) {
String requestUri = request.getRequestURI();
log.error("[统一异常处理]请求地址:{}, 属性名称:{}", requestUri, e.getSuppressed());
return SysRes.fail(SysCode.TYPE_MISMATCH_EXCEPTION);
}
/**
* 系统业务异常
*
* @param e BusinessException
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/9/4 17:17
*/
@ExceptionHandler(BusinessException.class)
public SysRes<Void> businessException(BusinessException e, HttpServletRequest request) {
String requestUri = request.getRequestURI();
log.error("[统一异常处理]请求地址:{}", requestUri, e);
return SysRes.fail(SysCode.BUSINESS_ERROR);
}
/**
* 系统内部错误异常
*
* @param e Exception
* @param request HttpServletRequest
* @return SysRes<Void>
* @author 伍六柒
* @since 2021/9/4 17:17
*/
@ExceptionHandler(Exception.class)
public SysRes<Void> unNoException(Exception e, HttpServletRequest request) {
String requestUri = request.getRequestURI();
log.error("[统一异常处理]请求地址:{}", requestUri, e);
return SysRes.fail(SysCode.INTERNAL_SERVER_ERROR);
}
}
上面需要注意一下路径异常-404会不生效,需要在yml中配置
spring: mvc: static-path-pattern: /statics throw-exception-if-no-handler-found: true
五、总结
最后小小的总结一下有些在上面没有提到的:
1.首先不建议大家在处理过程中,使用e.printStackTrace()
打印日志,因为这是控制台打印日志,只能在控制台输出,但是如果在开发过程中,我们的项目部署在服务器上,同时我们对日志进行了分片(时间或日志文件大小),那么只有在总日志上才可以看到异常的详细信息,在分片日志是看不到详细信息的,我们只能知道出了个啥异常,不知道具体是怎么出现的,而且这个可能会导致锁死。所以我个人一般都是直接在log中打印e
,这个e
打印不需要占位符。
2.其次打印异常信息的时候,不要所有的异常都打印全部信息,只需要打印自己需要的东西即可,比如我们入参校验、请求方式异常等我们不需要打印异常的详细信息。
3.不要过度封装,比如我上面的请求路径异常、请求方法异常、请求类型错误这些异常都是有明确的HTTP规范的(路径异常-404,方法异常-405,其他的可自行查阅HTTP code码),人家规范好了你不去遵守非得多此一举写一个,试想这种情况下,如果和你合作的前端人家把http的异常按规范已经处理好了,但是你把人家规范改了,这就很容易。。。(特殊业务需求或者自己玩一下另说)。我写那几个的目的主要是前端那边没有明确封装各个http的code,同时为了方便调试才写的。