Spring Boot2從入門到實戰:統一異常處理

【空山新雨】發表於2019-07-04

都說管理的精髓就是“制度管人,流程管事”。而所謂流程,就是對一些日常工作環節、方式方法、次序等進行標準化、規範化。且不論精不精髓,在技術團隊中,對一些通用場景,統一規範是必要的,只有步調一致,才能高效向前。如前後端互動協議,如本文探討的異常處理。

 

1. Spring Mvc中的異常處理

在spring mvc中,跟異常處理的相關類大致如下

springmvn異常處理類

 

上圖中,spring mvc中處理異常的類(包括在請求對映時與請求處理過程中丟擲的異常),都是 HandlerExceptionResolver 介面的實現,並且都實現了 Ordered 介面。與攔截器鏈類似,如果容器中存在多個實現了 HandlerExceptionResolver 介面的異常處理類,則它們的 resolveException 方法會被依次呼叫,順序由order決定,值越小的先執行,只要其中一個呼叫返回不是null,則後續的異常處理將不再執行。

各實現類簡單介紹如下:

  • DefaultHandlerExceptionResolver: 這個是預設實現,處理Spring定義的各種標準異常,將其轉換為對應的Http Status Code,具體處理的異常參考 doResolveException 方法
  • ResponseStatusExceptionResolver:用來支援@ResponseStatus註解使用的實現,如果自定義的異常通過@ResponseStatus註解進行了修飾,並且容器中存在ResponseStatusExceptionResolver的bean,則自定義異常丟擲時會被該bean進行處理,返回註解定義的Http Status Code及內容給客戶端
  • ExceptionHandlerExceptionResolver:用來支援@ExceptionHandler註解使用的實現,使用該註解修飾的方法來處理對應的異常。不過該註解的作用範圍只在controller類,如果需要全域性處理,則需要配合@ControllerAdvice註解使用。
  • SimpleMappingExceptionResolver:將異常對映為檢視
  • HandlerExceptionResolverComposite:就是各類實現的組合,依次執行,只要其中一個處理返回不為null,則不再處理。

因為本文主要是對spring boot如何對異常統一處理進行探討,所以以上只對各實現做了基本介紹,更加詳細的內容可查閱相關文件或後續再補上。

2. Spring Boot中如何統一異常處理

通過第一部分介紹,可以使用@ExceptionHandler + @ControllerAdvice 組合的方式來實現異常的全域性統一處理。對於REST服務來說,spring mvc提供了一個抽象類 ResponseEntityExceptionHandler, 該類類似於上面介紹的 DefaultHandlerExceptionResolver,對一些標準的異常進行了處理,但不是返回 ModelAndView物件, 而是返回 ResponseEntity物件。故我們可以基於該類來實現REST服務異常的統一處理
定義異常處理類 BaseWebApplicationExceptionHandler 如下:

@RestControllerAdvice
public class BaseWebApplicationExceptionHandler extends ResponseEntityExceptionHandler {

    private boolean includeStackTrace;

    public BaseWebApplicationExceptionHandler(boolean includeStackTrace){
        super();
        this.includeStackTrace = includeStackTrace;
    }

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @ExceptionHandler(BizException.class)
    public ResponseEntity<Object> handleBizException(BizException ex) {
        logger.warn("catch biz exception: " + ex.toString(), ex.getCause());
        return this.asResponseEntity(HttpStatus.valueOf(ex.getHttpStatus()), ex.getErrorCode(), ex.getErrorMessage(), ex);
    }

    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    public ResponseEntity<Object> handleIllegalArgumentException(Exception ex) {
        logger.warn("catch illegal exception.", ex);
        return this.asResponseEntity(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name().toLowerCase(), ex.getMessage(), ex);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleException(Exception ex) {
        logger.error("catch exception.", ex);
        return this.asResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.name().toLowerCase(), ExceptionConstants.INNER_SERVER_ERROR_MSG, ex);
    }

    protected ResponseEntity<Object> handleExceptionInternal(
            Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {

        if (HttpStatus.INTERNAL_SERVER_ERROR.equals(status)) {
            request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST);
        }
        logger.warn("catch uncustom exception.", ex);
        return this.asResponseEntity(status, status.name().toLowerCase(), ex.getMessage(), ex);
    }

    protected ResponseEntity<Object> asResponseEntity(HttpStatus status, String errorCode, String errorMessage, Exception ex) {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put(BizException.ERROR_CODE, errorCode);
        data.put(BizException.ERROR_MESSAGE, errorMessage);
        //是否包含異常的stack trace
        if(includeStackTrace){
            addStackTrace(data, ex);
        }
        return new ResponseEntity<>(data, status);
    }

    private void addStackTrace(Map<String, Object> errorAttributes, Throwable error) {
        StringWriter stackTrace = new StringWriter();
        error.printStackTrace(new PrintWriter(stackTrace));
        stackTrace.flush();
        errorAttributes.put(BizException.ERROR_TRACE, stackTrace.toString());
    }
}

 

這裡有幾點: 

  1. 定義了一個includeStackTrace變數,來控制是否輸出異常棧資訊
  2. 自定義了一個異常類BizException,表示可預知的業務異常,並對它提供了處理方法,見handleBizException方法
  3. 對其它未預知異常,用Exception型別進行最後處理,見handleException方法
  4. 重寫了超類的handleExceptionInternal方法,統一響應內容的欄位與格式
  5. 針對REST服務,使用的是@RestControllerAdvice註解,而不是@ControllerAdvice

BaseWebApplicationExceptionHandler是通過增強的方式對controller丟擲的異常做了統一處理,那如果請求都沒有到達controller怎麼辦,比如在過濾器那邊就拋異常了,Spring Boot其實對錯誤的處理做了一些自動化配置,參考ErrorMvcAutoConfiguration類,具體這裡不詳述,只提出方案——自定義ErrorAttributes實現,如下所示

public class BaseErrorAttributes extends DefaultErrorAttributes {

    private boolean includeStackTrace;

    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
        Map<String, Object> errorAttributes = new LinkedHashMap<String, Object>();
        addStatus(errorAttributes, webRequest);
        addErrorDetails(errorAttributes, webRequest, this.includeStackTrace);
        return errorAttributes;
    }

 

以上只列出了主要部分,具體實現可參考原始碼。這裡同樣定義了includeStackTrace來控制是否包含異常棧資訊。 

最後,將以上兩個實現通過配置檔案注入容器,如下:

@Configuration
@ConditionalOnClass({Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class})
@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
@AutoConfigureBefore(ErrorMvcAutoConfiguration.class)
public class ExceptionHandlerAutoConfiguration {
    @Profile({"test", "formal", "prod"})
    @Bean
    public ResponseEntityExceptionHandler defaultGlobalExceptionHandler() {
        //測試、正式環境,不輸出異常的stack trace
        return new BaseWebApplicationExceptionHandler(false);
    }

    @Profile({"default","local","dev"})
    @Bean
    public ResponseEntityExceptionHandler devGlobalExceptionHandler() {
        //本地、開發環境,輸出異常的stack trace
        return new BaseWebApplicationExceptionHandler(true);
    }

    @Profile({"test", "formal", "prod"})
    @Bean
    public ErrorAttributes basicErrorAttributes() {
        //測試、正式環境,不輸出異常的stack trace
        return new BaseErrorAttributes(false);
    }

    @Profile({"default","local","dev"})
    @Bean
    public ErrorAttributes devBasicErrorAttributes() {
        //本地、開發環境,輸出異常的stack trace
        return new BaseErrorAttributes(true);
    }
}

 

上面的@Profile主要是控制針對不同環境,輸出不同的響應內容。以上配置的意思是在profile為default、local、dev時,響應內容中包含異常棧資訊;profile為test、formal、prod時,響應內容不包含異常棧資訊。這麼做的好處是,開發階段,當前端聯調時,如果出錯,可直接從響應內容中看到異常棧,方便服務端開發人員快速定位問題,而測試、生產環境, 就不要返回異常棧資訊了。 

3. 基於Spring Boot的異常處理規範

  1. 異常的表示形式
    異常一般可通過自定義異常類,或定義異常的資訊,比如code,message之類,然後通過一個統一的異常類進行封裝。如果每一種異常都定義一個異常類,則會造成異常類過多,所以實踐開發中我一般傾向於後者。
    可以定義一個介面,該介面主要是方便後面的異常處理工具類實現
    public interface BaseErrors {
        String getCode();
    
        String getMsg();
    }

     

  2. 然後定義一個列舉,實現該介面,在該列舉中定義異常資訊,如
    public enum ErrorCodeEnum implements BaseErrors {
        qrcode_existed("該公眾號下已存在同名二維碼"),
        authorizer_notexist("公眾號不存在"),
       
        private String msg;
      
        private ErrorCodeEnum(String msg) {
            this.msg = msg;
        }
      
        public String getCode() {
            return name();
        }
      
        public String getMsg() {
            return msg;
        }
    }

     

  3. 封裝異常處理 
    分場景定義了ClientSideException,ServerSideException,UnauthorizedException,ForbiddenException異常,分別表示客戶端異常(400),服務端異常(500),未授權異常(401),禁止訪問異常(403),如ClientSideException定義
    public class ClientSideException extends BizException {
    
        public <E extends Enum<E> & BaseErrors> ClientSideException(E exceptionCode, Throwable cause) {
            super(HttpStatus.BAD_REQUEST, exceptionCode, cause);
        }
    
        public <E extends Enum<E> & BaseErrors> ClientSideException(E exceptionCode) {
            super(HttpStatus.BAD_REQUEST, exceptionCode, null);
        }
    }

    並且提供一個異常工具類ExceptionUtil,方便不同場景使用,

    • rethrowClientSideException:丟擲ClientSideException,將以status code 400返回客戶端。由客戶端引起的異常呼叫該方法,如引數校驗失敗。
    • rethrowUnauthorizedException: 丟擲UnauthorizedException,將以status code 401返回客戶端。訪問未授權時呼叫,如token校驗失敗等。
    • rethrowForbiddenException: 丟擲ForbidenException,將以status code 403返回客戶端。訪問被禁止時呼叫,如使用者被禁用等。
    • rethrowServerSideException: 丟擲ServerSideException,將以status code 500返回客戶端。服務端引起的異常呼叫該方法,如呼叫第三方服務異常,資料庫訪問出錯等。

在實際使用時,分兩種情況,

  1. 不通過try/catch主動丟擲異常,如:

    if (StringUtils.isEmpty(appId)) {
        LOG.warn("the authorizer for site[{}] is not existed.", templateMsgRequestDto.getSiteId());
        ExceptionUtil.rethrowClientSideException(ErrorCodeEnum.authorizer_notexist);
    }

     

  2. 通過try/catch異常重新丟擲(注意:可預知的異常,需要給客戶端返回某種提示資訊的,必須通過該方式重新丟擲。否則將返回統一的code 500,提示“抱歉,服務出錯了,請稍後重試”的提示資訊)如:

    try {
        String result = wxOpenService.getWxOpenComponentService().getWxMpServiceByAppid(appId).getTemplateMsgService().sendTemplateMsg(templateMessage);
        LOG.info("result: {}", result);
    } catch (WxErrorException wxException) {
        //這裡不需要打日誌,會統一在異常處理裡記錄日誌
        ExceptionUtil.rethrowServerSideException(ExceptionCodeEnum.templatemsg_fail, wxException);
    }

     

具體實現參考原始碼: https://github.com/ronwxy/base-spring-boot/tree/master/spring-boot-autoconfigure/src/main/java/cn/jboost/springboot/autoconfig/error
另附demo原始碼:https://github.com/ronwxy/springboot-demos/tree/master/springboot-error

4. 總結

本文寫完感覺資訊量有點多,對於不具備一定基礎的人來說理解可能有點難度。如果有任何疑問,歡迎交流。後續有需要的話也可以針對某個環節再進行細化補充。本文所提的規範不一定是最好的實踐,但規範或流程的管理,都是遵循先僵化,後優化,再固化的步驟,先解決有沒有的問題,再解決好不好的問題。


我的個人部落格地址:http://blog.jboost.cn
我的github地址:https://github.com/ronwxy
我的微信公眾號:jboost-ksxy (一個不只有技術乾貨的公眾號,歡迎關注)
——————————————————————————————————————————————————
微信公眾號
歡迎關注我的微信公眾號,及時獲取最新分享

相關文章