美文网首页
Java异常处理的最佳实践

Java异常处理的最佳实践

作者: 消失er | 来源:发表于2023-05-25 20:46 被阅读0次

    异常处理的原则

    1.抛出异常,要针对具体问题来抛出异常,抛出的异常要足够具体详细;

    • 抛出的异常,应能通过异常类名和message准确说明异常的类型和产生的原因。

    2.捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之;

    • 如果不想处理它,应将该异常抛给它的调用者;永远不要在没有充分理由的情况下吞掉异常。即要么处理,要么向上抛,决不能吃掉:You either handle it, or throw it. You don’t eat it.
    • 最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。

    3.尽早抛出,延迟捕获

    • 尽早抛出的基本目的是为了防止问题扩散,这样就给排查问题增加了难度。
    • 延迟捕获说的是对异常的捕获和处理需要根据当前代码的能力来做,如果当前方法内无法对异常做处理就抛给调用者;如果调用者也无法处理理论上它也应该继续上抛,这样异常最终会在一个适当的位置被catch下来,而比起异常出现的位置,异常的捕获和处理是延迟了很多。但是也避免了不恰当的处理。

    最佳实践

    1.Don’t log and rethrow Java exceptions
    日志记录和异常处理就像一个豆荚里的两颗豌豆。当你的 Java 代码中出现问题时,这通常意味着你有一个需要处理的异常,当然,任何时候发生错误或意外事件时,都应该适当地记录该事件。
    在Java 中执行异常处理时,开发人员实际上有两种选择:

    • 在抛出异常时处理异常,并在错误发生时从错误中恢复。
    • 重新抛出异常,以便应用程序的另一部分可以处理该问题。

    在分层架构的应用中,第二个选项特别常见,因为执行堆栈的顶部通常只有一个层,专门用于处理异常和从异常中恢复的任务。
    但是,开发者最常犯的错误之一是在重新抛出异常之前记录异常。这种做法必须被视为最高级别的bad case。

    比如下面的代码:

    /* log and rethrow exception example */
    try {
      Class.forName("com.mcnz.Example");
    } catch (ClassNotFoundException ex) {
      log.warning("Class was not found.");
      throw ex;
    }
    

    异常最终会在多层中多次记录。当通过日志文件进行跟踪故障时,排查过程令人窒息,排除人员不知道从哪里开始以及何时结束。
    这样做会导致代码重复,并在日志文件中散布重复的记录,这使得对代码进行故障排除变得更加困难。
    正确的做法是:仅在真正处理异常时记录异常log;真正处理异常意味着不再将异常上抛,或再往上已没有调用者。

    2.在流量出口处设置全局异常处理器
    上一条提到:仅在真正处理异常时记录异常log;在业务系统中,通常能真正处理异常的地方,就是在流量出口,即最外层的使用层。
    因此,捕获异常的处理逻辑,应该尽量放在流量出口的末尾。 这会在业务工程中放置更少的 catch 块,并使工程代码更易于阅读和维护。

    使用全局异常处理器,因为总会有未捕获的异常潜入到代码中。始终包含一个全局异常处理程序来处理任何未捕获的异常,这不仅可以让你记录并处理可能发生的异常,还可以防止你的应用程序在运行时崩溃。
    最外层的业务使用者,必须处理异常,并将其转化为用户可以理解的内容。

    3.检查suppressed exception,防止异常被覆盖
    Suppressed Exception是一种相对较新的语言特性,并非所有开发人员都知道。
    上篇 别被坑在finally代码块上 提到:如果程序执行try块出现异常,且进入执行finally 块也抛出异常,则最后抛出给调用方的异常是finally中的,try/catch中的不会再抛出,因为被覆盖掉了,在Java中,对上层调用方只能抛出一个异常。没有抛出的异常被称为“被屏蔽”的异常(suppressed exception)。

    suppressed exception其实并不好翻译。有些平台译为“被压制的异常”。“压制”这个词的含义是“使某物变小”,延伸到在 Java 中,被压制的异常指的是在 try-with-resources 语句块中,因为 try 块和 finally 块都抛出了异常,导致 finally 块中的异常被“压制”了,没有被正确捕获和处理。因此,我们可以称这些未被正确处理的异常为被压制的异常。

    Throwable#suppressedExceptions
    • 被屏蔽的异常,可通过Throwable.getSuppressed()获取;
    • 可以通过addSuppressed(Throwable exception)添加,这个函数一般是在try-with-resources语句中由自动调用的;因为try-with-resources结构会自动回收资源,通常不需要显示添加finally块;因此try-with-resources自动回收资源时出现异常,会自动调用addSuppressed。

    想要同时保留 finally 块和 try 块中的异常信息,上篇给出了一种方法:用变量保存try块的原始异常,在finally也出现异常时进行取舍。
    下面给出采用suppressed exception的一种新方式。

    try {
        //可能抛出 IOException 异常
        BufferedReader br = new BufferedReader(new FileReader(path));
        String line = br.readLine();
        throw new IOException("mock IOException");
    } catch (IOException e) {
        //try块里抛出的异常是e
        System.out.println("try块里抛出的异常是: " + e.getMessage());
    
        //finally块里抛出的异常是e.getSuppressed()
        Throwable[] suppressed = e.getSuppressed();
        String suppressedException = Arrays.stream(suppressed).map(Throwable::getMessage).collect(Collectors.joining(","));
        System.out.println("finally块里抛出的异常是: " + suppressedException);
    } finally {
        //此示例是为了让finally块产生异常,从而出现“被屏蔽”的异常
        throw new RuntimeException("mock throwExceptionInFinally");
    }
    //执行结果
    //try块里抛出的异常是: mock IOException
    //finally块里抛出的异常是:
    //Exception in thread "main" java.lang.RuntimeException: mock throwExceptionInFinally
    //  at FinallyTest.main(……
    

    需要注意的是,由于在Java中对上层调用方只能抛出一个异常;因此两种方式,都是只能同时保留 finally 块和 try 块中的异常信息,最终还需取舍,一个记录到日志文件,一个向上抛出。

    4.通过前置防御规避非受检异常,而非通过catch去捕获处理
    重学Java异常体系提到受检异常 vs 非受检异常的差异;

    • 非受检异常的发生通常是由于程序 bug 所致,应该尽量通过预先检查进行规避;比如在面对可能抛NullPointerException的地方时主动判断是不是null 并处理、循环处理时要检查下标边界防止出现IndexOutOfBounds。
    • 这种异常源于开发者的疏忽,很多开发人员将其跟真正需要处理的异常混为一谈,然后统一的在catch里忽略掉这种异常,是对自己的“放纵”。

    对于非受检异常,我们应该修正代码,而不是去通过异常处理器去处理。
    具体来说,就是RuntimeException及其子类(JDK内置的大多数RuntimeException子类), 尽量通过预先检查进行规避,而不应该通过 catch 来处理。

    5.保留异常链

    • 丢失异常的另一种场景是丢失异常链
    • 永远要记得:包装异常但不要丢弃原始异常

    异常链是一种面向对象编程技术,指将捕获的异常包装进一个新的异常中并重新抛出的异常处理方式。原异常被保存为新异常的一个属性(比如cause)。这个想法是指一个方法应该抛出定义在相同的抽象层次上的异常,但不会丢弃更低层次的信息。
    通过把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,也能通过这个异常链追踪到异常最初发生的位置。

    6.对异常进行文档说明

    • 为你的异常生成足够的文档说明,至少要有 Javadoc

    当在方法上声明抛出异常时,也需要进行文档说明。目的是为了给调用者提供尽可能多的信息,从而可以更好地避免或处理异常。
    在 Javadoc 添加 @throws 声明,并且描述抛出异常的场景(when and why抛出异常)。

    /**
     * 方法描述
     *
     * @throws MyBusinessException - when and why抛出异常
     */
    public void doSomething(String input) throws MyBusinessException {
        // ...
    }
    

    7.如无必要,勿增自定义异常

    • 优先使用Java内置异常
    • 自定义的异常数量需要控制

    在Java中,我们可以使用内置的异常类,也可以自定义异常类。

    • 对于重要的通用异常类型——例如空指针异常、数组下标越界异常、类型转换异常等等,Java语言内置了相应的异常类型。当我们需要捕捉这些异常时,可以直接使用内置的异常类型。这样可以简化代码,提高可读性。此外,内置异常类还具有特定的语义,可以帮助开发者更加准确地进行异常处理。
    • 当我们需要处理某些特定的异常类型时,为了便于开发者的理解和使用,我们可能需要创建自定义异常类。自定义异常类的命名应具有一定的描述性,可以在异常抛出时帮助开发者快速理解异常的含义和产生原因。

    对于业务系统而言,由于自定义异常通常设计成非受检异常(Unchecked Exception,即RuntimeException及其子类)以免强制捕获处理异常,当系统抛出太多RuntimeException子类,到了流量出口的全局异常处理器时,可能根本不知道应该捕获什么!

    • 如果只捕获你知道抛出的异常,那么你怎么知道抛出了哪些异常呢?当其他开发者抛出一个新的RuntimeException子类型并忘记在全局异常处理器捕获它时,可能会发生危险情况。
    • 如果直接捕获RuntimeException类型,那定义不同的异常类型还有什么意义,因为异常处理器对它们一视同仁。因此,自定义的异常数量不宜过度、需要控制。
      对于非业务系统,即Java中间件、组件、库等,需要单独考虑。

    8.不要在finally块抛出异常
    在 finally 中抛出异常同样会导致程序出现预期之外的行为。如果 finally 块中的代码抛出了异常,而且未在finally块捕捉处理,那么该异常将被抛出到上一级调用者,并且 try 或 catch 块中抛出的异常将会被覆盖(丢失)。因此,在 finally 代码块中抛出异常可能会对程序的逻辑造成混乱,不利于代码的维护和调试。
    详见 finally最佳实践

    9.不要使用异常控制程序的流程
    在程序设计中,异常机制的作用是用来处理错误或者异常情况,而不是用来控制程序的流程。在程序的正常执行流程中,异常应该是意外情况才会出现的,而不是作为正常流程中可预知的分支。
    使用异常控制程序的流程,可能会导致以下问题:

    • 代码可读性差:使用异常来控制程序流程,可能会使代码结构和逻辑变得复杂,难以阅读和理解。
    • 难以调试:异常的跳转会扰乱程序的执行流程并难以判断,给代码调试和维护增加难度。
    • 性能问题:抛出和捕获异常会消耗比较大的时间和资源。如果大量使用异常来控制程序的流程,可能会导致性能问题,降低程序的运行效率。

    因此,不用使用异常来管理业务逻辑,应该使用条件语句。如果一个控制逻辑可通过 if-else 语句来简单完成的,那就不用使用异常。
    主要影响性能的地方是往异常填充堆栈信息,在确定不需要堆栈信息的异常,可以重写fillInStackTrace方法,重写该方法不填充堆栈可以提升性能。

    之所以将这条最佳实践放在最后,是因为要完美实施它其实很难。一是正常的业务主流程(normal),跟“异常情况”(abnormal)有时候很难界定。比如用户注册,用户想注册的id一般来说都是被别人先注册了的。那么catch UserexistException的机会将会多于try中的主流程。二是在try-catch结构中,catch块对异常的处理,很容易包含特定异常情况下的处理逻辑。

    10.优先捕获最具体的异常Catch the most specific exception first
    在Java中,出现异常从异常表查找异常处理程序时,会根据catch声明的顺序来依次匹配,只有匹配异常的第一个 catch 块会被执行。因此,如果首先捕获 IllegalArgumentException ,则永远不会到达应该处理更具体的 NumberFormatException 的 catch 块,因为它是 IllegalArgumentException 的子类。
    总是优先捕获最具体的异常类,子类必须放在父类的前面,并将不太具体的 catch 块添加到列表的末尾。

    大多数 IDE 都可以辅助实现这个最佳实践。当你尝试首先捕获较不具体的异常时,IDE会报告无法访问的代码块。

    相关文章

      网友评论

          本文标题:Java异常处理的最佳实践

          本文链接:https://www.haomeiwen.com/subject/jkihedtx.html