《優化介面設計的思路》系列:第五篇—介面發生異常如何統一處理

2023-10-18 09:01:51

前言

大家好!我是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錯誤頁

注意