掌握 Spring 之異常處理

  • 2019 年 10 月 5 日
  • 筆記

前言

這次我們學習 Spring 的異常處理,作為一個 Spring 為基礎框架的 Web 程式,如果不對程式中出現的異常進行適當的處理比如異常資訊友好化,記錄異常日誌等等,直接將異常資訊返回給客戶端展示給用戶,對用戶體驗有不好的影響。所以本篇文章主要探討通過 Spring 進行統一異常處理的幾種方式實現,以更優雅的方式捕獲程式發生的異常資訊並進行適當的處理響應給客戶端。

本文主要內容涉及如下:

  • HandlerExceptionResolver 擴展
  • @ExceptionHandler@ControllerAdvice 使用
  • ResponseEntityExceptionHandler 擴展
  • ResponseStatusException 使用
  • Spring Boot ErrorController 擴展

示例項目:

  • spring-exception-handler: https://github.com/wrcj12138aaa/spring-exception-handler

環境支援:

  • JDK 8
  • SpringBoot 2.1.4
  • Maven 3.6.0

正文

Spring 框架的異常處理提供了許多種方式,在 Spring 3.2 之前主要有兩種處理方式:擴展 HandlerExceptionResolver 和 使用註解 @ExceptionHandler,Spring 3.2 之後提供了更豐富的處理方式。

HandlerExceptionResolver 擴展

HandlerExceptionResolver 是一個處理 Web 程式發生異常時的介面,介面方法如下:

@Nullable  ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);  

從返回值類型 ModelAndView 可以看出,這個屬於 Spring MVC 框架中的介面,實現此方法就可以對捕獲的異常進行解析處理,然後根據自身需要返回 ModelAndView 對象,以 JSON 數據或者頁面形式響應客戶端請求。

首先來看下 HandlerExceptionResolver 類層次體系,Spring 提供了 4 個實現類,下面根據這些類做了簡單的描述。

HandlerExceptionResolver 類體系

映射異常類到指定視圖,一般用於展現異常發生時的錯誤頁面

當我們需要實現自定義的 HandlerExceptionResolver時,只要通過繼承它的抽象類 AbstractHandlerExceptionResolver,覆寫 doResolveException方法就可以了。

下方的示例程式碼處理了程式中發生的 IllegalArgumentException 異常時的情況,並通過 MappingJackson2JsonView 對象返回客戶端一個 JSON 數據對象。如果不是 IllegalArgumentException異常,返回 null 表示讓其他異常處理器進行處理,這裡由於異常處理鏈機制,如果不處理異常,就會由 Web 容器將異常返回給客戶端。

@Component  public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {        @Override      protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {          try {              if (ex instanceof IllegalArgumentException) {                  ModelAndView modelAndView = new ModelAndView();                  Map<String, String> maps = new HashMap<>();                  maps.put("code", "400");                  maps.put("message", ex.getClass().getName());                  maps.put("data", null);                  MappingJackson2JsonView mappingJackson2JsonView = new MappingJackson2JsonView();                  mappingJackson2JsonView.setAttributesMap(maps);                  modelAndView.setView(mappingJackson2JsonView);                  return modelAndView;              }          } catch (Exception handlerException) {              logger.warn("Handling of [" + ex.getClass().getName() + "] resulted in Exception", handlerException);          }          return null;      }    }  

我們使用 Postman 工具模擬請求項目的 API 介面 /exception1 來導致異常的觸發,正常可以看到如下效果:

image-20190518131151510

@ExceptionHandler

接下來我們看下 @ExceptionHandler 的用法,這個註解通常定義在某個控制器下的方法里,表明處理該控制器出現的指定異常, 如下程式碼所示:

@RestController  public class RestApiController {      //...        @ExceptionHandler({IllegalStateException.class})      public ModelAndView handleIllegalStateException(IllegalStateException ex) {          System.out.println("非法狀態異常出現,需要處理 " + ex.getMessage());          ModelAndView modelAndView = new ModelAndView();          Map<String, String> maps = new HashMap<>();          maps.put("data", null);          maps.put("message", ex.getClass().getName());          maps.put("code", "400");          MappingJackson2JsonView mappingJackson2JsonView = new MappingJackson2JsonView();          mappingJackson2JsonView.setAttributesMap(maps);          modelAndView.setView(mappingJackson2JsonView);          return modelAndView;      }  }  

@ExceptionHandler 可以設置多個需要捕獲處理的異常類型,也可以不填默認為所有異常類,更多資訊可以查看 mvc-ann-exceptionhandler

然後使用 Postman 工具模擬請求項目的 API 介面 /exception2 來觸發異常,看下響應數據:

image-20190518134744575

這樣方式使用 @ExceptionHandler 存在一個缺陷,就是只會針對當前控制器下的異常處理,若需要實現全局控制器的異常處理,還需要配合註解 @ControllerAdvice 一起使用,接下來就介紹這個處理方式。

@ControllerAdvice

Spring 3.2 引入了一種新註解 @ControllerAdvice,用於將所有控制器中異常的處理放在一處進行,將指定一個類作為全局異常處理器,用 @ExceptionHandler 註解標註的方法去處理異常,具體示例程式碼如下:

@ControllerAdvice  public class NormalExceptionHandler {      @ExceptionHandler()      public ResponseEntity handleException(Exception e) {          System.out.println("NormalExceptionHandler handle exception");          return ResponseEntity.ok(new Result<>(400, e.getMessage(), null));      }  }  

程式碼中的 Result 對象只是一個數據傳輸對象 (DTO),便於返回客戶端統一格式的數據。

再來看下使用 Postman 工具模擬請求 API 介面 /exception3 響應的數據,見下圖。

image-20190518144403940

還有一個註解 @RestControllerAdvice@ControllerAdvice 很相似,其實就是 @ControllerAdvice@ResponseBody註解的組合,效果就是異常處理方法返回的對象,直接就會被序列化成 JSON 數據給客戶端,使用方式如下:

@RestControllerAdvice  public class RestExceptionHandler {      @ExceptionHandler({ArithmeticException.class})      public Result handlerException(Exception e) {          return new Result<>(400, e.getMessage(), null);      }  }  

這個註解是在 Spring 4.3 版本引入的,主要就是便於針對 REST 請求異常時直接返回 JSON 格式的數據,而不使用 ResponseEntity 對象方式傳遞數據。

@ControllerAdvice 默認攔截所有控制器中發生的異常,當然也可以限定範圍,限定方式有限定註解,包名等,具體示例如下:

// Target all Controllers annotated with @RestController  @ControllerAdvice(annotations = RestController.class)  public class ExampleAdvice1 {}    // Target all Controllers within specific packages  @ControllerAdvice("org.example.controllers")  public class ExampleAdvice2 {}    // Target all Controllers assignable to specific classes  @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})  public class ExampleAdvice3 {}  

對於 全局 @ExceptionHandler 方法處理的描述,官方文檔還有額外的備註如下:

Global @ExceptionHandler methods (from a @ControllerAdvice) are applied after local ones (from the @Controller).

這表明了異常處理也存在優先順序,先交給當前控制器內的 @ExceptionHandler方法處理,若未處理再由全局的@ExceptionHandler 方法處理。

ResponseEntityExceptionHandler 擴展

ResponseEntityExceptionHandler 類是主要針對 Spring MVC 所拋出異常的處理類,比如 405 請求,400 請求等,都默認由 ResponseEntityExceptionHandler處理,我們可以過繼承這個類覆寫它的方法,來實現特定請求異常的處理。比如下面程式碼實現對 405 請求異常的響應處理。

@@ControllerAdvice  public class CustomWebResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {      @Override      protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {          switch (status) {              case METHOD_NOT_ALLOWED:                  return getMethodNotAllowedResponse(request);              default:                  return ResponseEntity.ok(new Result<>(status.value(), status.getReasonPhrase(), null));          }      }        public ResponseEntity getMethodNotAllowedResponse(WebRequest request) {          String uri = "";          if (request instanceof ServletWebRequest) {              uri = ((ServletWebRequest) request).getRequest().getRequestURI();          }          Result<Object> result = new Result<>();          result.setCode(HttpStatus.METHOD_NOT_ALLOWED.value());          result.setMessage(uri + " 請求方式不正確");          return ResponseEntity.ok(result);      }  }  

通過這樣的方式,我們嘗試發送 GET 請求給 API 介面/hello,會有如下返回資訊:

image-20190518162624412

當時 ResponseEntityExceptionHandler 也存在局限性,目前支援的 SpringMVC 標準異常只有下面 15 種異常類型:

  • HttpRequestMethodNotSupportedException
  • HttpMediaTypeNotSupportedException
  • HttpMediaTypeNotAcceptableException
  • MissingPathVariableException
  • MissingServletRequestParameterException
  • ServletRequestBindingException
  • ConversionNotSupportedException
  • TypeMismatchException
  • HttpMessageNotReadableException
  • HttpMessageNotWritableException
  • MethodArgumentNotValidException
  • MissingServletRequestPartException
  • BindException
  • NoHandlerFoundException
  • AsyncRequestTimeoutException

ResponseStatusException

ResponseStatusException類是在 Spring 5.0 引入,關聯 HTTP 狀態碼和可選的原因,我們直接就可以在請求方法中構建這個異常對象進行返回,使用起來十分簡單:

@GetMapping("/exception4")  public ResponseEntity<String> exception4(String param) {      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "資源未找到");  }  

使用這種方式雖然能直接返迴響應碼和具體原因,但是沒有統一處理異常的效果,通常配合 @ControllerAdvice 一起組合使用。

Spring Boot ErrorController

ErrorController 是 Spring Boot 2.0 引入介面,基於此的實現類 org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController 為我們提供了一種通用的方式進行錯誤處理, 下面是這個實現類的關鍵方法:

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)  public ModelAndView errorHtml(HttpServletRequest request,          HttpServletResponse response) {      HttpStatus status = getStatus(request);      Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(              request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));      response.setStatus(status.value());      ModelAndView modelAndView = resolveErrorView(request, response, status, model);      return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);  }    @RequestMapping  public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {      Map<String, Object> body = getErrorAttributes(request,              isIncludeStackTrace(request, MediaType.ALL));      HttpStatus status = getStatus(request);      return new ResponseEntity<>(body, status);  }  

可以從這兩個方法看出針對錯誤請求,BasicErrorController 提供了兩種數據形式的返回,一種是 HTML 頁面,一種是 JSON 數據;如果我們直接使用瀏覽器訪問介面的話見到的就是 errorHtml方法返回的 HTML 頁面數據,它們的區別就在於請求時 Header 里 Accept 值的不同。

image-20190518170154527

另外,Spring Boot 提供統一錯誤資訊處理,是允許關閉的,只要在配置文件 application.properties 設置 server.error.whitelabel.enabledfalse即可。

server.error.whitelabel.enabled=false  

當然我們也可以基於此進行擴展,比如實現一個自定義的錯誤控制器,繼承 BasicErrorController,編寫自己的錯誤展示邏輯和內容,比如下面程式碼:

@Component  public class CustomErrorController extends BasicErrorController {        public CustomErrorController(ErrorAttributes errorAttributes) {          super(errorAttributes, new ErrorProperties());      }        @RequestMapping(produces = MediaType.APPLICATION_XML_VALUE)      public ResponseEntity<Map<String, Object>> xmlError(HttpServletRequest request, HttpStatus status) {          Map<String, Object> map = new HashMap<>();          map.put("code", status.value());          map.put("message", status.getReasonPhrase());          return ResponseEntity.ok(map);      }  }  

實現的 CustomErrorController 針對請求時 Aceept 為 application/xml的發生的異常都統一以 XML 格式進行返回,如圖:

image-20190518171944860

注意: Spring Boot 默認不支援數據進行 XML 格式的轉換,POM 文件需要額外添加依賴庫:

<dependency>        <groupId>com.fasterxml.jackson.dataformat</groupId>        <artifactId>jackson-dataformat-xml</artifactId>  </dependency>  

結語

本文我們主要學習了 Spring 框架 5 種異常處理的方式以及 Spring Boot 的通用異常處理行為,形式多樣,但具體情況需要具體訂製,為了保證程式的健壯性和便於快速定位請求出現的異常問題,我們必須為程式提供統一的異常處理方式,也在平時的項目里使用起來吧。

如果讀完覺得有收穫的話,歡迎點【好看】,點擊文章頭圖,掃碼關注【聞人的技術部落格】???。

參考

  • Spring Boot 中 Web 應用的統一異常處理 : http://blog.didispace.com/springbootexception
  • Error Handling for REST with Spring : https://www.baeldung.com/exception-handling-for-rest-with-spring
  • Spring REST Service Exception Handling https://dzone.com/articles/spring-rest-service-exception-handling-1
  • mvc-ann-exceptionhandler:https://docs.spring.io/spring/docs/5.1.6.RELEASE/spring-framework-reference/web.html#mvc-ann-exceptionhandler
  • spring-boot-return-json-and-xml-from-controllers: https://stackoverflow.com/questions/27790998/spring-boot-return-json-and-xml-from-controllers
  • Spring Web MVC Exceptions : https://docs.spring.io/spring/docs/5.1.6.RELEASE/spring-framework-reference/web.html#mvc-exceptionhandlers