用Assert(斷言)封裝異常,讓程式碼更優雅(附專案原始碼)

雨點的名字發表於2022-03-07

有關Assert斷言大家並不陌生,我們在做單元測試的時候,看業務事務複合預期,我們可以通過斷言來校驗,斷言常用的方法如下:

public class Assert {
    /**
     * 結果 = 預期 則正確
     */
    static public void assertEquals(Object expected, Object actual);
    /**
     * 結果 != 預期 則正確
     */
    static public void assertNotEquals(Object unexpected, Object actual);
    /**
     * condition == true 則正確
     */
    static public void assertTrue(boolean condition);
    /**
     * condition == false 則正確
     */
    static public void assertFalse(boolean condition);
    /**
     * 永遠是錯誤
     */
    static public void fail();
    /**
     * 結果不為空 則正確
     */
    static public void assertNotNull(Object object);
    /**
     * 結果為空 則正確
     */
    static public void assertNull(Object object);
    /**
     * 兩個物件引用 相等 則正確(上面相當於equels 這裡類似於使用“==”比較兩個物件)
     */
    static public void assertSame(Object expected, Object actual);
    /**
     * 兩個物件引用 不相等 則正確
     */
    static public void assertNotSame(Object unexpected, Object actual);
    /**
     * 兩個陣列相等 則正確
     */
    public static void assertArrayEquals(Object[] expecteds, Object[] actuals);
    /**
     * 這個單獨介紹 具體參考部落格:https://www.cnblogs.com/qdhxhz/p/13684458.html
     */
    public static <T> void assertThat(T actual, Matcher<? super T> matcher);  
};

使用斷言能讓我們編碼看去更加的清爽,比如:

   @Test
    public void test1() {
        Order order = orderDao.selectById(orderId);
        Assert.notNull(order, "訂單不存在。");
    }

    @Test
    public void test2() {
        // 另一種寫法
        Order order = orderDao.selectById(orderId);
        if (order == null) {
            throw new IllegalArgumentException("訂單不存在。");
        }
    }

這兩種方式一對比,是不是明顯感覺第一種更優雅,第二種寫法則是相對醜陋的 if {...} 程式碼塊。那麼 神奇的 Assert.notNull() 背後到底做了什麼呢?

下面是 Assert 的部分原始碼:

public abstract class Assert {
    public Assert() {
    }

    public static void notNull(@Nullable Object object, String message) {
        if (object == null) {
            throw new IllegalArgumentException(message);
        }
    }
}

可以看到,Assert 其實就是幫我們把 if {...} 封裝了一下,是不是很神奇。雖然很簡單,但不可否認的是編碼體驗至少提升了一個檔次。

那麼我們是不是可以模仿Assert,也寫一個自定義斷言類,不過斷言失敗後丟擲的異常不是IllegalArgumentException 這些內建異常,而是我們自己定義的異常。

下面讓我們來嘗試一下。

public interface Assert {
    /**
     * 建立異常
     * @param args
     * @return
     */
    BaseException newException(Object... args);

    /**
     * 建立異常
     * @param t
     * @param args
     * @return
     */
    BaseException newException(Throwable t, Object... args);

    /**
     * 斷言物件 obj 非空。如果物件 obj 為空,則丟擲異常
     *
     * @param obj 待判斷物件
     */
    default void assertNotNull(Object obj) {
        if (obj == null) {
            throw newException(obj);
        }
    }

    /**
     * 斷言物件 obj 非空。如果物件 obj 為空,則丟擲異常
     * 異常資訊 message 支援傳遞引數方式,避免在判斷之前進行字串拼接操作
     *
     * @param obj 待判斷物件
     * @param args message佔位符對應的引數列表
     */
    default void assertNotNull(Object obj, Object... args) {
        if (obj == null) {
            throw newException(args);
        }
    }
}

  1. 這裡只給出Assert介面的部分原始碼,更多斷言方法請參考專案的原始碼。
  2. BaseException 是所有自定義異常的基類。
  3. 在介面中定義預設方法是Java8的新語法。

上面的Assert斷言方法是使用介面的預設方法定義的,然後有沒有發現當斷言失敗後,丟擲的異常不是具體的某個異常,而是交由2個newException介面方法提供。

因為業務邏輯中出現的異常基本都是對應特定的場景,比如根據使用者id獲取使用者資訊,查詢結果為null,此時丟擲的異常可能為UserNotFoundException,並且有特

定的異常碼(比如7001)和異常資訊“使用者不存在”。所以具體丟擲什麼異常,有Assert的實現類決定。

看到這裡,你可能會有會有疑問,按照上面的做法,那不是有多少個異常情況,我都要去實現Assert介面,然後在寫定義等量的斷言類和異常類,這顯然是反人類的,

這也沒想象中高明嘛。別急,且聽我細細道來。


一、善解人意的Enum

自定義異常BaseException有2個屬性,即code、message,這樣一對屬性,有沒有想到什麼類一般也會定義這2個屬性?

沒錯,就是列舉類。且看我如何將 Enum 和 Assert 結合起來,相信我一定會讓你眼前一亮。如下:

public interface IResponseEnum {
    int getCode();
    String getMessage();
}

我們自定一個業務異常類

/**
 * 業務異常
 * 業務處理時,出現異常,可以丟擲該異常
 *
 */
public class BusinessException extends  BaseException {

    private static final long serialVersionUID = 1L;

    public BusinessException(IResponseEnum responseEnum, Object[] args, String message) {
        super(responseEnum, args, message);
    }

    public BusinessException(IResponseEnum responseEnum, Object[] args, String message, Throwable cause) {
        super(responseEnum, args, message, cause);
    }
}

業務異常斷言實現類,它同時繼承2個介面,一個是Assert介面,一個是IResponseEnum介面

public interface BusinessExceptionAssert extends IResponseEnum, Assert {

    @Override
    default BaseException newException(Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);

        return new BusinessException(this, args, msg);
    }

    @Override
    default BaseException newException(Throwable t, Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);

        return new BusinessException(this, args, msg, t);
    }

}

業務異常列舉

/**
 *  業務異常列舉
 */
@Getter
@AllArgsConstructor
public enum BusinessResponseEnum implements BusinessExceptionAssert {


    USER_NOT_FOUND(6001, "未查詢到使用者資訊"),

    ORDER_NOT_FOUND(7001, "未查詢到訂單資訊"),

    ;

    /**
     * 返回碼
     */
    private int code;
    /**
     * 返回訊息
     */
    private String message;

}

這樣如果是業務異常,就都可以在BusinessResponseEnum中定義一個新列舉物件就可以了。以後每增加一種異常情況,只需增加一個列舉例項即可,再也不用每一種異常

都定義一個異常類了。接下來看使用如下:

   /**
    * 查詢使用者資訊
    */
    public User queryDetail(Integer userId) {
        final User user = this.getById(userId);
        // 校驗非空
        BusinessResponseEnum.USER_NOT_FOUND.assertNotNull(user);
        return user;
    }

若不使用斷言,程式碼可能如下:

  /**
    * 查詢使用者資訊
    */
    public User queryDetail(Integer userId) {
        final User user = this.getById(userId);

        if (user == null) {
            throw new UserNotFoundException();
            // 或者這樣
            throw new BusinessException(6001, "未查詢到使用者資訊");
        }
        return user;
    }

使用列舉類結合(繼承)Assert,只需根據特定的異常情況定義不同的列舉例項,如上面的USER_NOT_FOUND、ORDER_NOT_FOUND,就能夠針對不同情況丟擲特定的異常

(這裡指攜帶特定的異常碼和異常訊息),這樣既不用定義大量的異常類,同時還具備了斷言的良好可讀性。


 二、驗證統一異常處理

在throw丟擲異常後,我們就可以統一去處理異常,如果有必要我們可以加入異常日誌存入資料庫中,有助於後續排查問題。

/**
 *  全域性異常處理器
 * 
 * @author xub
 * @date 2022/2/28 上午10:52
 */
@Slf4j
@Component
@ControllerAdvice
@ConditionalOnWebApplication
public class ViewExceptionResolver {
    /**
     * 生產環境
     */
    private final static String ENV_PROD = "prod";

    @Autowired
    private UnifiedMessageSource unifiedMessageSource;

    /**
     * 當前環境
     */
    @Value("${spring.profiles.active}")
    private String profile;

    /**
     * 獲取國際化訊息
     *
     * @param e 異常
     * @return
     */
    public String getMessage(BaseException e) {
        String code = "response." + e.getResponseEnum().toString();
        String message = unifiedMessageSource.getMessage(code, e.getArgs());

        if (message == null || message.isEmpty()) {
            return e.getMessage();
        }

        return message;
    }

    /**
     * 業務異常
     *
     * @param e 異常
     * @return 異常結果
     */
    @ExceptionHandler(value = BusinessException.class)
    @ResponseBody
    public CommandResult handleBusinessException(BaseException e) {
        log.error(e.getMessage(), e);
        return CommandResult.ofFail(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
     * 自定義異常
     *
     * @param e 異常
     * @return 異常結果
     */
    @ExceptionHandler(value = BaseException.class)
    @ResponseBody
    public CommandResult handleBaseException(BaseException e) {
        log.error(e.getMessage(), e);

        return CommandResult.ofFail(e.getResponseEnum().getCode(), getMessage(e));
    }


    /**
     * 未定義異常
     *
     * @param e 異常
     * @return 異常結果
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public CommandResult handleException(Exception e) {
        log.error(e.getMessage(), e);

        if (ENV_PROD.equals(profile)) {
            // 當為生產環境, 不適合把具體的異常資訊展示給使用者, 比如資料庫異常資訊.
            int code = CommonResponseEnum.SERVER_ERROR.getCode();
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return CommandResult.ofFail(code, message);
        }

        return CommandResult.ofFail(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
}

 三、測試驗證

這裡請求地址,業務id=10資料庫是不存在這個使用者,所以會報錯。

localhost:8085/user/getUserInfo?userId=10
用Assert(斷言)封裝異常,讓程式碼更優雅(附專案原始碼)

達到了預期效果。


 四、總結

使用 斷言列舉類 相結合的方式,再配合統一異常處理,基本大部分的異常都能夠被捕獲。為什麼說大部分異常,因為當引入 spring cloud security 後,還會

有認證/授權異常,閘道器的服務降級異常、跨模組呼叫異常、遠端呼叫第三方服務異常等,這些異常的捕獲方式與本文介紹的不太一樣,不過限於篇幅,這裡不做詳細說明,

以後會有單獨的文章介紹。

專案地址:用Assert(斷言)封裝異常,讓程式碼更優雅(附專案原始碼)


感謝

這篇文章給自己提供了很好的思路,基本上按照這個思路往下寫的

統一異常處理介紹及實戰:https://www.jianshu.com/p/3f3d9e8d1efa

相關文章