Java 异常设计最佳实践

关于异常

在讲Java异常实践之前,先理解一下什么是异常。到底什么才算是异常呢?其实异常可以看做在我们编程过程中遇到的一些意外情况,当出现这些意外情况时我们无法继续进程正常的逻辑处理,此时我们就可以抛出一个异常。

广义的讲,抛出异常分三种不同的情况:

  • 编程错误导致的异常 :在这个类别里,异常的出现是由于代码的错误(譬如NullPointerException、IllegalArgumentException、IndexOutOfBoundsException )。代码通常对编程错误没有什么对策,所以它一般是非检查异常。

  • 客户端的错误导致的异常 :客户端代码试图违背制定的规则,调用API不支持的资源。如果在异常中显示有效信息的话,客户端可以采取其他的补救方法。例如:解析一个格式不正确的XML文档时会抛出异常,异常中含有有效的信息。客户端可以利用这个有效信息来采取恢复的步骤。

  • 资源错误导致的异常 :当获取资源错误时引发的异常。例如,系统内存不足,或者网络连接失败。客户端对于资源错误的反应是视情况而定的。客户端可能一段时间之后重试或者仅仅记录失败然后将程序挂起。

Java 异常基本概念

Java为异常设计了一套异常处理机制,当程序运行过程中发生一些异常情况时,程序不会返回任何值,而是抛出封装了错误信息的异常对象,Java 语言提供了专门的异常处理机制去处理这些异常。

那么为什么编程语言要设计异常呢?首先,引入异常之后,我们就可以把错误代码从正常代码中分离出来进行单独处理,这样使代码变得更加整洁;其次,当出现一些特殊情况时,我们还可以抛出一个检查异常,告知调用者让其处理。

Java 异常体系结构

Java 异常设计最佳实践
从上面异常继承树可以看出,所以异常都继承自Throwable,这也意味着所有异常都是可以抛出的。

具体来说,广义的异常可以分为ErrorException两大类。

Error表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,Java虚拟机运行错误Virtual MachineError,当 JVM 不再有继续执行操作所需的内存资源时,将出现OutOfMemoryError,虚拟机错误还有*Error 、InternalError、 UnknownError等。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。经常见到的Error还有LinkageError(结合错误),具体有 NoSuchMethodError 、IllegalAccessError 、NoClassDefFoundError

所以,对于Error我们编程中基本是用不到的,也就是说我们在编程中可以忽略Error错误。所以我们通常所说的异常只的是Exception,而Exception可分为检查异常和非检查异常。

检查异常与非检查异常

通常我们所说的异常指的都是Exception的子类,它们具体可以分为两大类在Java,Exception的子类和RuntimeException的子类,它们分别对应着检查异常和非检查异常。

Checked exception

检查异常,继承自Exception类。对于检查异常,Java强制我们必须进行处理。对于抛出检查异常的API我们有两种处理方式:

  • 对抛出检查异常的API进程try catch
  • 继续把检查异常往上抛

常见的检查异常有:

 SQLException
 IOException
 InterruptedException

ps:其实Error也是一个检查型的。

Unchecked exception

非检查异常,也称运行时异常RuntimeException ,继承自RuntimeException,所有非检查都有个特点,就是代码不需要处理它们的异常也能通过编译,所以它们称作unchecked exception。RuntimeException 本身也是继承自Exception

常见的非检查异常有:

NullPointerException
IllegalArgumentException
NumberFormatException
IndexOutOfBoundsException
IllegalStateException

检查异常 or 非检查异常?

其实对于检查异常存在的必要性一直都很有争议,Java是第一个使用检查异常的主流面向对象语言,而C++和C#都是没有检查异常的。所以我们在编程中全部使用非检查异常(RuntimeException的子类)也是可以的。

但是Java为什么又设计了检查异常呢,其实个人觉得检查异常的存在还是有必要的。检查异常可以使API的调用者明确知道API可能抛出的异常信息并让他们能够对这种异常情况做处理。或者是说,检查异常允许调用者从异常中恢复,而非检查异常一般是编程错误,调用者无法对其进行处理。 但是使用检查异常需要谨慎。因为检查异常会强制调用者对其进行try catch或者往上层抛,这样就给调用者造成了不必要的负担。

那么到底何时适合使用检查异常呢?有一个简单的原则是:

如果你希望调用者有意识地采取措施,那么抛出检查型异常;如果你自己也搞不清楚到底该不该让调用者采取措施,那么久干脆抛出非检查异常。

尽量少使用检查异常

上面提到,对于检查异常,强制要求开发者必须进行处理,也就是开发者要么对其进行try catch,要么往上层抛。如果检查异常很多,就意味着程序中需要添加很多的异常处理代码,导致晦涩的异常处理,并且检查异常容易破坏接口方法。为了解决检查异常带来的缺陷,我们可以利用异常转译的方法,将检查异常转化为非检查异常。

异常转译

异常转译就是将一种异常转换为另一种异常。异常转译针对所有继承 Throwable 超类的异常类而言的。对于我们开发者来说,如果遇到检查异常,而我们又不知道该对其做出如何处理,那么我们完全可以在catch块里将其封装成一个非检查异常然后抛出。例如下面这个例子:

public List getAllAccounts() throws SQLException{
  ...
}

getAllAccounts()抛出了两个checked exception。这个方法的调用者就必须处理这两个异常,尽管它也不知道在getAllAccounts()中什么文件找不到以及什么数据库语句失败,也不知道该提供什么文件系统或者数据库的事务层逻辑。这样,异常处理就在方法调用者和方法之间形成了一个不恰当的紧耦合。但是如果我们真正遇到这种情况,我们完全可以这么做:

try{
    getAllAccounts(SQLException  sqle)
}catch( ){
  throw new RuntimeException("获取失败!",sqle);//正确的做法
  throw new RuntimeException("获取失败!");//错误的做法
}

需要注意的是,我们在对异常进行转译的时候一定要在构造方法中传入原异常的throwable对象,这样可以保留原异常栈信息,而不是把原异常用另一个异常完全替换掉。

当然,我们也可以根据实际情况将一个非检查异常包装成一个检查异常。

异常的架构设计

从系统不同角度看异常

从系统最终用户的角度来看,系统对于用户来说就是一个黑盒,用户并不知道系统如何实现及运行,对用户而言,系统所出现的任何异常或错误,都属于系统运行时异常。所以在设计面向最终用户服务的API时,应该捕获API所有可能出现的异常,并把异常情况封装成与用户业务相近的提示信息,用户可以根据这些提示信息作出一些处理。

而对于系统开发者而言,更多的是从系统内部逻辑来看异常。有一部分异常需要内部截获处理即try catch,而另外一部分异常对于异常产生源而言无法进行有效处理,从而需要向外抛出异常以待合适的调用者进行处理。对于开发者而言,需要预见异常,并且需要考虑何时处理异常,何时抛出异常,必要时以某种方式记录或通知异常。总而言之,开发者需要通过对系统运行时可能出现的异常尽可能地处理以保证系统的正常运行,并对于无法处理的异常以一种合适的方式记录、通知、呈现以便找到发生异常的原因,从而解决或避免异常。

设计一个统一的异常处理类

一般当程序发生异常时,通常异常处理可能需要做一些通用处理,如异常日志记录、异常通知,重定向到一个统一的错误页面(如 Web 应用)等。如果这些通用异常处理放置于 catch 块中,将导致大量的重复代码,从而可能引起日志冗余、同一异常的实现多样化等问题。另外,大量异常处理程序放置于 catch 块中造成程序的高耦合性。为了解决此类问题,有必要分离出异常处理程序、统一异常处理风格、降低耦合性、增强异常处理模块的复用程度。通常的异常处理模式包括业务委托模式(Business Delegate)、前端控制器模式(Front Controller)、拦截过滤器模式(Intercepting Filter)、AOP 模式、模板方法模式等。

在web编程中,一般对控制层的异常都应该做统一处理,因为控制层向上面对用户,所以我们要在控制层捕获service所有可能出现的异常。上面也提到我们在控制层对每个service调用进行try catch显然会很繁琐而且也会导致大量重复代码,所以在遇到这种情况时我们一定要考虑引入统一异常处理机制,而很多框架也提供了这样的处理机制,如Spring的AOP,SpringMVC的 ExceptionHandler、RESTEasy的ExceptionMapper。

对于Spring MVC框架统一异常处理机制请参考:Spring MVC 中的异常处理 (handling exceptions)
对于Restful框架的统一异常处理机制请参考: RESTEasy中的通用异常处理ExceptionMapper

异常层次定义

异常层次结构应该以一种普遍通用的原则定义。为此,我们可以利用面向对象语言具备多态的性质,隐藏异常的实际实现。对于异常 service 而言,只需要捕获最基本的应用程序异常 AppException,异常处理过滤器会自动过滤实际异常类型并找到相应的异常处理器。另外,在方法的 throws 语句中勿需放入大量的检查异常;对方法调用者也不会出现混乱的 catch 块,最多可能只存在一个用于处理基本应用程序异常 AppException(委托给异常 service 处理)。

前面的章节过,应用系统异常可以从用户和开发者两个视角去考虑。因此,我们可以把异常划分为业务操作异常和系统内部运行时异常两种类型。抛出业务级异常或系统运行时异常的决策,需要与应用系统本身的架构层次相结合,考虑所要处理异常的层次。如图所示为一个典型的异常层次结构:
Java 异常设计最佳实践
其中,BussinessException 属于基本业务操作异常,所有业务操作异常都继承于该类。例如,通常 UI 层或 Web 层是由系统最终用户执行业务操作驱动,因此最好抛出业务类异常。ServiceException 一般属于中间服务层异常,该层操作引起的异常一般包装成基本 ServiceException。DAOException 属于数据访问相关的基本异常。

对于多层系统,每一层都有该层的基本异常。在层与层之间的信息传递与方法调用时候,一旦在某层发生异常,传递到上一层的时候,一般包装成该层异常,直至与用户最接近的 UI 层,从而转化成用户友好的错误信息。

上一篇:基于K8s、NAS、GitLab、Jenkins的持续集成交付容器环境最佳实践


下一篇:Kotlin 项目开发最佳实践: Kotlin + Spring Boot + kts(Kotlin Script) 全栈统一前后端