聊聊springboot專案全域性異常處理那些事兒

linyb極客之路發表於2022-01-18

前言

之前我們業務團隊在處理全域性異常時,在每個業務微服務中都加入了@RestControllerAdvice+@ExceptionHandler來進行全域性異常捕獲。某次領導在走查程式碼的時候,就提出了一個問題,為什麼要每個微服務專案都要自己在寫一套全域性異常程式碼,為什麼不把全域性異常塊抽成一個公共的jar,然後每個微服務以jar的形式引入。後面業務團隊就根據領導的要求,把全域性異常塊單獨抽離出來封裝成jar。今天聊的話題就是關於把全域性異常抽離出來,發生的一些問題

問題一:全域性異常抽離出來後,業務錯誤碼如何定義?

之前團隊的業務錯誤碼定義是:業務服務字首 + 業務模組 + 錯誤碼,如果是識別不了的異常,則使用業務字首 + 固定模組碼 + 固定錯誤碼。
之前的全域性異常虛擬碼如下

@RestControllerAdvice
@Slf4j
public class GlobalExceptionBaseHandler {

   
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public AjaxResult handleException(Exception e) {
        String servicePrifix = "U";
        String moudleCode = "001";
        String code = "0001";
        String errorCode = servicePrifix + moudleCode + code;
        String msg = e.getMessage();
        if(StringUtils.isEmpty(msg)){
            msg = "服務端異常";
        }
        log.error(msg, e);
        return AjaxResult.error(msg, errorCode);
    }
    }
現在全域性異常抽離出來後,那個業務服務字首如何識別?之前未抽離時,業務服務字首各個業務服務直接寫死在程式碼裡。

當時我們臨時的解決方案是通過spring.application.name來解決。因為全域性異常程式碼塊抽離出來後,最終還是要被服務引入的。因此獲取業務服務字首的虛擬碼可以通過如下獲取

public enum  ServicePrefixEnum {

    USER_SERVICE("U","使用者中心");

    private final String servicePrefix;

    private final String serviceDesc;

    ServicePrefixEnum(String servicePrefix,String serviceDesc) {
        this.servicePrefix = servicePrefix;
        this.serviceDesc = serviceDesc;
    }

    public String getServicePrefix() {
        return servicePrefix;
    }

    public String getServiceDesc() {
        return serviceDesc;
    }
}
  public String getServicePrefix(@Value("${spring.application.name}") String serviceName){
      return ServicePrefixEnum.valueOf(serviceName).getServicePrefix();
    }

但這種方案其實是存在弊端

弊端一: 通過列舉硬編碼,預設了目前了微服務名稱,一旦專案改變了微服務名,就找不到服務字首了。
弊端二: 如果新上線了業務服務模組,這個列舉類還得改動

後面我們在全域性異常jar中增加了自定義業務碼的配置,業務人員僅需在springboot配置檔案配置,形如下

lybgeek:
  bizcode:
    prefix: U

此時全域性異常改造示例形如下

@RestControllerAdvice
@Slf4j
public class GlobalExceptionBaseHandler {
    
    
    @Autowired
    private ServiceCodeProperties serviceCodeProperties;

   
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public AjaxResult handleException(Exception e) {
        String servicePrifix = serviceCodeProperties.getPrifix();
        String moudleCode = "001";
        String code = "0001";
        String errorCode = servicePrifix + moudleCode + code;
        String msg = e.getMessage();
        if(StringUtils.isEmpty(msg)){
            msg = "服務端異常";
        }
        log.error(msg, e);
        return AjaxResult.error(msg, errorCode);
    }
}

問題二:全域性異常因引入了和業務相同的依賴jar,但jar存在版本差異

如果全域性異常直接如下寫,是不存在問題。示例如下

@RestControllerAdvice
@Slf4j
public class GlobalExceptionBaseHandler {


    @Autowired
    private ServiceCodeProperties serviceCodeProperties;

    
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public AjaxResult handleException(Exception e) {
        String servicePrifix = serviceCodeProperties.getPrifix();
        String moudleCode = "001";
        String code = "0001";
        String errorCode = servicePrifix + moudleCode + code;
        String msg = e.getMessage();
        if(StringUtils.isEmpty(msg)){
            msg = "服務端異常";
        }
        log.error(msg, e);
        return AjaxResult.error(msg, HttpStatus.INTERNAL_SERVER_ERROR.value());
    }


    @ExceptionHandler(BizException.class)
    public AjaxResult handleException(BizException e)
    {
        return AjaxResult.error(e.getMessage(), e.getErrorCode());
    }

}

即全域性異常直接分為業務異常和Execption這兩種,這樣劃分的弊端在於沒辦法細分異常,而且也使專案組定義的模組碼和業務碼沒法細分。因此我們也列出常用可以預知的系統異常,示例如下

  /**
     *引數驗證失敗
     * @param e
     * @return
     */
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public AjaxResult handleException(ConstraintViolationException e)
    {
        log.error("引數驗證失敗", e);
        return AjaxResult.error("引數驗證失敗", HttpStatus.BAD_REQUEST.value());
    }

   /**
     * 資料庫異常
     * @param e
     * @return
     */
    @ExceptionHandler({SQLException.class, MybatisPlusException.class,
            MyBatisSystemException.class, org.apache.ibatis.exceptions.PersistenceException.class,
            BadSqlGrammarException.class
    })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public AjaxResult dbException(Exception e) {
        String msg = ExceptionUtil.getExceptionMessage(e);
        log.error(msg, e);
        return AjaxResult.error(msg,HttpStatus.BAD_REQUEST.value());
    }

    /**
     * 資料庫中已存在該記錄
     * @param e
     * @return
     */
    @ExceptionHandler(DuplicateKeyException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public AjaxResult handleException(DuplicateKeyException e)
    {
        log.error("資料庫中已存在該記錄", e);
        return AjaxResult.error("資料庫中已存在該記錄", HttpStatus.CONFLICT.value());
    }

不過這樣導致了一個問題,就是全域性異常和業務方使用相同的依賴jar,但存在版本差異時,可能就會存在依賴衝突,導致業務專案啟動報錯。因此解決方案就是在pom檔案加入optional標籤。示例如下

    <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <optional>true</optional>
        </dependency>

這標籤的意思這jar座標是可選的,因此如果專案中已經有引入該jar的座標,就直接用該jar的座標

問題三:引入maven optional標籤後,因業務沒引入全域性異常需要的jar,導致專案啟動報錯

這個問題的產生:舉個示例,我們的業務微服務專案有聚合層,某些聚合層是不需要依賴儲存介質,比如mysql。因此這些聚合層專案pom就不會引入類似mybatis相關的依賴。但我們的全域性異常又需要類似mybatis相關的依賴,這樣導致如果要引用全域性異常模組,有得額外加入業務方不需要的jar。

因此springboot的條件註解就派上用場了,利用@ConditionalOnClass註解。示例如下

@RestControllerAdvice
@Slf4j
@ConditionalOnClass({SQLException.class, MybatisPlusException.class,
        MyBatisSystemException.class, org.apache.ibatis.exceptions.PersistenceException.class,
        BadSqlGrammarException.class, DuplicateKeyException.class})
public class GlobalExceptionDbHandler {




    /**
     * 資料庫異常
     * @param e
     * @return
     */
    @ExceptionHandler({SQLException.class, MybatisPlusException.class,
            MyBatisSystemException.class, org.apache.ibatis.exceptions.PersistenceException.class,
            BadSqlGrammarException.class
    })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public AjaxResult dbException(Exception e) {
        String msg = ExceptionUtil.getExceptionMessage(e);
        log.error(msg, e);
        return AjaxResult.error(msg,HttpStatus.BAD_REQUEST.value());
    }

    /**
     * 資料庫中已存在該記錄
     * @param e
     * @return
     */
    @ExceptionHandler(DuplicateKeyException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public AjaxResult handleException(DuplicateKeyException e)
    {
        log.error("資料庫中已存在該記錄", e);
        return AjaxResult.error("資料庫中已存在該記錄", HttpStatus.CONFLICT.value());
    }
}

@ConditionalOnClass這個註解的作用就是如果classpath存在指定的類,則該註解上的類會生效。

同時這邊有個細節點,就是全域性異常可能就得細分,即把原來的大一統的全域性異常,按業務場景分開,比如儲存介質相關的儲存異常,web相關異常

總結

總結

本文主要講當將全域性異常抽離成jar,可能會發生的問題。這邊有涉及到一些細節點沒講,比如為啥要定義服務字首+業務模組碼+錯誤碼,其實主要還是為了好排查問題。

也許有朋友會問,你們都搞了微服務,難道不上分散式鏈路追蹤?根據分散式鏈路追蹤可以很方便定位到整個鏈路了。但真的開發微服務的時候,如果公司原來就就沒運維平臺,有時候為了成本考量,測試、開發環境都不會上的分散式鏈路追蹤的,甚至線上專案初期也不會上分散式鏈路追蹤。因此定義好相關的業務碼就變得格外重要

demo連結

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-exception

相關文章