统一异常处理介绍及实战——支持自定义错误信息

ps: 因为本文的内容比较简单,所以都是以测试用例来做实例,但逻辑与在 web 项目大同小异,具体代码详见 这里
ps: 本文作为 统一异常处理介绍及实战 这篇文章的扩展,若还没阅读过,还请先移步过去了解一下,它会为你打开一扇神奇的门,看到不一样的统一异常处理方式。

背景

在前文 统一异常处理介绍及实战 中介绍如何优雅地抛出业务异常。举个例子,如果希望在创建订单的时候,检测到商品不存在,抛 “创建订单失败” 的异常,可以这么写:

@Test
public void assertNotNull() {
    Goods goods = getGoods("1001");
    ResponseEnum.ORDER_CREATION_FAILED.assertNotNull(goods);

    // others
}

@Getter
@AllArgsConstructor
public enum ResponseEnum implements BusinessExceptionAssert {

    ORDER_CREATION_FAILED(7001, "订单创建失败,请稍后重试");

    private int code;
    private String message;
}

public Goods getGoods(String id) {
    return null;
}

上面的代码最后打印如下日志:


order creation failed

但有没有发现,控制台打印的内容,对分析问题的帮助有限,因为导致订单失败的原因有很多,就比如上面举例的 商品不存在,也有可能是 计算订单金额时出现异常 ,亦或是 调用其他服务时发现服务不可用 等等。

其实在开发中,这样的场景是很常见,可以简单归纳为:一个大类异常可以再细分出各种更具体的异常,并且用户并不关心具体异常,只关心此次操作成功与否

虽说用户不关心真正的错误原因,但对于开发人员来说,还是有必要知道真正的问题出在哪里,不然运维看到这些日志然后,说:那啥,用户创建订单失败,你看是不是有bug。然后我瞬间就——

我瞬间就

如果可以在打印日志的时候顺便也把具体错误信息也打印出来,那定位问题就简单多了。比如:商品服务突然宕机不可用了,运维看到了直接紧急恢复下服务,用户就又能正常下单了。

自定义错误信息

具体的错误信息,肯定不是程序自己凭空构造出来的,而是需要开发人员在开发过程中,以某种形式去教程序怎么构造,构造出来后,跟最终返回给用户端的错误信息一起打印出来。

所以打印出来的错误日志,必须包含2个错误信息,一个是给用户看的错误信息(订单创建失败),另一个是给运维/开发人员看的错误信息(获取商品详情失败)。

分析到这里,接下来就是怎么实现的问题了。

assert*WithMsg

这里选择使用增加 assert*WithMsg 方法的方式,即每一种类型的断言方法,都增加2套 assert*WithMsg 方法,为什么是2套,下文会给出答案。

这里以 断言非空 为例子,其他的都一样,代码如下:

/**
 * <p>断言对象<code>obj</code>非空。如果对象<code>obj</code>为空,则抛出异常
 *
 * @param obj 待判断对象
 * @param errMsg 自定义的错误信息
 */
default void assertNotNullWithMsg(Object obj, String errMsg) {
    if (obj == null) {
        WrapMessageException e = new WrapMessageException(errMsg);
        throw newException(e);
    }
}

/**
 * <p>断言对象<code>obj</code>非空。如果对象<code>obj</code>为空,则抛出异常
 * <p>异常信息<code>message</code>支持传递参数方式,避免在判断之前进行字符串拼接操作
 *
 * @param obj 待判断对象
 * @param errMsg 自定义的错误信息. 支持 {index} 形式的占位符, 比如: errMsg-用户[{0}]不存在, args-1001, 最后打印-用户[1001]不存在
 * @param args message占位符对应的参数列表
 */
default void assertNotNullWithMsg(Object obj, String errMsg, Object... args) {
    if (obj == null) {
        if (ArrayUtil.isNotEmpty(args)) {
            errMsg = MessageFormat.format(errMsg, args);
        }

        WrapMessageException e = new WrapMessageException(errMsg);
        throw newException(e, args);
    }
}

其中涉及到一个异常类 WrapMessageException,其实就是一个继承了 RuntimeException 的普通异常类,这里可以先理解为就是 RuntimeException,至于为什么要定义这么一个异常,这里先卖个关子。

当传入自定义错误信息 errMsg 后,使用该错误信息创建一个 WrapMessageException,然后把它传给 newException(Throwable t)。这么做有什么好处呢? 我们再来写个测试用例,看一下最终的打印效果。

@Test
public void assertNotNull2() {
    String goodsId = "1001";
    Goods goods = getGoods(goodsId);
    ResponseEnum.ORDER_CREATION_FAILED.assertNotNullWithMsg(goods, "商品[{0}]不存在", goodsId);

    // others
}

打印结果如下:


商品不存在

有没有看到那个 Caused by(相信各位大佬都是知道怎么看异常信息的),把我们刚刚传进去的具体错误信息也打印出来了。再从整体上看,从上到下分别是:订单创建失败,请稍后重试Caused by: 商品[1001]不存在,是不是很流畅,一下子就能定位具体异常。

newExceptionWithMsg

因为有很多断言方法,每个方法都需要写大致相同的逻辑,所以这里再封装两个 newExceptionWithMsg 默认方法,如下:

/**
 * 创建异常.
 * 先使用 {@code errMsg} 创建一个 {@link WrapMessageException} 异常,
 * 再以入参的形式传给 {{{@link #newException(Throwable, Object...)}}}, 作为最后创建的异常的 cause 属性.
 *
 * @param errMsg 自定义的错误信息
 * @param args
 * @return
 */
default BaseException newExceptionWithMsg(String errMsg, Object... args) {
    if (args != null && args.length > 0) {
        errMsg = MessageFormat.format(errMsg, args);
    }

    WrapMessageException e = new WrapMessageException(errMsg);
    throw newException(e, args);
}

/**
 * 创建异常.
 * 先使用 {@code errMsg} 和 {@code t} 创建一个 {@link WrapMessageException} 异常,
 * 再以入参的形式传给 {{{@link #newException(Throwable, Object...)}}}, 作为最后创建的异常的 cause 属性.
 *
 * @param errMsg 自定义的错误信息
 * @param args
 * @return
 */
default BaseException newExceptionWithMsg(String errMsg, Throwable t, Object... args) {
    if (ArrayUtil.isNotEmpty(args)) {
        errMsg = MessageFormat.format(errMsg, args);
    }

    WrapMessageException e = new WrapMessageException(errMsg, t);
    throw newException(e, args);
}

最后的 assert*WithMsg 方法为:

default void assertNotNullWithMsg(Object obj, String errMsg) {
    if (obj == null) {
        throw newExceptionWithMsg(errMsg);
    }
}

default void assertNotNullWithMsg(Object obj, String errMsg, Object... args) {
    if (obj == null) {
        throw newExceptionWithMsg(errMsg, args);
    }
}

复杂的错误信息

考虑到自定义的错误信息有可能会比较复杂,所以又定义一套 assert*WithMsg 方法来处理这种场景。定义如下:

default void assertNotNullWithMsg(Object obj, Supplier<String> errMsg) {
    if (obj == null) {
        throw newExceptionWithMsg(errMsg.get());
    }
}

default void assertNotNullWithMsg(Object obj, Supplier<String> errMsg, Object... args) {
    if (obj == null) {
        throw newExceptionWithMsg(errMsg.get(), args);
    }
}

唯一不同的是,errMsg 的类型变了,变成 Supplier<String>,该接口为 java8 提供的,在使用 java8lambda 表达式 新特性时经常会用到,如果对这一特性不是特别了解,可先略过,只需知道一点就是:可以通过 errMsg.get() 得到想要的自定义异常。

这就是另一套 assert*WithMsg 方法了,哈哈。。。

为什么定义 WrapMessageException 异常类

首先来看下具体源码:

/**
 * 只包装了 错误信息 的 {@link RuntimeException}.
 * 用于 {@link com.sprainkle.spring.cloud.advance.common.core.exception.assertion.Assert} 中用于包装自定义异常信息
 */
public class WrapMessageException extends RuntimeException {
    public WrapMessageException(String message) {
        super(message);
    }

    public WrapMessageException(String message, Throwable cause) {
        super(message, cause);
    }
}

可以看到,源码很简单,就是继承了 RuntimeException,并且只提供2个构造方法。至于为什么,这个跟 WrapMessageException 这个类的职能有关。因为该类只用来包装 错误信息,也可以理解为 错误信息 的载体,所以不定义无参构造方法。另外,有时已经有一个具体异常,那么当然也需要支持传进来,所以又加多一个构造方法。

至于为什么定义这样一个异常类,考虑到以后可能会对捕获到的异常进一步分析,如果检测到存在 WrapMessageException,则执行某种逻辑,所以必须定义一个具体异常类,且不能继承 BaseException,因为没有 code 属性。如果直接使用 RuntimeException 则很难解决上面的需求。

总结

当需要自定义详细错误信息时,可以使用如下代码:

ResponseEnum.ORDER_CREATION_FAILED.assertNotNullWithMsg(goods, "商品[{0}]不存在", goodsId);

如果错误信息比较复杂,需要依赖其他变量来构造,可以使用如下代码:

int a = 1;
String b = "2";
Xxx c = ...;
ResponseEnum.ORDER_CREATION_FAILED.assertNotNullWithMsg(goods, () -> "XXX" + a + b + c, goodsId);

// 不要这么用,因为无论断言是否成功,都会拼接错误信息
ResponseEnum.ORDER_CREATION_FAILED.assertNotNullWithMsg(goods, "XXX" + a + b + c, goodsId);

谢谢观看,完!!!

推荐阅读

Spring Cloud 进阶玩法
Spring Cloud Stream 进阶配置——使用延迟队列实现“定时关闭超时未支付订单”