前言
全局的異常處理是Java后端不可或缺的一部分,可以提高代碼的健壯性和可維護性。
在我們的開發中,總是難免會碰到一些未經處理的異常,假如沒有做全局異常處理,那么我們返回給用戶的信息應該是不友好的,很抽象的,用戶會認為我們的程序是不安全的。
相反,如果有了全局異常處理,那么我們就可以給用戶提供更友好的反饋。
我們甚至可以把全局異常處理寫到簡歷上,比如說你可以這樣描述:項目采用了 HandlerExceptionResolver(或者 ControllerAdvice 方案)的全局異常處理策略,提高了代碼的健壯性和可維護性,優化了用戶體驗。
我會結合具體的業務場景給大家一種身臨其境的感覺(@),講一講HandlerExceptionResolver 和 ControllerAdvice 具體怎么在項目中使用。
業務場景
技術派整合了Redis,比如用戶登錄的時候會從Redis中獲取緩存,那假如我們沒有啟動Redis服務呢?
然后我們在本地啟動技術派的服務端。
然后點擊登錄 ->一鍵登錄。
然后就會收到這樣一條提示信息。
由于我們項目是開源的,所以這里就直接把服務端的信息返回出來,好讓大家第一時間辨別出是哪里出了問題,可以及時去調整。
當你看到這樣一條錯誤提示,第一時間就能明白,哦,原來是 Redis 沒有啟動啊。
在服務器端的控制臺面板中(錯誤堆棧信息中),可以找到對應的錯誤信恙。
其中 ForumExceptionHandler 就是用來進行全局異常處理的,它是HandlerExceptionResolver 接囗的實現類
HandlerExceptionResolver
HandlerExceptionResolver 是 Spring 提供的一種異常處理機制,它允許我們在應用程序中以統一的方式處理控制器方法引發的異常。
要使用 HandlerExceptionResolver,我們需要創建一個實現該接口的類,并在其中定義如何處理異常。例如:
@Slf4j
@Order(-100)
public class ForumExceptionHandler implements HandlerExceptionResolver {@Overridepublic ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {}
- @Slf4j 是 lombok 提供的一個日志注解。
- @0rder 注解用于指定 Spring 中組件的加載順序。它接受一個整數值,數值越小,組件的優先級越高,加載順序越靠前。
- 在 resolveException 方法中,我們可以自定義異常處理邏輯,根據異常類型返回不同的 ModelAndView。
我們來看一下 resolveException 方法中的具體寫法:
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {Status errStatus = buildToastMsg(ex);if (restResponse(request, response)) {// 表示返回json數據格式的異常提示信息if (response.isCommitted()) {// 如果返回已經提交過,直接退出即可return new ModelAndView();}try {response.reset();// 若是rest接口請求異常時,返回json格式的異常數據;而不是專門的500頁面response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);response.setHeader("Cache-Control", "no-cache, must-revalidate");response.getWriter().println(JsonUtil.toStr(ResVo.fail(errStatus)));response.getWriter().flush();response.getWriter().close();return new ModelAndView();} catch (Exception e) {throw new RuntimeException(e);}}String view = getErrorPage(errStatus, response);ModelAndView mv = new ModelAndView(view);response.setContentType(MediaType.TEXT_HTML_VALUE);mv.getModel().put("global", SpringUtil.getBean(GlobalInitService.class).globalAttr());mv.getModel().put("res", ResVo.fail(errStatus));mv.getModel().put("toast", JsonUtil.toStr(ResVo.fail(errStatus)));return mv;
}
技術派做了兩種處理,一種是REST接口請求的異常,一種是針對普通頁請求的異常。
1、如果是 REST 接口請求異常,代碼會返回一個 JSON 格式的異常提示信息:
- 首先檢查響應是否已經提交,如果已經提交,則直接返回一個空的 ModelAndView。
- 如果響應未提交,將重置響應對象,設置響應的內容類型為JSON,并添加相關的響應頭。
- 使用 response.getwriter()將異常狀態對象 errStatus 轉換為 JSON 格式并寫入響應。完成后,返回一個空的 ModelAndView。
②、如果是普通頁面請求異常,代碼會返回一個包含錯誤信息的 HTML 頁面:
- 根據異常狀態對象 errStatus 和響應對象 response 獲取錯誤頁面的視圖名稱。
- 創建-個 ModelAndView 對象,并設置視圖名稱。
- 設置響應的內容類型為 HTML。
- 向 ModelAndView 中添加全局屬性、錯誤響應對象以及錯誤信息(以JSON 格式)
- 最后返回這個 ModelAndView 對象,用于展示錯誤頁面。
下圖是當遇到 404 錯誤的時候,返回的 404 頁面。
其中 buildToastMsg 方法用來對異常進行分類,使用 instanceof 關鍵字來判斷不同類型的異常,添加不同的異常碼和提示消息。
private Status buildToastMsg(Exception ex) {
if (ex instanceof ForumException) {return ((ForumException) ex).getStatus();
} else if (ex instanceof AsyncRequestTimeoutException) {return Status.newStatus(StatusEnum.UNEXPECT_ERROR, "超時未登錄");
} else if (ex instanceof HttpMediaTypeNotAcceptableException) {return Status.newStatus(StatusEnum.RECORDS_NOT_EXISTS, ExceptionUtils.getStackTrace(ex));
} else if (ex instanceof HttpRequestMethodNotSupportedException || ex instanceof MethodArgumentTypeMismatchException || ex instanceof IOException) {// 請求方法不匹配return Status.newStatus(StatusEnum.ILLEGAL_ARGUMENTS, ExceptionUtils.getStackTrace(ex));
} else if (ex instanceof NestedRuntimeException) {log.error("unexpect NestedRuntimeException error! {}", ReqInfoContext.getReqInfo(), ex);return Status.newStatus(StatusEnum.UNEXPECT_ERROR, ex.getMessage());
} else {log.error("unexpect error! {}", ReqInfoContext.getReqInfo(), ex);return Status.newStatus(StatusEnum.UNEXPECT_ERROR, ExceptionUtils.getStackTrace(ex));
}
}
StatusEnum 中定義了異常碼的規范,舉幾個例子。
/*** 異常碼規范:* xxx - xxx - xxx* 業務 - 狀態 - code* <p>* 業務取值* - 100 全局* - 200 文章相關* - 300 評論相關* - 400 用戶相關* <p>* 狀態:基于http status的含義* - 4xx 調用方使用姿勢問題* - 5xx 服務內部問題* <p>* code: 具體的業務code*/
@Getter
public enum StatusEnum {SUCCESS(0, "OK"),// -------------------------------- 通用// 全局傳參異常ILLEGAL_ARGUMENTS(100_400_001, "參數異常"),ILLEGAL_ARGUMENTS_MIXED(100_400_002, "參數異常:%s"),// 全局權限相關FORBID_ERROR(100_403_001, "無權限"),
}
getErrorPage 方法用于返回不同的錯誤頁面,比如常見的 404 Not Found(請求的資源不存在,服務器無法找到請求的資源)、403 Forbidden(服務器理解請求,但是拒絕處理它,一般是由于權限問題或者訪問被拒絕)500 lnternal Server Error(服務器發生了錯誤,無法完成請求)等。
private String getErrorPage(Status status, HttpServletResponse response) {// 根據異常碼解析需要返回的錯誤頁面if (StatusEnum.is5xx(status.getCode())) {response.setStatus(500);return "error/500";} else if (StatusEnum.is403(status.getCode())) {response.setStatus(403);return "error/403";} else {response.setStatus(404);return "error/404";}
}
restResponse 方法用來判斷是否是 REST 請求,比如說 admin 后臺請求、api數據請求、上傳圖片等接口
Ajax 請求等,這些請求統一返回 JSON 格式的異常提示信息,否則返回普通的頁面格式的異常提示信息。
**
* 后臺請求、api數據請求、上傳圖片等接口,返回json格式的異常提示信息
* 其他異常,返回500的頁面
*
* @param request
* @param response
* @return
*/
private boolean restResponse(HttpServletRequest request, HttpServletResponse response) {if (request.getRequestURI().startsWith("/api/admin/") || request.getRequestURI().startsWith("/admin/")) {return true;}if (request.getRequestURI().startsWith("/image/upload")) {return true;}if (response.getContentType() != null && response.getContentType().contains(MediaType.APPLICATION_JSON_VALUE)) {return true;}if (isAjaxRequest(request)) {return true;}// 數據接口請求AntPathMatcher pathMatcher = new AntPathMatcher();if (pathMatcher.match("/**/api/**", request.getRequestURI())) {return true;}return false;
}
再來看一下自定義的異常類 ForumException,非常簡單,繼承了 RuntimeException。
/*** 業務異常**/
public class ForumException extends RuntimeException {@Getterprivate Status status;public ForumException(Status status) {this.status = status;}public ForumException(int code, String msg) {this.status = Status.newStatus(code, msg);}public ForumException(StatusEnum statusEnum, Object... args) {this.status = Status.newStatus(statusEnum, args);}}
HandlerExceptionResolver 的工作原理主要基于 Spring MVC 的異常處理流程。當一個請求進入 Spring MVC后,它會根據請求信息找到對應的處理器(handler,也就是Controller)。在Controller 執行過程中,如果拋出了異常,Spring MVC 就會啟動異常處理流程。
1)異常發生:當 Controller 執行過程中拋出異常,Spring MVC 捕獲到這個異常后,會進入異常處理流程
2)查找異常解析器:Spring MVC 會遍歷所有已注冊的 HandlerExceptionResolver 實現。比如說我們自定義的
ForumExceptionHandler,Spring MVC 本身也提供了一些默認的實現,比如DefaultHandlerExceptionResolver、ExceptionHandlerExceptionResolver.
3)執行異常解析器:對于每個 HandlerExceptionResolver 實現,Spring MVC會調用它的 resolveException方法,并傳入請求、響應、處理器和異常對象。如果解析器能處理這個異常,它會返回一個非空的ModelAndView 對象。這個對象封裝了異常處理后的視圖和模型數據。
4)處理返回結果:當 resolveException 方法返回一個非空的 ModelAndView 對象時,Spring MVC 會將這個對象用于生成最終的響應。可能渲染一個錯誤視圖、設置響應狀態碼等。如果所有的HandlerExceptionResolver 都無法處理這個異常(即都返回了空的 ModelAndView對象),那么 SpringMVC 會將異常重新拋出,以便其他異常處理器(如 Servet 容器)進行處理。
通過這個流程,HandlerExceptionResolver 能夠在 Spring MVC 中統一管理和處理異常。記得在 Spring Boot的啟動類中將自定義的 HandlerExceptionResolver 添加到 Spring 配置中。
@Slf4j
@EnableAsync
@EnableScheduling
@EnableCaching
@ServletComponentScan //與@WebFilter(urlPatterns = "/*", filterName = "reqRecordFilter", asyncSupported = true)注解配套
@SpringBootApplication
public class QuickForumApplication implements WebMvcConfigurer, ApplicationRunner {@Overridepublic void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {resolvers.add(0, new ForumExceptionHandler());}
}
@ControllerAdvice
除了 HandlerExceptionResolver,全局異常還可以采用 @ControllerAdvice 注解的方式。它可以將通用的操作和邏輯抽離出來,避免在每個控制器中重復相同的操作。
第一步,新建一個自定義的異常類 ForumAdviceException。
/*** 業務異常**/
public class ForumAdviceException extends RuntimeException {@Getterprivate Status status;public ForumAdviceException(Status status) {this.status = status;}public ForumAdviceException(int code, String msg) {this.status = Status.newStatus(code, msg);}public ForumAdviceException(StatusEnum statusEnum, Object... args) {this.status = Status.newStatus(statusEnum, args);}}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Status {/*** 業務狀態碼*/@ApiModelProperty(value = "狀態碼, 0表示成功返回,其他異常返回", required = true, example = "0")private int code;/*** 描述信息*/@ApiModelProperty(value = "正確返回時為ok,異常時為描述文案", required = true, example = "ok")private String msg;public static Status newStatus(int code, String msg) {return new Status(code, msg);}public static Status newStatus(StatusEnum status, Object... msgs) {String msg;if (msgs.length > 0) {msg = String.format(status.getMsg(), msgs);} else {msg = status.getMsg();}return newStatus(status.getCode(), msg);}
}
第二步,新建一個全局異常控制器 GlobalExceptionHandler,內容如下所示。
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(value = ForumAdviceException.class)public ResVo<String> handleForumAdviceException(ForumAdviceException e) {return ResVo.fail(e.getStatus());}
}
@RestControllerAdvice 是一個特殊的 @ControllerAdvice 注解,適用于處理 RESTfu API 異常的情況。這意味著它將用于處理來自帶有 @RestController 注解的控制器拋出的異常。
此類中定義的方法 handleForumAdviceException 使用 @ExceptionHandler 注解,表示它將處理ForumAdviceException 類型的異常。
第三步,加一個測試的控制器方法 testControllerAdvice。
@RequestMapping(path = "testControllerAdvice")
@ResponseBody
public String testControllerAdvice() {throw new ForumAdviceException(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "測試ControllerAdvice異常");
}
第四步,如果之前在啟動類中注冊了 ForumExceptionHandler,此時需要干掉。
第五步,重啟啟動服務,測試 testControllerAdvice 接口。
接口返回的內容如下所示
兩種全局異常處理的優缺點
好,我們來對比一下兩種全局異常處理 HandlerExceptionResolver 和 @ControllerAdvice(或@RestControllerAdvice)的優缺點。
1、HandlerExceptionResolver
HandlerExceptionResolver 是一個接口,用于處理由 Controller 拋出的異常,我們可以重寫resolveException方法,在其中實現該接口來自定義全局異常處理邏輯。然后在Spring Boot 的啟動類中通過
extendHandlerExceptionResolvers 將自定義的 HandlerExceptionResolver 添加到解析器中。
- 優點:可以更加靈活地處理異常,因為你可以編寫任何處理邏輯。
- 缺點:與其他 Spring MVC 組件的集成不夠緊密,需要手動添加和配置,。
2、@ControllerAdvice(或@RestControllerAdvice)
@ControllerAdvice(或 @RestControllerAdvice)是用于定義全局異常處理類的注解。在這個類中,我們可以使用 @ExceptionHandler 注解來處理不同類型的異常。@ControllerAdvice 需要與 @ExceptionHandler 注解一起使用。
- 優點:更容易實現和集成,只需創建一個帶有 @ControllerAdvice(或 @RestControllerAdvice)注解的類,并使用 @ExceptionHandler 注解定義異常處理方法。
- 缺點:異常處理邏輯可能不如 HandlerxceptionResolver 那么靈活。
我們可以根據具體的需求和使用場景,選擇其中之一來實現全局異常處理。另外二者可以共存,
ControllerAddvice優先級比HandlerExceptionResolver高,也可以用@order注解指定優先級。