搭建系统框架时,关于异常,我们一般要考虑这样几件事情。

系统中有哪些异常

这个问题其实很简答:一类是业务异常,例如“用户输入的身份证号不合法”、“银行卡四要素鉴权失败”、“余额不足”等业务逻辑上的问题;除此之外的全都是系统异常,例如网络超时、数据库锁超时、甚至堆栈溢出内存溢出等等。

业务异常中,有几种特殊的异常。当我们是通过类似乐观锁的方式来检测幂等时,在流程中任何一点上都有可能发现当前数据、当前业务已经执行过一遍了。这时系统不能按正常逻辑继续处理,必须中断并回滚此前的操作;同时需要接口上给调用方返回一个成功的结果。此时,就需要一个专门的异常来处理这种问题。

此外,虽然绝大多数情况下,发生异常就应当回滚事务,但偶尔也有例外:某些异常不需要回滚事务。这种异常也需要有额外的标记和处理。

系统异常一般没什么别的办法,除了重试就是抛出。但是业务异常,值得我们考虑考虑。

业务异常有什么特点

在业务系统中、由我们手动抛出的异常,是一类怎样的异常呢?

问题常常能且只能用户处理

设想一下,用户输入的身份证号不合法,除了提示用户重新输入一遍,还有什么办法?四要素鉴权失败,除了提示用户重新输入一遍,还有什么办法?余额不足了,除了让用户去充值,还有什么办法?没办法,你只能交给用户去处理。

而且很多时候,这类问题可以交给用户去处理。身份证错了,用户可以重新输入一个正确的;四要素鉴权失败,用户可以重新输入一遍;余额不足了,用户可以充值。业务异常的问题是可以交给用户来处理的——这也是最省事儿的方法。

当然,还有一种办法是给开发提bug或工单,要求开发查一下……但是这样做了的话,通常会把开发的仇恨值拉满:“都已经有那么明确的提示了,还要我们查什么”。

系统常常只有一种处理流程

很多业务功能中,系统都只有一种处理流程,没有替代流程。因此,这个流程出现问题后,我们就只能中断系统流程、反馈给用户去处理。

显然,如果系统有可替代方案的话,那么,主流程发生问题时,我们就可以尝试“恢复上下文”并使用替代流程再处理一遍。如果所有的替代流程都失败了,最后再交给用户处理。

例如, 把String解析为Date时,我们可以先按照"yyyy-MM-dd"解析一遍;如果解析失败再尝试按"yyyyMMdd"解析一遍。又如,如果业务允许,那么用户四要素鉴权失败时,可以尝试一次三要素鉴权;如果三要素还失败,再尝试一次二要素鉴权。还有,系统做逾期还款时,有一个“自动减免三天罚息”的逻辑:如果钱不够还了,先尝试减免一天,还不够就再减免一天,还不够就再减免一天……直到够还了、或者已经把罚息违约金减免完了、或者已经减免了三天的钱了。这些都是有替代流程的情况,就不能简单地把异常丢给用户处理。

怎样定义业务异常类

通常,我们需要声明一个特定的异常类,用来标记当前发生的问题是“业务逻辑发生问题”,而不是“系统发生故障”。例如,如果是网络故障,可能会抛出IOException,如果是数据库问题,可能会是SQLException,这种就是“系统发生故障”。但是,如果是“用户输入的身份证号不合法”、“银行卡四要素鉴权失败”、“余额不足”等业务逻辑上的问题,很多时候我们都使用业务异常直接向上抛出。

命名

所谓编程,95%的情况下都是在命名。业务异常也一样。虽然大多数情况下业务异常都会命名为BizException、BusinessException或者ServiceException之类的名字,但是,我更建议用系统名字来命名业务异常:MySystemException、HerSystemException等。

如果你感受过一个系统里有四五个ServiceException类,你就知道我为什么建议这样命名了。我曾经在系统A中抛出了一个系统B中声明的ServiceException,结果是显然的:系统A中的拦截器没有对系统B的ServiceException做处理,我抛出的那个异常就直接被当做500返回给客户端了。

Checked or Runtime

业务异常是使用受检异常还是运行时异常?时至今日,这个问题已经没有太多的疑问了:应该使用运行时异常。

如果使用受检异常,那么抛出异常的代码就必须在方法名上声明throws。然而,系统对绝大多数业务异常都束手无策,对方法上声明的throws也别无选择,只有把它沿着调用链逐层向上传递。这种做法除了引入冗余的代码和编译问题外,对系统没有什么实质性的帮助。

使用运行时异常,则可以让系统代码对底层异常无感知——既不需要在方法名上声明throws,也不需要逐层把异常声明向上传递。这样对大多数代码和程序员都更友好。

error code

业务异常里带上错误码,其实是被接口返回值绑定的。接口返回值要给前端一个错误码,用以区分后端发生了什么错误、前端如何处理。当发生异常时,为了保证接口返回值的一致性,就要把异常转换成对应的错误码。系统异常一般会统一转换为一个错误码;业务异常不能这样一刀切,就只好自己带上错误码了。如果所有接口都不需要返回错误码,而是直接跳转页面,那么业务异常里其实可以不返回错误码。

我所见的大多数情况下,业务异常里的错误码都是一串数字。0000代表成功、1000代表入参不合法、2000代表四要素鉴权失败、3000代表余额不足,等等。

用数字做错误码有两个好处。

其一是比较安全。我们并不知道访问系统的到底是用户还是黑客,因而不能让他知道到底出了什么错。例如用户登录不上系统时,不能告诉对方到底是用户名错误还是密码错误,以免被爆破出用户信息。此时,如果系统返回一个1001错误码,请问到底是用户名错了还是密码错了?不知道,总归你重新输入就对了。

其二是可以方便地做归并处理。这一点上HttpStatusCode就是一个非常好的例子。我们都知道,HttpStatusCode中,2xx是正常返回,3xx是资源位置变化,4xx是访问不到服务,5xx是服务内部异常。这样,我们就可以用if(code >=200 && code <300)这样的代码来把这一类问题统一处理了。

当然,有好处就有坏处。最直接的坏处就是错误码一多,开发自己也记不清楚哪个错误码代表什么问题了。随之导致的“次生灾害”就是新增异常时,错误码很容易发生重复。对trouble shooting、接口对接来说,这都是不大但也不小的问题。

除了数字之外,也有些系统直接用可以表意的字符串来做错误码:“SUCCESS”自然是成功,“ILLEGAL_PARAM”表示参数不合法,“FOUR_ITMES_ERROR”表示四要素鉴权失败、“BALANCE_NOT_ENOUGH”表示余额不足,诸如此类。

这种方式当然不如使用数字那么安全,但是更加一目了然,接口对接和查问题时更加方便快捷——安全和便捷在很多场景下都是这样矛盾的。而且,这类错误码出现重复的可能性比纯数字的更小。如果系统仅仅提供中后台服务而不需要直接和前端用户交互,那么这种可以表意的错误码是更好的选择。

error message

毋庸置疑,业务异常里应该带上错误信息:无论是给用户看还是给开发看,我们都需要一个明确的信息。

不过,给用户看的和给开发看的还是有区别的。给用户看的重点在于描述怎样解决这个问题:四要素鉴权失败了需要去联系银行确认银行卡预留手机号;余额不足了需要点击某个链接去充值等等。给开发看的重点在于描述问题是什么:是唯一键冲突、还是锁超时、或者干脆就是死锁了?是网络超时了、还是404了?

一般来说,给用户看的文案应该由产品或者交互来定;但是产品对系统中的“异常”了解得并不多。因而,绝大多数的业务异常的文案都是开发自己定的。这就使得很多时候用户面对系统提示会一脸茫然:这是发生了什么问题?除了打客服电话之外,我还能怎么办?

所以有时候我会觉得,异常信息和用户提示,不能一概而论。给用户的提示信息应该由接口、甚至前端来处理;而异常中的信息只提供给开发trouble shooting用。当然,要这样做的话,需要付出很大的编码成本,未必划算。

data

异常里要不要带上发生问题时的上下文数据?这取决于捕获这个异常之后要做怎样的处理。如果需要使用上下文数据,往往就只能在异常里把它带出来了。

例如,我们有一个校验,要求在拦截住用户之后,把用户类型(新用户、老用户、黑名单用户)发送给前端,前端据此将用户引导到不同的产品页面上。这时候,我们就使用了一个“带数据的异常”来处理这种情况:

public class ApplyController{
    public Result<Apply> apply(long userId){
        Result<Apply> result = new Result<>();
        try{
            Apply apply = applyService.apply(userId);
            result.setCode(SUCCESS_CODE);
            result.setCode(SUCCESS_MSG);
            result.setData(apply);
        }catch(UserTypeException ute){
           result.setCode(ute.getCode());
           result.setMsg(ute.getMsg());
           // 在这里处理了用户类型校验异常带出来的数据
           Apply apply = new Apply();
           apply.setUserType(ute.getUserType());
           result.setData(apply);
        }
        // 其它异常交给AOP统一处理
        return result;
    }
}

是否需要使用子类

绝大多数情况下,我们自定义的业务异常都只需要一个类就行:

public class MySystemException extends RuntimeException{
    private final String code;
    private final String msg;
    public MySystemException(String code, String msg){
        this.code = code;
        this.msg = msg;
    }
    // getters,略
}

但是,就如UserTypeException所展示的那样:有些时候仅仅使用MySystemException 满足不了需求,我们还需要对它进行扩展,通过一个子类来传递更多信息。

public class UserTypeExceptin extends MySystemException {
    private final UserType userType;
    public UserTypeException(Usertype userType){
        super("2002","抱歉,您不能申请这款产品。");
        this.userType = userType;
    }
    // getters
}

使用标准异常还是自定义异常

标准异常和自定义异常的主要区别在于:所有使用Java的人都应该熟悉Java的标准异常;但并不是所有人都熟悉你的自定义异常。

如果你的异常只在自己系统的内部使用,那么建议使用自定义异常。自定义异常比标准异常更“定制化”,在处理各种问题时也更加灵活方便。而且,开发和维护一个系统的人有义务去了解这个系统内部的一些约定、规范和标准,自定义异常自然也在此列。

但是,当你的异常需要提供给其他人使用的时候——例如你要给人提供一个jar包,而jar包中不得不抛出一些异常的时候,这时就应当尽量使用标准异常,因为调用方并没有义务了解你的内部实现细节。

这就像你在自己家乡被狗咬了,你可以用家乡话喊“起开起开”;但是如果你在外国被狗咬了,你就得喊“help”,甚至可能要喊“SOS”、“May Day”,即使在日本、法国这种非英语国家,这些呼救消息也是通用的。

总之,所谓“自定义”异常,是在你的“自定义”范围内的标准异常。如果是别人走进你的“自定义”范围,那么可以要求别人来遵循你的标准。但是如果要走出这个范围,那么你的“自定义”就不能作为标准了。

怎样处理业务异常

作为技术,知道“是什么”和“为什么”之后,我们还需要知道“怎么做”。

只在必要的地方使用异常

相信所有介绍异常的文章中都会提到这一点,只不过表述方式会有差别:“使用if条件判断代替异常”、“只在真正出现问题的时候才使用异常”、“不要用异常来控制业务流程”,诸如此类。

所谓使用异常的“必要的地方”,个人理解,需要满足以下两个条件。

每一段代码块都有其前置条件和后置条件。前置条件是对输入数据的约束,而后置条件则是对输出条件的约束。使用异常的第一个条件,就是代码流程中的数据违反了这两个条件中的任意一项。例如,参数校验不通过是违反前置条件的一种常见情形,而调用某个服务接口得到一个超时异常则是一种违反后置条件的情形。在这些场景下,我们都可以考虑通过抛出异常来反馈问题。

使用异常的第二个条件,就是自己处理不了违反两项条件约束的情况。如果某项参数校验失败后,我们可以给它设置一个默认值,那就不用抛出异常了。如果调用接口超时后我们可以重试、或者可以转为异步处理,那也不必急着抛出异常。

只有同时满足了两个条件,我们才可以说,这个地方有必要抛出一个异常。

在业务入口处统一处理异常

这是最简单、也是最常见的一种做法。无论是使用ControllerAdvice,还是用AOP,我们都可以统一地捕获Controller、Dubbo或gRpc接口、MQ消费者甚至定时任务中抛出的异常,并针对各入口的特性进行处理。具体的实现方式这里就不赘述了。

当然,使用这种方法来处理异常,有一定的前提条件。

首先,要在业务入口进行处理,那么这异常必须符合前文提到的两个特点,即“只能交给用户处理”和“没有替代流程”。自然的,这里的“用户”也包括系统服务的调用方。否则的话,系统应当在更合适的地方——如异常的抛出点,或者离异常最近的业务现场——进行必要的处理。

其次,统一处理必须以统一规范为基础。如果有的接口返回Result,有的接口返回Response,甚至有些接口直接返回String/Long,显然我们无法对它们做统一处理。

第三,统一处理只会针对业务异常和系统异常两个基类来做处理,而不会、也不应该针对某种特定异常进行特殊处理。如果在某些特殊场景下需要使用特定异常并进行特殊处理,应该在那个场景内进行处理,而不应当把某个特殊场景的需求放到统一处理的模块中来。

在特殊场景下使用特定异常并进行特殊处理

那么,都有哪些场景下需要使用特定异常呢?

从根子上来说,当我们需要抛出异常、并且除了统一处理所需信息(如错误码、错误提示文案等信息)之外,还需要借助异常来传递更多信息时,我们就有必要使用特定异常了。

比较常见的场景是某些第三方框架组件要求使用特定异常来标记某些特殊流程。例如,Spring的事务管理框架就需要在@Transactional注解中标记rollbackFor和noRollbackFor两项属性,而这两项属性都只接受Class<? extends Throwable>;Spring的重试框架也需要在@Retryable注解中标记include和exclude两项属性,他们同样只接受`Class<? extends Throwable>。如果我们要使用这两个框架提供的某些功能,一般都需要声明并抛出某种特定异常。这种情况下,这些异常所传递的信息就是通过其类定义告诉对应的处理模块:“要/不要回滚事务”或“要/不要进行重试”。

前面提到过,在使用乐观锁来做幂等处理时,由于乐观锁常常在业务调用链的深处才会被触发,此时我们往往只能用异常来告诉调用方:这个业务操作已经执行过至少一次了,不要重复操作。同时,考虑到接口幂等性,第N次调用与第一次调用应该返回同样的结果,也就是“处理成功”的结果。因此,当触发乐观锁时,我们应当声明、抛出并捕获一个特定异常,并将这个异常结果转换为“处理成功”。这也是一种使用特定异常的场景,乐观锁异常也是通过其类定义传递出“这个业务操作已经执行过至少一次了”这个信息。

有些系统会通过清洗接口层日志,来分析业务中出现的问题。而为了进行日志清洗和分析,除了接口最终返回结果之外,有时还需要把业务调用链深处的一些数据也“带”到接口层日志中,如是调用哪个三方接口时出现了错误、出错时的入参和返回值分别是什么,等等。此时,除了声明一个特定异常,恐怕也没有别的东西可以把这类信息传递出来了。

我遇到过的最特殊的一个场景,是这样一个查询接口。

最初,它的功能是从数据库中查出一张用户绑定的银行卡,并判断这张卡有没有通过四要素认证。如果没有通过四要素认证,那么就抛出业务异常,以告知用户重新绑定一张银行卡。

随着业务扩展,另一个模块也来调用这个接口。但是新的模块要求:如果数据库中查到了卡、只是这张卡没有通过四要素认证,那么需要把这张卡的数据返回给它,以方便它回填到四要素认证表单中,从而提供更好的用户体验。

这么一来,同一个接口、针对同一种情况,有了两种处理逻辑。一种不需要返回数据,直接抛出业务异常;另一种则需要返回数据,还需要返回“是否通过四要素认证”这样一个信息。

显然地,我的处理方式是声明了一个新的业务异常,在这个业务异常中带上了相关数据。这样,既兼容了原有逻辑,又满足了新的需求,轻松简单。

当然,这个问题还有其它的解决方案,不妨拿出来对比一下。