译:当心泛型异常带来的风险

原文链接 Beware the dangers of generic Exceptions

捕获和抛出泛型异常(Generic Exceptions)会让你不知不觉地陷入困境。

在最近从事的一个项目中,我发现了一段清理资源的代码。因为这段代码有各种各样的调用,它可以抛出6种不同的异常。为了简化代码(或者是不愿意敲更多代码),当初设计程序的程序员声明这个方法抛出Exception异常,而不是六种潜在的不同异常。这就迫使调用代码必须被封装在一个捕获Exception异常的try/catch代码块中。程序的设计者认为这段代码的目的是清理资源,失败的情况并不重要,所以catch块为空直到系统被关闭a

a:这里代码的编写者在捕获了异常之后,直接忽略了处理异常。

显然,这些都不是最佳的编程实践,但似乎也算不上严重的错误...除了代码第三行中一个小小的逻辑错误:

代码清单1. 最初的清理代码

private void cleanupConnections() throws ExceptionOne, ExceptionTwo {
    for (int i = 0; i < connections.length; i++) {
        connections[i].release(); // Throws ExceptionOne, ExceptionTwo
        connections[i] = null;
    }
    connections = null;
}

protected abstract void cleanupFiles() throws ExceptionThree, ExceptionFour;
protected abstract void removeListeners() throws ExceptionFive, ExceptionSix;

public void cleanupEverything() throws Exception {
    cleanupConnections();
    cleanupFiles();
    removeListeners();
}

public void done() {
    try {
        doStuff();
        cleanupEverything();
        doMoreStuff();
    } catch (Exception e) {}
}

在代码的另一部分,connections数组在第一个connection被创建了之后才被初始化。但是,如果没有任何connection被创建,那么connections数组为空,即null。所以在一些情况下,调用connections[i].release()将会导致NullPointerException。这是一个处理起来相对简单的问题,只要为connections数组添加加一个检查就行:connections != null

但是这个异常从未被报告过。该异常被cleanupConnections()抛出,接着被cleanupEverything()再次抛出,最后在done()方法中被捕获。而done()方法并未对异常作出任何处理,甚至没有日志记录。由于cleanupEverything()只在done()方法中被调用,因此这个异常从未被发现。所以代码也从未得到修复。

因此,在cleanupConnections()失败的情况下,cleanupFiles()方法和removeListeners()方法均不会被调用(所以他们占用的资源也从未被释放),并且doMoreStuff()也从未被调用,这样在done()方法中的最后处理永远都没有完成。让情况更糟的是,在系统关闭的时候,done()并没有被调用;相反,done()方法被调用来完成每一次事务。所以在每一次事务中都会产生资源泄漏。

显然这是一个很重要的问题:错误未被报告并且产生了资源泄漏。但是代码本身看起来似乎很无辜,并且从代码的编写方式来看,这个问题很难被追踪。但是,通过应用以下几个简单的原则,这个问题就可以被发现并且被修复:

  • 不要忽略异常
  • 不要捕获泛型异常(Generic Exceptions)
  • 不要抛出泛型异常(Generic Exceptions)

1. 不要忽略异常

代码清单1中最明显的问题就是程序中的错误被完全忽略了。一个非预期的异常(异常,从他们的属性上来说,就是非预期的)被抛出了,然而程序并未做好处理这个异常的准备。这个异常甚至从未被报告过,因为程序假设那些预期的异常并不重要。

在大多数情况下,一个异常应该至少被日志记录。好几个日志包都可以在不过多影响系统性能的情况下记录系统错误和异常。大多数日志系统也允许打印栈记录,这样可以为查找异常发生的位置和原因提供有效的信息。最后,因为日志通常是写入文件,异常的记录就可以方便地被查看和分析。

在一些特殊的情况下,用日志记录异常并不是非常重要的。在finally块中清理资源就是其中的一种情况。

1.1 finally中的异常

在代码清单2中,部分数据是从一个文件中读取的。这个文件必须被关闭无论在读取数据的过程中是否发生异常,因此close()方法就被放在了finally句子中。但是如果在关闭文件的时候发生错误,那我们也就无能为力了:
代码清单2:

public void loadFile(String fileName) throws IOException {
    InputStream in = null;
    try {
        in = new FileInputStream(fileName);
        readSomeData(in);
    } finally {
        if (in != null) {
            try { 
                in.close();
            } catch (IOException ioe) {
                // Ignored
            }
        }
    }
}

请注意如果因为I/O问题导致数据加载失败,那么对loadFile()的调用仍然会抛出一个IOException异常。同时请注意,即使异常在close()方法中被忽略,在程序注释中的声明也让这段代码对所有使用它的人来说是清晰易懂的。你也可以将同样的处理流程应用到所有的I/O流清理,关闭socketJDBC接口等等。

忽略异常中关键的一点是保证只有一个方法被放置在了忽略异常的try/catch模块中(所以try/catch模块之外的其他方法依然可以被调用)并且一个指定的异常会被捕获。这是一种明显区别与其他其他捕获泛型异常的特殊情况。在其他情况下,异常应该(至少)被日志记录,用栈追踪记录则更好。

2. 不要捕获泛型异常

在一个复杂的软件中,经常有特定的代码块执行多个可能抛出一系列异常b的方法。动态加载类和初始化一个对象也可能抛出多个不同的异常,其中包括ClassNotFoundExceptionInstantiationExceptionIllegalAccessExceptionClassCastException

译者注:一个方法抛出多个异常

一个忙兮兮的程序员可能简单地把方法调用放在一个捕获泛型异常的try/catch代码块中,而不是为try模块添加四个不同的catch模块(请参考代码清单3)。虽然这种写法看起来没什么坏处,却会带来一些无意识的负面效果。例如,如果className()nullClass.forName()就会抛出NullPointerException,而NullPointerException会被该方法捕获。

这样,catch块就捕获了本来没打算捕获的异常,因为NullPointerExceptionRuntimeException的子类,而RuntimeException又是Exception的子类。所以catch (Exception e)捕获了所有RuntimeException的子类,其中包括NullPointerExceptionIndexOutOfBoundsExceptionArrayStoreException。通常,一个程序员都不打算去捕获这些异常。

代码清单3中,classNamenull将会导致抛出NullPointerException,该异常提示调用方法这是一个无效的类名:

public SomeInterface buildInstance(String className) {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        impl = (SomeInterface) clazz.newInstance();
    } catch (Exception e) {
        log.error("Error creating class: " + className);
    } 
    return impl;
}

捕获泛型异常的另一个后果是限制了日志记录,因为catch代码块不知道自己捕获了哪个一特定的异常。一些程序员在面对这个问题的时候,采用添加检查来确定异常的类(参考代码清单4),这一做法是与使用catch块的目的相矛盾的:
代码清单4:

catch (Exception e) {
    if (e instanceOf ClassNotFoundException) {
        log.error("Invalid class name: " + className + ", " + e.toString());
    } else {
        log.error("Cannot create class: " + className + ", " + e.toString());
    }
}

代码清单5提供了一个完整的捕获特定异常的例子,一些程序员可能会感兴趣。这样,instanceOf就不是必要的了,因为对应的异常会被捕获。每一个受检异常(ClassNotFoundExceptionInstantiationExceptionIllegalAccessException)都会被捕获并且处理。通过检查相对应的异常,产生ClassCastException的特殊情况也会被查证:

public SomeInterface buildInstance(String className) {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        impl = (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
       log.error("Invalid class name: " + className + ", " + e.toString());
    } catch (InstantiationException e) {
       log.error("Cannot create class: " + className + ", " + e.toString());
    } catch (IllegalAccessException e) {
       log.error("Cannot create class: " + className + ", " + e.toString());
    } catch (ClassCastException e) {
       log.error("Invalid class type, " + className
       + " does not implement " + SomeInterface.class.getName());
    }
    return impl;
}

在某些情况下,更好的办法是重新抛出一个已知的异常(或者创建一个新的异常),而不是直接在方法中处理异常。这就允许调用方法通过把异常放置在已知的上下文中来处理错误情况。

代码清单6提供了另外一个版本的buildInterface()方法,如果在加载和初始化类的时候发生错误,那么该方法将会抛出ClassNotFoundException。在这个例子中,程序会确保调用方法得到一个合理初始化的对象或者是一个异常。因此,调用方法并不需要去检查返回值是否为null

注意这个例子使用了Java 1.4中的通过封装另外一个异常来创建一个新的异常的方法,这个方法可以保留原有的栈追踪信息。不然,栈追踪将会指明buildInterface()是产生异常的方法,而不是更深层的真正抛出异常的newInstance()方法。

代码清单6:

public SomeInterface buildInstance(String className) throws ClassNotFoundException {
    try {
        Class clazz = Class.forName(className);
        return (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
        log.error("Invalid class name: " + className + ", " + e.toString());
        throw e;
    } catch (InstantiationException e) {
        throw new ClassNotFoundException("Cannot create class: " + className, e);
    } catch (IllegalAccessException e) {
        throw new ClassNotFoundException("Cannot create class: " + className, e);
    } catch (ClassCastException e) {
        throw new ClassNotFoundException(className
        + " does not implement " + SomeInterface.class.getName(), e);
    }
}

在一些情况下,代码也许能够从特定的错误状态中恢复正常。在这些情况中,捕获一些特定的异常是非常重要的,因为代码可以查证在一个特定错误状态是否可以被恢复。请带着上述观点来查看代码清单6中的类初始化例子。

代码清单7中,当参数className无效时,代码返回一个默认的对象,但是在非法操作的情况下会抛出一个异常,例如一个无效的类型转换或者是访问权限不够。

注意: 这里提到的IllegalClassException是一个用来演示的局域异常类。
代码清单7

public SomeInterface buildInstance(String className) throws IllegalClassException {
    SomeInterface impl = null;
    try {
        Class clazz = Class.forName(className);
        return (SomeInterface)clazz.newInstance();
    } catch (ClassNotFoundException e) {
        log.warn("Invalid class name: " + className + ", using default");
    } catch (InstantiationException e) {
        log.warn("Invalid class name: " + className + ", using default");
    } catch (IllegalAccessException e) {
        throw new IllegalClassException("Cannot create class: " + className, e);
    } catch (ClassCastException e) {
        throw new IllegalClassException(className + " does not implement " + SomeInterface.class.getName(), e);
    }
    if (impl == null) {
        impl = new DefaultImplemantation();
    }
    return impl;
}

2.1 什么时候应该捕获泛型异常

某些情况下,捕获泛型异常是便捷而且合理的。这些情况非常特殊,但是对大型并且容错的系统来说是至关重要的。在代码清单8中,请求从queue中被读出并按顺序被处理。但是,如果在请求处理的过程中产生任何异常(不管是 BadRequestException还是RuntimeException的任何子类, 包括NullPointerException),那么该异常会在处理请求的while循环之外被捕获。因此任何错误豆浆导致请求处理循环中,并且剩下的请求将不会被处理。这展示了在处理请求的过程中一个极不合理的错误处理机制:

public void processAllRequests() {
    Request req = null;
    try {
        while (true) {
            req = getNextRequest();
            if (req != null) {
                processRequest(req); // throws BadRequestException
            } else {
                // Request queue is empty, must be done
                break;
            }
        }
    } catch (BadRequestException e) {
        log.error("Invalid request: " + req, e);
    }
}

解决请求处理的一个更好方法是在代码的逻辑中做两个重要修改,请查看下面的代码清单9第一,将try/catch模块移到请求处理的循环内。这样,任何错误都会被捕获并在请求处理循环内得到解决。这样,即使当一个请求处理失败,循环依然会继续处理其他请求。第二,修改try/catch模块用以捕获泛型异常,所以任何异常都会在循环内被捕获并且剩余请求会被继续处理:
代码清单9:

public void processAllRequests() {
    while (true) {
        Request req = null;
        try {
            req = getNextRequest();
            if (req != null) {
                processRequest(req); // Throws BadRequestException
            } else {
                // Request queue is empty, must be done
                break;
            }
        } catch (Exception e) {
            log.error("Error processing request: " + req, e);
        }
    }
}

捕获一个泛型异常听起来违背了第二部分刚开始的观点--的确是这样的。但是我们这里讨论的是一个特殊的情境。在这种情况下,我们捕获泛型异常是为了防止系统因为一个异常而停止运行。在用循环(loop)处理请求、事务和事件的情况下,即使在处理过程中有异常被抛出,该循环需要持续运行。

代码清单9中,while循环中的try/catch可以被看作是最高级别的异常管理器,因此这个顶级的异常管理器需要去捕获并且用日志记录所有在这个代码级别引发的异常。这样,异常就不会被忽略或者丢失,同时这些异常也不会影响剩余的需要被处理的请求。

每一个大型的、复杂的系统都有一个顶级的异常管理器(也许每一个子系统都有一个,这取决于系统如何实现处理)。该顶级异常管理器的目的并不是修复导致异常产生的底层问题,但它必须能够在不停止处理的情况下捕获并记录这些问题。这也并不意味和所有异常都要在这一层被抛出。任何可以在更低层被处理的异常都应该在更低层被处理:即当问题发生时,异常应该在逻辑可以帮助理解更多状况的地方被处理。但是如果一个异常无法在一个更低层被处理,那么抛出到更高的级别。这样无法修复的错误就被被集中到同一个地方(最高级别的异常管理器中)去处理,而不是分散在整个系统中。

3. 不要抛出泛型异常

代码清单1的最终问题源于程序员决定在cleanupEverything()方法中抛出一个泛型异常。当一个方法抛出六种不同的异常时,代码可能会变得杂乱:方法声明变得难以阅读、迫使调用方法捕获六种不同的异常,请看代码清单10

public void cleanupEverything() throws 
        ExceptionOne, ExceptionTwo, ExceptionThree,
        ExceptionFour, ExceptionFive, ExceptionSix {
    cleanupConnections();
    cleanupFiles();
    removeListeners();
}
public void done() {
    try {
        doStuff();
        cleanupEverything();
        doMoreStuff();
    } catch (ExceptionOne e1) {
        // Log e1
    } catch (ExceptionTwo e2) {
        // Log e2
    } catch (ExceptionThree e3) {
        // Log e3
    } catch (ExceptionFour e4) {
        // Log e4
    } catch (ExceptionFive e5) {
        // Log e5
    } catch (ExceptionSix e6) {
        // Log e6
    }
}

但即使是代码有点杂乱,至少是清晰的。使用特定的异常避免了一些真实存在的问题:首先,抛出一个泛型异常隐藏了潜在问题的细节,因此也就丢失了处理这个现实问题的机会。其次,抛出一个泛型异常会迫使所有调用该方法的代码捕获泛型异常(如我们之前讨论的,这个用法有问题),或者是通过重新抛出泛型异常来方法这个问题。

通常,当一个方法声明它抛出一个泛型异常时有如下两个原因:其一,该方法调用了几个可以抛出多个不同异常的方法(比如调停者模式和门面模式)并且隐藏了异常状态的细节。因此该方法简单地声明抛出Exception泛型异常并忽略,而不是创建并抛出一个域级异常(用来封装更低层的异常)。其二,程序并未想清楚应该用什么异常来表述当前的状况,因此方法初始化并抛出一个泛型异常(throw new Exception())。

只要稍微思考和设计,这两方面的问题都可以解决:究竟应该抛出哪个详细的域级异常?设计应当包含简单的声明,该声明指出方法应该抛出那些可能出现的异常。另外一个选择是创建一个域级异常来封装和声明被抛出的异常。大多数情况下,一个方法抛出的异常(或者一系列不同的异常)应该越详细越好。详细的异常提供了更多有关错误状态的信息,因此可以让错误状况得到处理或至少被详细地记录。

泛型异常Exception类是一个非受检异常,这意味着调用任意抛出泛型异常的方法都必须声明抛出泛型异常,或者将方法调用封装在一个捕获泛型异常的try/catch模块中。我已经在前面的方法中解释过这个问题。

4. 小心使用泛型异常

这篇文章探索了如何使用泛型异常的几个方面:永远不要抛出泛型异常,并且永远不要忽略泛型异常;尽量少或者不要捕获泛型异常(除非在不得已的特殊情况下)。泛型异常并没有提供允许你有效处理他们的详细信息,并且你最终可能捕获那些本不打算捕获的异常。

异常是Java语言中一个强有力的组件,如果使用得当的话,它能使你称为一个高效的程序员并且缩短你的开发周期,特别是在测试和调试的时候。当异常被错误使用时,它们可以通过隐藏你系统中的问题来阻碍你。所以,请注意在哪以及如何使用泛型异常。



作者:厨房里的工程师
链接:https://www.jianshu.com/p/3f7df4c400a8

猜你喜欢

转载自blog.csdn.net/w372426096/article/details/80792446