前言
大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和代碼有表述不當之處,還請不吝賜教。
作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後台和小程序等。在這些項目中,我設計過單/多租户體系系統,對接過許多開放平台,也搞過消息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於代碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠代碼規約,在開發過程中儘可能按規約編寫代碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。
BUG對於程序員來説實在是不陌生,當代碼出現BUG時,異常也會隨之出現,但BUG並不等於異常,BUG只是導致異常出現的一個原因。導致異常發生的原因非常多,本篇文章我也主要只講一下接口相關的異常怎麼處理。
本文參考項目源碼地址:summo-springboot-interface-demo
由於文章經常被抄襲,開源的代碼甚至被當成收費項,所以源碼裏面不是全部代碼,有需要的同學可以留個郵箱,我給你單獨發!
一、接口異常的分類
在接口設計中,應該儘量避免使用異常來進行控制流程。接口應該儘可能返回明確的錯誤碼和錯誤信息,而不是直接拋出異常。
1. 業務異常(Business Exception)
這是接口處理過程中可能出現的業務邏輯錯誤,例如參數校驗失敗、權限不足等。這些異常通常是預期的,並且可以提供相應的錯誤碼和錯誤信息給調用方。
2. 系統異常(System Exception)
這是接口處理過程中可能出現的非預期錯誤,例如數據庫異常、網絡異常等。這些異常通常是未知的,並且可能導致接口無法正常響應。這種錯誤不僅需要記錄異常信息通知系統管理員處理,還需要封裝起來做好提示,不能直接把錯誤返回給用户。
3. 客户端異常(Client Exception)
這是調用方在使用接口時可能出現的錯誤,例如請求參數錯誤、請求超時等。這些異常通常是由於調用方的錯誤導致的,接口本身沒有問題。可以根據具體情況選擇是否返回錯誤信息給調用方。
二、接口異常的常見處理辦法
1. 異常捕獲和處理
在接口的實現代碼中,可以使用try-catch語句捕獲異常,並進行相應的處理。可以選擇將異常轉化為合適的錯誤碼和錯誤信息,然後返回給調用方。或者根據具體情況選擇是否記錄異常日誌,並通知系統管理員進行處理。
2. 統一異常處理器
可以使用統一的異常處理器來統一處理接口異常。在Spring Boot中,可以使用@ControllerAdvice和@ExceptionHandler註解來定義一個全局的異常處理器。這樣可以將所有接口拋出的異常統一處理,例如轉化為特定的錯誤碼和錯誤信息,並返回給調用方。
3. 拋出自定義異常
可以根據業務需求定義一些自定義的異常類,繼承RuntimeException或其他合適的異常類,並在接口中拋出這些異常。這樣可以在異常發生時,直接拋出異常,由上層調用方進行捕獲和處理。
4. 返回錯誤碼和錯誤信息
可以在接口中定義一套錯誤碼和錯誤信息的規範,當發生異常時,返回對應的錯誤碼和錯誤信息給調用方。這樣調用方可以根據錯誤碼進行相應的處理,例如展示錯誤信息給用户或者進行相應的邏輯處理。
例如這樣的彈窗提示
5. 跳轉到指定錯誤頁
比如遇到401、404、500等錯誤時,SpringBoot框架會返回自帶的錯誤頁,在這裏我們其實可以自己重寫一些更美觀、更友好的錯誤提示頁,最好還能引導用户回到正確的操作上來,例如這樣
而不是下面這樣
三、接口異常的統一處理
通過前面兩段我們可以發現,造成異常的原因很多,出現異常的地方很多,異常的處理手段也很多。基於以上三多的情況,我們需要一個地方來統一接收異常、統一處理異常,上面提到SpringBoot的@ControllerAdvice註解作為一個全局的異常處理器來統一處理異常。但@ControllerAdvice註解不是萬能的,它有一個問題:
對於@ControllerAdvice註解來説,它主要用於處理Controller層的異常情況,即在控制器方法中發生的異常。因為它是基於Spring MVC的控制器層的異常處理機制。
而Filter層是位於控制器之前的一層過濾器,它可以用於對請求進行預處理和後處理。當請求進入Filter時,還沒有進入到Controller層,所以@ControllerAdvice註解無法直接處理Filter層中的異常。
所以對於Filter中的異常,我們需要單獨處理。
1. @ControllerAdvice全局異常處理器的使用
(1)自定義業務異常
由於SpringBoot框架並沒有定義業務相關的錯誤碼,所以我們需要自定義業務錯誤碼。該錯誤碼可以根據業務複雜程度進行分類,每個錯誤碼對應一個具體的異常情況。這樣前後端統一處理異常時可以根據錯誤碼進行具體的處理邏輯,提高異常處理的準確性和效率。同時,定義錯誤碼還可以方便進行異常監控和日誌記錄,便於排查和修復問題。
a、定義常見的異常狀態碼
ResponseCodeEnum.java
package com.summo.demo.model.response;
public enum ResponseCodeEnum {
/**
* 請求成功
*/
SUCCESS("0000", ErrorLevels.DEFAULT, ErrorTypes.SYSTEM, "請求成功"),
/**
* 登錄相關異常
*/
LOGIN_USER_INFO_CHECK("LOGIN-0001", ErrorLevels.INFO, ErrorTypes.BIZ, "用户信息錯誤"),
/**
* 權限相關異常
*/
NO_PERMISSIONS("PERM-0001", ErrorLevels.INFO, ErrorTypes.BIZ, "用户無權限"),
/**
* 業務相關異常
*/
BIZ_CHECK_FAIL("BIZ-0001", ErrorLevels.INFO, ErrorTypes.BIZ, "業務檢查異常"),
BIZ_STATUS_ILLEGAL("BIZ-0002", ErrorLevels.INFO, ErrorTypes.BIZ, "業務狀態非法"),
BIZ_QUERY_EMPTY("BIZ-0003", ErrorLevels.INFO, ErrorTypes.BIZ, "查詢信息為空"),
/**
* 系統出錯
*/
SYSTEM_EXCEPTION("SYS-0001", ErrorLevels.ERROR, ErrorTypes.SYSTEM, "系統出錯啦,請稍後重試"),
;
/**
* 枚舉編碼
*/
private final String code;
/**
* 錯誤級別
*/
private final String errorLevel;
/**
* 錯誤類型
*/
private final String errorType;
/**
* 描述説明
*/
private final String description;
ResponseCodeEnum(String code, String errorLevel, String errorType, String description) {
this.code = code;
this.errorLevel = errorLevel;
this.errorType = errorType;
this.description = description;
}
public String getCode() {
return code;
}
public String getErrorLevel() {
return errorLevel;
}
public String getErrorType() {
return errorType;
}
public String getDescription() {
return description;
}
public static ResponseCodeEnum getByCode(Integer code) {
for (ResponseCodeEnum value : values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return SYSTEM_EXCEPTION;
}
}
b、自定義業務異常類
BizException.java
package com.summo.demo.exception.biz;
import com.summo.demo.model.response.ResponseCodeEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class BizException extends RuntimeException {
/**
* 錯誤碼
*/
private ResponseCodeEnum errorCode;
/**
* 自定義錯誤信息
*/
private String errorMsg;
}
(2) 全局異常處理器
BizGlobalExceptionHandler
package com.summo.demo.exception.handler;
import javax.servlet.http.HttpServletResponse;
import com.summo.demo.exception.biz.BizException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.ModelAndView;
@RestControllerAdvice(basePackages = {"com.summo.demo.controller", "com.summo.demo.service"})
public class BizGlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ModelAndView handler(BizException ex, HttpServletResponse response) {
ModelAndView modelAndView = new ModelAndView();
switch (ex.getErrorCode()) {
case LOGIN_USER_INFO_CHECK:
// 重定向到登錄頁
modelAndView.setViewName("redirect:/login");
break;
case NO_PERMISSIONS:
// 設置錯誤信息和錯誤碼
modelAndView.addObject("errorMsg", ex.getErrorMsg());
modelAndView.addObject("errorCode", ex.getErrorCode().getCode());
modelAndView.setViewName("403");
break;
case BIZ_CHECK_FAIL:
case BIZ_STATUS_ILLEGAL:
case BIZ_QUERY_EMPTY:
case SYSTEM_EXCEPTION:
default:
// 設置錯誤信息和錯誤碼
modelAndView.addObject("errorMsg", ex.getErrorMsg());
modelAndView.addObject("errorCode", ex.getErrorCode().getCode());
modelAndView.setViewName("error");
}
return modelAndView;
}
}
(3) 測試效果
@RestControllerAdvice和@ExceptionHandler使用起來很簡單,下面我們來測試一下(由於不寫界面截圖是在太醜,我麻煩ChatGPT幫我寫了一套簡單的界面)。
a、普通業務異常捕獲
第一步、打開登錄頁
訪問鏈接:http://localhost:8080/login
輸入賬號、密碼,點擊登錄進入首頁
第二步、登錄進入首頁
第三步、調用一個會報錯的接口
再服務啓動之前我寫了一個根據用户名查詢用户的方法,如果查詢不到用户的話我會拋出一個異常,代碼如下:
public ResponseEntity<String> query(String userName) {
//根據名稱查詢用户
List<UserDO> list = userRepository.list(
new QueryWrapper<UserDO>().lambda().like(UserDO::getUserName, userName));
if (CollectionUtils.isEmpty(list)) {
throw new BizException(ResponseCodeEnum.BIZ_QUERY_EMPTY, "根據用户名稱查詢用户為空!");
}
//返回數據
return ResponseEntity.ok(JSONObject.toJSONString(list));
}
這時,我們查詢一個不存在的用户
訪問接口:http://localhost:8080/user/query?userName=sss
因為數據庫中沒有用户名為sss的這個用户,會拋出一個異常
b、403權限不足異常捕獲
第一步、打開登錄頁
訪問鏈接:http://localhost:8080/login
登錄界面使用小B的賬號登錄
第二步、登錄進入首頁
第三步、調用刪除用户的接口
調用接口:http://localhost:8080/user/delete?userId=2
由於小B的賬號只有查詢權限,沒有刪除權限,所以返回403錯誤頁
注意👉🏻:在調試之前需要在application.yml或application.properties配置文件中增加一個配置:server.error.whitelabel.enabled=false
這個配置的意思是是否啓用默認的錯誤頁面,這裏我們自己寫了一套錯誤頁,所以不需要框架自帶的配置了。
2. 自定義Filter中異常的處理
由於@ControllerAdvice註解無法捕獲自定義Filter中拋出的異常,這裏我們就需要使用另外一種方法進行處理:ErrorController接口。
(1) 原理解釋
Spring Boot的ErrorController是一個接口,用於定義處理應用程序中發生的錯誤的自定義邏輯。它允許開發人員以更靈活的方式處理和響應異常,而不是依賴於默認的錯誤處理機制。:
- 定製錯誤頁面:通過實現ErrorController接口,可以自定義應用程序的錯誤頁面,以提供更好的用户體驗。可以根據不同的異常類型和HTTP狀態碼提供不同的錯誤頁面或錯誤信息。
- 記錄錯誤日誌:ErrorController可以用於捕獲和記錄應用程序中的異常,並將其記錄到日誌中。這對於問題追蹤和排查非常有幫助,可以瞭解應用程序中發生的錯誤和異常的詳細信息。
-
重定向或轉發請求:通過ErrorController,可以根據錯誤的類型或其他條件,將請求重定向到不同的URL或轉發到其他控制器方法。這對於根據錯誤情況做出不同的處理非常有用,例如重定向到自定義的錯誤頁面或執行特定的錯誤處理邏輯。
(2) 使用方法
使用方法直接看看我的代碼就知道了。
CustomErrorController.javapackage com.summo.demo.controller; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; @Controller public class CustomErrorController implements ErrorController { @RequestMapping("/error") public ModelAndView handleError(HttpServletRequest request, HttpServletResponse response) { //獲取當前響應返回的狀態碼 int statusCode = response.getStatus(); //如果響應頭中存在statusCode,則默認使用這個statusCode if (StringUtils.isNotBlank(response.getHeader("statusCode"))) { statusCode = Integer.valueOf(response.getHeader("statusCode")); } if (statusCode == HttpServletResponse.SC_FOUND) { // 獲取Location響應頭的值,進行重定向 String redirectLocation = response.getHeader("Location"); return new ModelAndView("redirect:" + redirectLocation); } else if (statusCode == HttpServletResponse.SC_UNAUTHORIZED) { // 重定向到登錄頁 return new ModelAndView("redirect:/login"); } else if (statusCode == HttpServletResponse.SC_FORBIDDEN) { // 返回403頁面 return new ModelAndView("403"); } else if (statusCode == HttpServletResponse.SC_NOT_FOUND) { // 返回404頁面 return new ModelAndView("404"); } else if (statusCode == HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { // 返回500頁面,並傳遞errorMsg和errorCode到模板 ModelAndView modelAndView = new ModelAndView("500"); modelAndView.addObject("errorMsg", response.getHeader("errorMsg")); modelAndView.addObject("errorCode", response.getHeader("errorCode")); return modelAndView; } else { // 返回其他錯誤頁面 return new ModelAndView("error"); } } }細心的讀者可能會看到,statusCode來自於兩個地方,第一個是response.getStatus();第二個是response.getHeader("statusCode")。這兩者的區別是第一個是框架自動設置的,第二個則是我根據業務邏輯設置的。
原因是在WebFilter中一旦拋出了異常,response.getStatus()一定會是500,即使這個異常是因為用户身份失效導致的。但異常又不得不拋出,所以我通過自定義response的header的方式設置了錯誤碼,傳遞到/error接口。
(3) 測試效果
a、404錯誤頁,接口找不到
第一步、打開登錄頁
訪問鏈接:http://localhost:8080/login
輸入賬號、密碼,點擊登錄進入首頁
第二步、登錄進入首頁
第三步、訪問一個不存在的頁面
訪問鏈接:http://localhost:8080/xxxx
由於xxxx接口沒有被定義過,界面會返回404
b、401錯誤,用户身份標識為空或無效
這裏我做的處理是,如果用户身份標識為空或無效那麼我會默認跳轉到登錄頁。
測試方法是打開一個無痕界面,隨便輸入一個鏈接:http://localhost:8080/user/query
由於Cookie中token不存在,所以我不管訪問的是哪個鏈接,直接將狀態碼改為401,而CustomErrorController遇到401的錯誤,會默認重定向到登錄頁。
四、優化無痕窗口下的重新登錄體驗
Filter異常的全局處理除了ErrorController之外,還可以通過自定義攔截器的方式實現,這兩個東西會一個就行了。這裏我再説一個高級一點的東西,舉個例子:
我在一個無痕窗口調用接口:http://localhost:8080/user/query?userName=小B
因為當前窗口的Cookie中是沒有token的,按照401錯誤的處理方式,我會重定向到登錄頁去。
但這個有一個問題:重新登錄之後,進入的是首頁,不是調用user/query接口,我還得重新去找這個接口,重新輸入參數。而且這要是一個分享頁那就尷尬了,登陸完不知道對方分享了啥,用户體驗會很差,那麼有辦法優化這個問題嗎?答案是有,如何做,繼續看。
1. 在WebFilter中獲取當前請求的全路徑
所謂全路徑就是“http://localhost:8080/user/query?userName=小B” ,如何獲取,可以用我這個方法
/**
* 獲取完整的路徑URL,包括參數
*
* @param httpServletRequest
* @return 路徑URL
*/
private String getRequestURL(HttpServletRequest httpServletRequest) {
String url = httpServletRequest.getRequestURL().toString();
String query = httpServletRequest.getQueryString();
if (query != null) {
url += "?" + query;
}
return url;
}
2. 在WebFilter拋出401錯誤的地方設置httpServletResponse的header
如下
httpServletResponse.setHeader("redirectURL",URLEncoder.encode(getRequestURL(httpServletRequest), "utf-8"));
因為參數有可能是中文,這裏需要用URLEncoder轉下義。
3. 在CustomErrorController中獲取到這個跳轉鏈接
// 重定向到登錄頁或指定頁面
if (StringUtils.isNotBlank(response.getHeader("redirectURL"))) {
return new ModelAndView("redirect:/login?redirectURL=" + response.getHeader("redirectURL"));
}
效果如下
可以看到我們在login後面攜帶了一個redirectURL參數
4. 登錄提交時將redirectURL參數一併提交
@PostMapping("/login")
public void userLogin(@RequestParam(required = true) String userName,
@RequestParam(required = true) String password,
@RequestParam(required = false) String redirectURL,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
userService.login(userName, password, redirectURL, httpServletRequest, httpServletResponse);
}
5. 驗證通過後重定向到redirectURL
try {
//如果跳轉路徑不為空,則直接重定向到跳轉路徑
if (StringUtils.isNotBlank(redirectURL)) {
httpServletResponse.sendRedirect(redirectURL);
return;
}
//跳轉到登錄頁
httpServletResponse.sendRedirect("/index");
} catch (IOException e) {
log.error("重定向發生異常", e);
}
以上就是這個問題的解決方案了,具體代碼大家可以看我的demo:summo-springboot-interface-demo