都說管理的精髓就是“制度管人,流程管事”。而所謂流程,就是對一些日常工作環節、方式方法、次序等進行標準化、規範化。且不論精不精髓,在技術團隊中,對一些通用場景,統一規範是必要的,只有步調一致,才能高效向前。如前後端互動協議,如本文探討的異常處理。
1. Spring Mvc中的異常處理
在spring mvc中,跟異常處理的相關類大致如下
上圖中,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()); } }
這裡有幾點:
- 定義了一個includeStackTrace變數,來控制是否輸出異常棧資訊
- 自定義了一個異常類BizException,表示可預知的業務異常,並對它提供了處理方法,見handleBizException方法
- 對其它未預知異常,用Exception型別進行最後處理,見handleException方法
- 重寫了超類的handleExceptionInternal方法,統一響應內容的欄位與格式
- 針對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的異常處理規範
- 異常的表示形式
異常一般可通過自定義異常類,或定義異常的資訊,比如code,message之類,然後通過一個統一的異常類進行封裝。如果每一種異常都定義一個異常類,則會造成異常類過多,所以實踐開發中我一般傾向於後者。
可以定義一個介面,該介面主要是方便後面的異常處理工具類實現
public interface BaseErrors { String getCode(); String getMsg(); }
- 然後定義一個列舉,實現該介面,在該列舉中定義異常資訊,如
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; } }
- 封裝異常處理
分場景定義了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返回客戶端。服務端引起的異常呼叫該方法,如呼叫第三方服務異常,資料庫訪問出錯等。
在實際使用時,分兩種情況,
-
不通過try/catch主動丟擲異常,如:
if (StringUtils.isEmpty(appId)) { LOG.warn("the authorizer for site[{}] is not existed.", templateMsgRequestDto.getSiteId()); ExceptionUtil.rethrowClientSideException(ErrorCodeEnum.authorizer_notexist); }
-
通過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 (一個不只有技術乾貨的公眾號,歡迎關注)
——————————————————————————————————————————————————
歡迎關注我的微信公眾號,及時獲取最新分享