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,同時為了方便調試才寫的。