异常与错误码
在开发业务系统代码,我们会经常与异常与错误码打交道,但有时傻傻地分不清楚。编写代码时,到底是使用异常还是返回错误码,一直以来都被程序员们广泛争论。
我们先来看看他们的区别,在编程语言上区别:
- 异常:与面向对象编程结合紧密,它是一个类型系统,表示程序运行时发生错误的信号,一种识别及响应错误情况的一致性机制。
- 错误码:与面向过程编程结合紧密,它通常是一串数字,表示处理函数返回业务流程错误的结果,错误码很容易被忽略且经常被忽略。
在接口定义上区别:
- 异常: 面向代码开发者,通常用于在代码实现层,尤其是在面对象语言中,接口定义异常需要方法签名,以强制要求接口使用都处理异常。
- 错误码:面向客户界面,通常用于对外接口响应非正常结果定义,自定义错误码以增加接口的交互体验与问题定位。
从语言设计工程化来说,异常优于错误码,它有如下优点:
- 正常控制流会被立即中止,无效值或状态不会在系统中继续传播
- 提供了异常堆栈,便于开发者定位异常发生的位置。
- 提供了统一的处理错误的方法,有非常强的合约。
因此很多的书籍建议:用抛出异常代替返回错误代码,但实际运用中,异常和继承一样,经常被滥用的东西,适得其反。
受检异常
以Java为例,异常系统又分为两种:
- 受检异常(checked exception):继承自java.lang.Exception的异常,这种异常需要显式的try/catch或throw出来。引起该异常的原因多种多样,比如说文件不存在、或者是连接错误等等。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。
- 非检查异常(unchecked exception):继承自java.lang.RunTimeException的异常,这种异常不需要显式的try/catch或throw。与受检异常不同,它则是代码Bug导致的异常,如空指针异常,下标越界异常等。非检查异常我们应该是通过完善代码来避免它的出现。
受检异常也是我们常纠结说的:是使用异常还是返回错误码
中的异常,它实际上是业务层需要处理的错误,在开发时可以确定。受检异常设计的出发点很好,严谨的处理这些异常会很好地提高软件的健壮性。因为受检异常它强调:一个方法指定自己一定会抛什么异常,调用者必须一定要处理,或者明确声明继续向上抛。那么整个程序对异常的处理是明确而清晰的。
现实问题
从实际经验来看,受检异常运用在较底层的SDK上,会使SDK与其使用者之间形成一种契约。但我们开发的大多是业务系统,如果强行套用受检异常真是吃力却不讨人喜欢。我曾接手一个业务系统的代码,看到满眼的异常处理与异常转换,增加了很多其实是对业务逻辑无意义的代码,代码显得非常臃肿而不是那么的Clean。原因是在业务系统中,一个典型的业务接口,一个正常结果,却可能有上十个不正常的错误结果返回,如果每种都定义一个异常,则受检异常要求必要一个个声明,一个个处理。
是人大多都有惰性,一个个处理显然是不现实的,常用的手段就是catch根Exception,吃掉所有异常就无法区分不同的错误结果;要么是直接都往上抛,一般来说,业务系统通常会在最上层有一个收底的异常处理。不同层次的代码对异常的理解不一样,到了最上层收底,它只能是像catch根Exception,对外显示系统错误这类非常笼统的东西,让人非常地迷惑。
业内观点
有非常多的学者都在讨论受检异常应该去掉,理由大概是:
受检异常要求客户端程序员必要处理异常,但是程序员未必能知道如何处理,而从异常中恢复其实挺困难的,强迫程序员去处理的话是不现实的。常变成了编写什么不做的代码来“处理”它,导致“吞食则有害”的问题。吞掉能通过编译,但也隐藏了问题。
所以即使在JVM系统的Scala与Kotlin,他们在设计上没有继承了Java的受检异常机制,方法上异常的签名变成了可选。在Java系统中,也有像apache commons工具类ExceptionUtils.rethrow把受检异常转成运行异常,也有Lombok的@SneakyThrows注解来自动生成转换代码。这也侧面说明受检异常不受欢迎大有它的市场。
再来看看函数式编程中,对于错误处理通用是Result类型,Rust语言吸纳它。而Go语言则更为简化,直接把错误码作为一种返回类型,异常则是panic。从他们设计上可以看出,把逻辑上的Bug(Java中的RunTimeException)与业务可恢复错误机制(Java中的Exception)区分了,而不是像Java那样统一采用异常机制:
- 逻辑Bug:提供是一种快速失败Fail-Fast机制,如Go中的panic,以及Rust中异常,他们会导致程序运行崩溃,崩溃时可以打印堆栈用于定位问题。
- 业务错误:提供是一种错误模型,如Go中的error接口,以及Rust中的Result类型,他们是一种编程契约,要求其使用处理可恢复。
业务错误码
回到开头的问题,对于Java程序,我们可以基于异常机制来传递错误码。基于这种开发方式可以避免大量的重复的try/catch(受检异常检查)或者if/else(错误码的判断)语句,让我们的代码更加简洁。
基于个人的经验建议实施如下:
- 基于Exception或RunTimeException定义一个业务异常基类,如BizException
- BizException类包含httpStatusCode(对应Http的状态码), errorCode(业务错误码), description(错误码描述), params(参数列表,可用于上层基于错误码做国际化字段替换)
- 基于场景细分几大类子异常,如非法参数异常,未认证异常,无权限异常等
- 方法对异常签名可以统一为BizException基类
- 业务处理异常分支时,直接构造BizException或其子类,填充errorCode,description,params参数抛出
- 一般没有必要catch BizException,而是直接继续向上抛
- 最后一个收底的ExceptionHandler,把BizException中的字段转换为http状态码,以及Json Body(含errorCode,description,params等)
带来收益:
- 代码干净清爽,不存在无意义的异常转换
- 格式统一,机制统一
- 接口错误码与实现保持一致
需要注意是,需要区分接口错误码与内部异常。有哪些需要内部消化的异常,不能直接透传给接口响应,如数据库异常,调用其它服务口超时异常
结语
受检异常在新的语言纷纷抛弃,编译上语法约定并不能根本上解决业务场景上错误处理的健壮性(吞食问题)。业务系统主要还是要设计出合理的错误码,异常可以作为传递错误码的载体。切不可采用复杂的受检异常类型体系来映射到每个业务错误码,这只会让代码过于臃肿。