springmvc/springboot 全局異常處理和自定義異常

前言

異常處理其實一直都是項目開發中的大頭,但關注異常處理的人一直都特別少。經常是簡單的 try/catch 所有異常,然後簡單的 printStackTrace ,最多使用 logger 來打印下日誌或者重新拋出異常,還有的已經有自定義異常了,但是還是在 controller 捕獲異常,需要 catch(異常1 )catch(異常2) 特別繁瑣,而且容易漏。

其實 springmvc 在 ControllerAdvice 已經提供了一種全局處理異常的方式,並且我們還可以使用 aop 來統一處理異常,這樣在任何地方我們都只需要關注自己的業務,而不用關注異常處理,而且拋出異常還可以利用 spring 的事務,它只有在檢測到異常纔會事務回滾。

重要說明

  1. 下面的相關代碼用到了 lombok ,不知道的可以百度下 lombok 的用途
  2. 使用建造者模式

統一異常處理

這裏使用 springmvc 的 ControllerAdvice 來做統一異常處理

import com.sanri.test.testmvc.dto.ResultEntity;
import com.sanri.test.testmvc.exception.BusinessException;
import com.sanri.test.testmvc.exception.RemoteException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.ArrayList;
import java.util.List;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @Value("${project.package.prefix:com.sanri.test}")
    protected String packagePrefix;

    /**
     * 處理業務異常
     * @param e
     * @return
     */
    @ExceptionHandler(BusinessException.class)
    public ResultEntity businessException(BusinessException e){
        printLocalStackTrack(e);
        return e.getResultEntity();
    }

    @ExceptionHandler(RemoteException.class)
    public ResultEntity remoteException(RemoteException e){
        ResultEntity parentResult = e.getParent().getResultEntity();
        ResultEntity resultEntity = e.getResultEntity();
        //返回給前端的是業務錯誤,但是需要在控制檯把遠程調用異常給打印出來
        log.error(parentResult.getReturnCode()+":"+parentResult.getMessage()
                +" \n -| "+resultEntity.getReturnCode()+":"+resultEntity.getMessage());

        printLocalStackTrack(e);

        //合併兩個結果集返回
        ResultEntity merge = ResultEntity.err(parentResult.getReturnCode())
                .message(parentResult.getMessage()+" \n  |- "+resultEntity.getReturnCode()+":"+resultEntity.getMessage());
        return merge;
    }

    /**
     * 打印只涉及到項目類調用的異常堆棧
     * @param e
     */
    private void printLocalStackTrack(BusinessException e) {
        StackTraceElement[] stackTrace = e.getStackTrace();
        List<StackTraceElement> localStackTrack = new ArrayList<>();
        StringBuffer showMessage = new StringBuffer();
        if (ArrayUtils.isNotEmpty(stackTrace)) {
            for (StackTraceElement stackTraceElement : stackTrace) {
                String className = stackTraceElement.getClassName();
                int lineNumber = stackTraceElement.getLineNumber();
                if (className.startsWith(packagePrefix)) {
                    localStackTrack.add(stackTraceElement);
                    showMessage.append(className + "(" + lineNumber + ")\n");
                }
            }
            log.error("業務異常:" + e.getMessage() + "\n" + showMessage);
        } else {
            log.error("業務異常,沒有調用棧 " + e.getMessage());
        }
    }

    /**
     * 異常處理,可以綁定多個
     * @return
     */
    @ExceptionHandler(Exception.class)
    public ResultEntity result(Exception e){
        e.printStackTrace();
        return ResultEntity.err(e.getMessage());
    }
}

統一返回值

  • 一般我們都會定義統一的返回,這樣前端好做返回值的解析,像這樣
package com.sanri.test.testmvc.dto;

import lombok.Data;
import lombok.ToString;

import java.io.Serializable;

/**
 * 普通消息返回
 * @param <T>
 */
@Data
@ToString
public class ResultEntity<T> implements Serializable {
    private String returnCode = "0";
    private String message;
    private T data;
    public ResultEntity() {
        this.message = "ok";
    }
    public ResultEntity(T data) {
        this();
        this.data = data;
    }
    public static ResultEntity ok() {
        return new ResultEntity();
    }
    public static ResultEntity err(String returnCode) {
        ResultEntity resultEntity = new ResultEntity();
        resultEntity.returnCode = returnCode;
        resultEntity.message = "fail";
        return resultEntity;
    }
    public static ResultEntity err() {
        return err("-1");
    }
    public ResultEntity message(String msg) {
        this.message = msg;
        return this;
    }
    public ResultEntity data(T data) {
        this.data = data;
        return this;
    }
}

自定義異常

  • 自定義異常,就我目前的工作經歷來看的話,異常一般就三種 。

    1. 第一種是業務異常,即給定的輸入不能滿足業務條件,如時間過期、姓名重複、身份證不對啊等等
    2. 第二種是調用第三方系統時的異常,其實也屬於業務異常
    3. 第三種是系統的致命錯誤,一般是出錯了,但這個要在測試和開發階段就需要處理好,線上出錯只能是給用戶說系統出錯了,然後開發查日誌來看錯誤。

業務異常

對於業務異常來說,我們有時候需要對錯誤進行編號,因爲前端需要拿到編號來做一些頁面跳轉的工作,而且客戶在投訴錯誤的時候也可以告訴運營編號,然後可以做應對的措施;但絕大部分的時候是不需要錯誤編號的,這時可以隨機生成一個編號。我們可以定一個號段來定義錯誤編號,比如 0 定義爲正常,1~100 爲通用錯誤, 101 ~1000 是 A 系統,1000 ~ 2000 是 B 系統,然後 10000 以上是隨機代碼等。

import com.sanri.test.testmvc.dto.ResultEntity;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.List;

/**
 * 系統業務異常(根異常),異常號段爲 :
 * 0 : 成功
 * 1 ~ 9999 內定系統異常段
 * 10000 ~ 99999 自定義異常碼段
 * 100000 ~ Integer.MAX_VALUE 動態異常碼段
 */
public class BusinessException extends RuntimeException {
    protected ResultEntity resultEntity;
    protected static final int  MIN_AUTO_CODE = 100000;

    public static BusinessException create(String message) {
        int value= (int) (MIN_AUTO_CODE + Math.round((Integer.MAX_VALUE - MIN_AUTO_CODE) * Math.random()));
        return create(value + "",message);
    }

    public static BusinessException create(String returnCode,String message){
        if(StringUtils.isBlank(returnCode)){
            return create(message);
        }
         BusinessException businessException = new BusinessException();
         businessException.resultEntity = ResultEntity.err(returnCode).message(message);
         return businessException;
    }

    public static BusinessException create(ExceptionCause exceptionCause ,Object...args){
        ResultEntity resultEntity = exceptionCause.result();
        String message = resultEntity.getMessage();

        if(ArrayUtils.isNotEmpty(args)){
            String [] argsStringArray = new String [args.length];
            for (int i=0;i<args.length;i++) {
                Object arg = args[i];
                argsStringArray[i] = ObjectUtils.toString(arg);
            }
            String formatMessage = String.format(message, argsStringArray);
            resultEntity.setMessage(formatMessage);
        }

        BusinessException businessException = new BusinessException();
        businessException.resultEntity = resultEntity;
        return businessException;
    }

    @Override
    public String getMessage() {
        return resultEntity.getMessage();
    }

    public ResultEntity getResultEntity() {
        return resultEntity;
    }
}

遠程調用異常

遠程調用異常一般別人也會返回錯誤碼和錯誤消息給我們,這時我們可以定義一個遠程異常,把業務異常做爲父級異常,這時候呈現的錯誤結構會是這樣,舉個例子

投保業務出錯 
  -| E007 生效日期必須大於當前日期 

代碼如下,用到了建造者設計模式,如不知道這個設計模式,可以自行百度

import com.sanri.test.testmvc.dto.ResultEntity;
import com.sun.deploy.net.proxy.RemoveCommentReader;

public class RemoteException  extends BusinessException{
    private BusinessException parent;

    private RemoteException(BusinessException parent) {
        this.parent = parent;
    }

    /**
     * 創建遠程異常
     * @param parent
     * @param remoteCode
     * @param remoteMessage
     * @return
     */
    public static RemoteException create(BusinessException parent,String remoteCode,String remoteMessage){
        RemoteException remoteException = new RemoteException(parent);
        remoteException.resultEntity = ResultEntity.err(remoteCode).message(remoteMessage);
        return remoteException;
    }

    /**
     * 簡易創建遠程信息
     * @param parent
     * @param remoteMessage
     * @return
     */
    public static RemoteException create(BusinessException parent,String remoteMessage){
        return create(parent,"remoteError",remoteMessage);
    }

    public static RemoteException create(String localMessage,String remoteCode,String remoteMessage){
        return new Builder().localMessage(localMessage).remoteCode(remoteCode).remoteMessage(remoteMessage).build();
    }
    public static RemoteException create(String localMessage,String remoteMessage){
        return new Builder().localMessage(localMessage).remoteMessage(remoteMessage).build();
    }

    public static class Builder{
        private String localCode;
        private String localMessage;

        private String remoteCode;
        private String remoteMessage;

        public Builder localCode(String localCode){
            this.localCode = localCode;
            return this;
        }
        public Builder localMessage(String localMessage){
            this.localMessage = localMessage;
            return this;
        }
        public Builder remoteCode(String remoteCode){
            this.remoteCode = remoteCode;
            return this;
        }
        public Builder remoteMessage(String remoteMessage){
            this.remoteMessage = remoteMessage;
            return this;
        }

        public RemoteException build(){
            BusinessException businessException = BusinessException.create(localCode, localMessage);
            RemoteException remoteException = RemoteException.create(businessException,remoteCode,remoteMessage);
            return remoteException;
        }
    }

    public BusinessException getParent() {
        return parent;
    }
}

優雅的拋出異常

見過很多項目拋出新異常時使用了這樣的方式 throw new BusinessException(...) 感覺特別不雅觀。

  • 我們不需要暴露異常的構造函數,可以這樣子
BusinessException.create("姓名重複,請重新輸入");
  • 或者我們可以使用枚舉,在枚舉類中添加一個方法來創建異常,這針對需要錯誤編號的異常。

使用方法:

throw SystemMessage.NOT_LOGIN.exception();

代碼定義:

import com.sanri.test.testmvc.dto.ResultEntity;

public interface ExceptionCause<T extends Exception> {
    T exception(Object... args);

    ResultEntity result();
}
import com.sanri.test.testmvc.dto.ResultEntity;

public enum  SystemMessage implements ExceptionCause<BusinessException> {  
    NOT_LOGIN(4001,"未登錄或 session 失效"),
    PERMISSION_DENIED(4002,"沒有權限"),
    DATA_PERMISSION_DENIED(4007,"無數據權限"),
    SIGN_ERROR(4003,"簽名錯誤,你的簽名串爲 [%s]")
    ;
    ResultEntity resultEntity = new ResultEntity();

    private SystemMessage(int returnCode,String message){
        resultEntity.setReturnCode(returnCode+"");
        resultEntity.setMessage(message);
    }

    @Override
    public BusinessException exception(Object...args) {
        return BusinessException.create(this,args);
    }

    @Override
    public ResultEntity result() {
        return resultEntity;
    }

    /**
     * 自定義消息的結果返回
     * @param args
     * @return
     */
    public ResultEntity result(Object ... args){
        String message = resultEntity.getMessage();
        resultEntity.setMessage(String.format(message,args));
        return resultEntity;
    }

    public String getReturnCode(){
        return resultEntity.getReturnCode();
    }
}
  • 我們可以進一步封裝,將其轉換成斷言,這個就看個人喜好了,將可以這樣使用,只是寫個例子,一般登錄都在過濾器就攔截了。
assertLogin();

/**
 * 斷言用戶是否爲登錄狀態  
 */
public void assertLogin(){
    // 獲取當前用戶,從 session 或 redis 或 auth2 或 shiro 或 SSO 中獲取 
    User user = xxx.get();
    if(user == null){
        throw SystemMessage.NOT_LOGIN.exception();
    }
}

演示使用方法

@RestController
public class ExceptionController {

    /**
     * 靜態異常展示,固定錯誤碼
     */
    @GetMapping("/staticException")
    public void staticException(){

        throw SystemMessage.ACCESS_DENIED.exception("無權限");
    }

    /**
     * 動態異常,前端不關注錯誤碼
     */
    @GetMapping("/dynamicException")
    public void dynamicException(){

        throw BusinessException.create("名稱重複,請使用別的名字");
    }

    /**
     * 第三方調用異常,需顯示層級異常
     */
    @GetMapping("/remoteException")
    public void remoteException(){

        //模擬遠端錯誤
        String remoteCode = "E007";
        String remoteMessage = "生效日期必須大於當前日期";

        throw RemoteException.create("某某業務調用錯誤",remoteCode,remoteMessage);
    }
}

github 項目代碼

以上代碼可以到我的 github 上下載相關項目,可以直接運行,拆箱即用。
https://gitee.com/sanri/example/tree/master/test-mvc

sanri-tools 工具

創作不易,希望可以推廣下我的小工具,很實用的解決項目中的一些麻煩的事情,歡迎來 github 點星,fork
github 地址:https://gitee.com/sanri/sanri-tools-maven
博客地址:https://blog.csdn.net/sanri1993/article/details/98664034

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章