大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題並整理成文,限於本人水平,如果文章和程式碼有表述不當之處,還請不吝賜教。
作為一名從業已達六年的老碼農,我的工作主要是開發後端Java業務系統,包括各種管理後臺和小程式等。在這些專案中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過訊息中心這類較為複雜的應用,但幸運的是,我至今還沒有遇到過線上系統由於程式碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身並不複雜;二是我一直遵循某大廠程式碼規約,在開發過程中儘可能按規約編寫程式碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。
BUG對於程式設計師來說實在是不陌生,當程式碼出現BUG時,異常也會隨之出現,但BUG並不等於異常,BUG只是導致異常出現的一個原因。導致異常發生的原因非常多,本篇文章我也主要只講一下介面相關的異常怎麼處理。
本文參考專案原始碼地址:summo-springboot-interface-demo
在介面設計中,應該儘量避免使用異常來進行控制流程。介面應該儘可能返回明確的錯誤碼和錯誤資訊,而不是直接丟擲異常。
這是介面處理過程中可能出現的業務邏輯錯誤,例如引數校驗失敗、許可權不足等。這些異常通常是預期
的,並且可以提供相應的錯誤碼和錯誤資訊給呼叫方。
這是介面處理過程中可能出現的非預期錯誤,例如資料庫異常、網路異常等。這些異常通常是未知
的,並且可能導致介面無法正常響應。這種錯誤不僅需要記錄異常資訊通知系統管理員處理,還需要封裝起來做好提示,不能直接把錯誤返回給使用者。
這是呼叫方在使用介面時可能出現的錯誤,例如請求引數錯誤、請求超時等。這些異常通常是由於呼叫方的錯誤
導致的,介面本身沒有問題。可以根據具體情況選擇是否返回錯誤資訊給呼叫方。
在介面的實現程式碼中,可以使用try-catch語句捕獲異常,並進行相應的處理。可以選擇將異常轉化為合適的錯誤碼和錯誤資訊,然後返回給呼叫方。或者根據具體情況選擇是否記錄異常紀錄檔,並通知系統管理員進行處理。
可以使用統一的例外處理器來統一處理介面異常。在Spring Boot中,可以使用@ControllerAdvice和@ExceptionHandler註解來定義一個全域性的例外處理器。這樣可以將所有介面丟擲的異常統一處理,例如轉化為特定的錯誤碼和錯誤資訊,並返回給呼叫方。
可以根據業務需求定義一些自定義的異常類,繼承RuntimeException或其他合適的異常類,並在介面中丟擲這些異常。這樣可以在異常發生時,直接丟擲異常,由上層呼叫方進行捕獲和處理。
可以在介面中定義一套錯誤碼和錯誤資訊的規範,當發生異常時,返回對應的錯誤碼和錯誤資訊給呼叫方。這樣呼叫方可以根據錯誤碼進行相應的處理,例如展示錯誤資訊給使用者或者進行相應的邏輯處理。
例如這樣的彈窗提示
比如遇到401、404、500等錯誤時,SpringBoot框架會返回自帶的錯誤頁,在這裡我們其實可以自己重寫一些更美觀、更友好的錯誤提示頁,最好還能引導使用者回到正確的操作上來,例如這樣
而不是下面這樣
通過前面兩段我們可以發現,造成異常的原因很多,出現異常的地方很多,異常的處理手段也很多。基於以上三多的情況,我們需要一個地方來統一接收異常、統一處理異常,上面提到SpringBoot的@ControllerAdvice註解
作為一個全域性的例外處理器來統一處理異常。但@ControllerAdvice註解
不是萬能的,它有一個問題:
對於@ControllerAdvice註解來說,它主要用於處理Controller層的異常情況,即在控制器方法中發生的異常。因為它是基於Spring MVC的控制器層的例外處理機制。
而Filter層是位於控制器之前的一層過濾器,它可以用於對請求進行預處理和後處理。當請求進入Filter時,還沒有進入到Controller層,所以@ControllerAdvice註解無法直接處理Filter層中的異常。
所以對於Filter中的異常,我們需要單獨處理。
由於SpringBoot框架並沒有定義業務相關的錯誤碼,所以我們需要自定義業務錯誤碼。該錯誤碼可以根據業務複雜程度進行分類,每個錯誤碼對應一個具體的異常情況。這樣前後端統一處理異常時可以根據錯誤碼進行具體的處理邏輯,提高例外處理的準確性和效率。同時,定義錯誤碼還可以方便進行異常監控和紀錄檔記錄,便於排查和修復問題。
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;
}
}
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;
}
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;
}
}
@RestControllerAdvice和@ExceptionHandler使用起來很簡單,下面我們來測試一下(由於不寫介面截圖是在太醜,我麻煩ChatGPT幫我寫了一套簡單的介面
)。
存取連結: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的這個使用者,會丟擲一個異常
存取連結:http://localhost:8080/login
登入介面使用小B的賬號登入
呼叫介面:http://localhost:8080/user/delete?userId=2
由於小B的賬號只有查詢許可權,沒有刪除許可權,所以返回403錯誤頁
注意