联系我

Java中优雅处理异常

2020.12.19

一句话异常

一句话简单概括异常:程序运行时,发生的不被期望的事件,阻止了程序按预期正常执行,这就是异常。

Java的异常层级结构

image.png

Throwable类

Throwable类是Java异常类型的顶级父类,一个对象只有是Throwable类的实例才能被异常处理机制识别。它有两个重要的方法,分别是getCause方法和getStackTrace方法,通常情况下我们不用打印Cause信息来定位问题,但某些特殊情况下有用到。我们用的较多的是getStackTrace方法,因为我们需要打印程序的调用栈信息来快速定位问题。

Error类

Error表示由运行应用程序的环境引起的错误,对于这类错误导致的应用程序中断,仅靠程序自身无法恢复,例如当JVM内存不足时发生OutOfMemoryError,或者当栈溢出时发生的StackOverflowError。

Exception类

Exception类表示主要由应用程序本身引起的异常,遇到这类异常的话,我们应该尽可能处理,使程序恢复运行,而不应该随意终止异常。比如当应用程序尝试访问null对象时发生NullPointerException,或者当应用程序尝试转换不兼容的类型时发生ClassCastException。

受检异常与非受检异常

非受检异常发生在运行时,所以不能够被编译器感知,Error和RuntimeException以及他们的子类就属于非受检异常。这类异常发生的原因多半是代码写的有问题,对于这些异常,应该修正代码,而不是通过异常处理器处理。

除了RuntimeException以外的Exception类都属于受检异常,而受检异常必须在编译时被捕获处理,否则编译不会通过。

自定义异常

一般情况下,通过扩展Exception类实现,这样自定义的异常都属于受检异常。如果要自定义非受检异常,则扩展 RuntimeException类。按照惯例,自定义异常应该总是包含如下的构造函数:

  • 一个无参构造函数;
  • 一个带有String参数的构造函数,并传递给父类的构造函数;
  • 一个带有String参数和Throwable参数,并都传递给父类构造函数;
  • 一个带有Throwable 参数的构造函数,并传递给父类的构造函数。

一个典型的自定义异常是下面这样的:

public class MyBusinessException extends Exception {
  
    private static final long serialVersionUID = 7718828512143293558L;
    
    private final ErrorCode code;

    public MyBusinessException(ErrorCode code) {
        super();
        this.code = code;
    }

    public MyBusinessException(String message, Throwable cause, ErrorCode code) {
        super(message, cause);
        this.code = code;
    }

    public MyBusinessException(String message, ErrorCode code) {
        super(message);
        this.code = code;
    }

    public MyBusinessException(Throwable cause, ErrorCode code) {
        super(cause);
        this.code = code;
    }
    
    public ErrorCode getCode() {
        return this.code;
    }
}

错误实践

  • 禁止在构造函数中抛出异常;
  • 禁止使用异常来管理业务逻辑;
  • 禁止使用返回值来表示异常,如C程序经常出现的-1等。
  • 大部分情况下不建议在循环中进行异常处理,应该在循环外对异常进行捕获处理;

最佳实践

  • 异常的名字必须清晰且有具体的语义;
  • 应该捕获具体的异常,而不是统一 catch(Exception e) ;
  • 定义适合业务场景的异常类层次,也不要过度设计;
  • 使用适当的异常捕获粒度,一个基本操作定义一个 try-catch 块,禁止为了简便,将几百行代码放到一个 try-catch 块中。
  • 为自定义异常生成足够的文档说明,至少是 JavaDoc。
  • 禁止直接在 catch 中捕获Throwable类。
  • 不推荐在 catch 块中二次抛出异常,需要根据业务场景包装异常后再抛出。
  • Catch语句需要尽量保证简单,不能包含复杂的业务逻辑。
  • 不要忽略 Exception,即使可能在分析一个不可能发生的异常(代码是变化的,当前不可能不代表未来也不可能)。

针对上面每一点做个简单的解释:

  1. 我们在代码当中经常需要制定一些特定的业务异常,举个简单的例子,比如我们在开发用户注册功能时候,会校验用户填入的一些表单项,针对每个表单项都有可能出现填写不合要求的场景,此时我们就可以定义一个SignUpArgCheckException类,然后在校验不通过的地方将异常抛出即可。而如果我们仅仅跑出一个RuntimeException,不做任何封装定义的话,我们的代码就很大程度失去了业务语义,对于后期维护的人员来说也不便于理解阅读。
  2. 在我们定义了自定义异常以后,我们可以在Service层进行很多校验,然后抛出对应异常,而这些异常我们在服务调用的时候都是需要捕获处理的。试想一下,如果我们不管规范直接catch大的Exception类,然后返回相应的错误message,用户看到的错误信息就可能会包含一些他看不懂的代码异常信息,而这些异常信息是不应该直接展示给用户看的,严重的话还会成为安全攻击的把柄,所以这条规范非常重要,平时开发过程当中要注意。
  3. 对于一个业务服务逻辑可能会涉及到很多校验工作,那我们要为每种特定的校验错误建立一个异常类吗,答案当然是否定的,这样过多设计异常类会导致类膨胀,也是没有必要的,因为我们可以适当的把异常类进行归类,定义适合业务场景的异常类层次。
  4. 对于第四点,我们在很多“祖传”代码当中可能会经常看见,以前的代码结构分层不像现在这么细,非常粗犷,可能只有Controller和Dao层,接口处理逻辑直接在一个大的try-catch块里面包着。这样的代码唯一的好处就是代码逻辑“行云流水”,方便维护者理解,但是它非常不便于维护,如果后续需要添加或更改业务逻辑,那么不得不对原有方法进行调整,而这么做的风险是很大的,极容易出现老系统不可用的情况。
  5. 这一点很好理解,如果后续其他开发人员需要阅读或使用你定义的异常类,那么他至少可以判断这个异常类是否是他需要的,他还需不需要重新定义异常类。
  6. Throwable类的错误信息非常贴近代码底层细节,同时对于一些error也会捕获到,这样容易把错误吞掉,不便于问题排查追踪。
  7. 这个大多数情况下是极不推荐的,除非真的是场景特殊。
  8. 通常我们在catch语句当中只做两件事,第一,打日志,第二,将错误结果包装处理后给到上层应用代码。
  9. 这个也很好理解,有时候我们catch住自定义异常后,认为当前代码其他地方也不可能抛出什么异常,因此就只catch自定义异常,最外面的一个大Exception类不去捕获,当然这也是不对的,还是上面说到的,代码是变化的,当前不可能不代表未来也不可能。

开放性问题

相信大家对异常都形成了自己的一些认识和理解,这里抛出一个开放性问题,也是我们组上周在code review过程中讨论的。有些开发在业务校验不通过之后喜欢按照方法返回值类型封装一个相应的返回结果,而有的开发在业务校验不通过之后喜欢直接throw一个自定义业务异常。你认为哪种更好呢,思考一下,欢迎留言写下你的见解。