博客 / 詳情

返回

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

前言

大家好!我是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.java

    package 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

user avatar eisuto 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.