Java 中异常处理的 9 个最佳实践

发布于: 2020 年 07 月 09 日
Java中异常处理的9个最佳实践

在Java中进行处理异常并非是一件容易的事,初学者经常陷入困惑,甚至有经验的开发者也需要认真研讨哪些异常需要处理,哪些异常需要向上抛出。导致每个开发团队都会自己定制一套特有的异常处理规则,这使得新加入团队的成员都经历一段痛苦的适应期。

尽管如此,前辈们依然总结了几个最佳实践可以遵循,这些实践被绝大多数的团队所采用,本文将为你列出9个最常用且最重要的实践来帮助你提升异常处理的技能。

在做任何事的行动之前,知道为什么做?做了能解决什么问题?然后才去思考怎么做!这样不仅会让你思路更清晰,还可以让这件事更有价值。因此在进入探讨异常处理最佳实践的正题之前,我们首先需要解决两个问题:

  1. 什么是异常和异常处理?

  2. 为什么需要它们?

异常及异常处理

什么是异常?总结为一句话就是:程序在执行过程中产生的异常情况。当某些事情出现了错误异常就会发生,比如打开一个并不存在的文件、尝试在一个为null的对象上调用方法等等,都会发生异常。

异常是不可预知的,可是一旦它发生了就需要进行异常处理,可谓知错就改善莫大焉!异常处理是一种错误处理机制,如果你不对异常做任何处理,异常将会导致应用程序崩溃。

一旦你选择了进行处理异常,也就意味着你承认问题的发生,采用必要要的措施去让应用程序从错误中恢复,从而让业务继续进行,阻止应用程序崩溃。

实际上异常处理并不是处理问题的唯一一种方式,如今的高级语言一般都有异常处理机制,但比较古老的如C语言是通过返回错误码的方式来处理异常的。比如数组越界比较常用的返回值是-1。

这种方式的优点是代码逻辑易于推理,没有中断和代码跳转。另一方面,这种处理方式鼓励函数的调用者总是检查返回的错误码。但是这种检查容易造成代码污染,导致代码的可读性和可维护性降低。

错误代码的另一个严重的缺点是缺乏上下文信息,你可能知道错误码“-5”代表找不到文件,但究竟找不到哪个文件呢!错误码就无法表述了。

错误代码一般用于面向过程的语言,对面向对象的高级语言,有些场景是无能为力的,比如构造函数异常,是无法返回错误码的。

异常处理

当异常被抛出时,应用程序的流程就会被中断,如果没能及时处理异常,应用程序将崩溃。用户将看到异常信息,但那些信息大多他们是看不懂的,这将是一个很糟糕的用户体验,实际上异常信息还可以包装成非常友好的提示。

所以必须进行异常处理,哪怕是为了提高用户体验、记录问题日志、优雅地退出应用程序等。

我们可以使用代码块(try...catch...)来进行异常处理,当发生异常,通过try执行代码,如果发生异常,应用程序的流程将转移到catch中,catch捕捉到异常并进行必要的处理。

以上表述的异常处理原理对初学者依然比较抽象,我们来举个例子

package com.zqf;
public class App
{
public static void main(String[] args){
System.out.println("First line");
System.out.println("Second line");
System.out.println("Third line");
//初始化具有3个元素的素组
int[] myIntArray = new int[]{1, 2, 3};
print4thItemInArray(myIntArray);
System.out.println("Fourth line");
System.out.println("Fith line");
}
private static void print4thItemInArray(int[] arr) {
//获取第4个(下标3)元素,因为没有所以抛出异常
System.out.println(arr[3]);
System.out.println("Fourth element successfully displayed!");
}
}

分析下这个程序,在main中初始化有3个元素的数组,把这个数组传递给私有方法print4thItemInArray,在print4thItemInArray中试图获取数组的第4个元素,由于没有第4个元素将抛出“ArrayIndexOutOfBoundsException”异常,应用程序只会打印到“Third line”。

执行应用输出结果如下

First line

Second line

Third line

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3

at com.zqf.App.print4thItemInArray(App.java:20)

at com.zqf.App.main(App.java:14)

Process finished with exit code 1

现在我们修改下,增加异常处理

package com.zqf;
public class App {
public static void main(String[] args) {
System.out.println("First line");
System.out.println("Second line");
System.out.println("Third line");
//初始化具有3个元素的素组
int[] myIntArray = new int[]{1, 2, 3};
try {//捕捉异常
print4thItemInArray(myIntArray);
}catch (ArrayIndexOutOfBoundsException ex){//异常处理
System.out.println("Have no four items!");
}
System.out.println("Fourth line");
System.out.println("Fith line");
}
private static void print4thItemInArray(int[] arr) {
//获取第4个(下标3)元素,因为没有所以抛出异常
System.out.println(arr[3]);
System.out.println("Fourth element successfully displayed!");
}
}

现在运行看看输出

First line

Second line

Third line

Have no four items!

Fourth line

Fith line

实际上这次的异常依然会发生,因为第4个元素的确不存在,所以在"Fourth element successfully displayed!"输出之前就抛出了异常,中断执行流程,但流程跳转到catch语句块了,catch只打印了一条“Have no four items”,继续向下执行。

Java异常体系

在Java中,所有的异常都有一个共同的祖先Throwable,它有2个子类:Exception(异常)和Error(错误),它们又各自有大量的子类。Exception(异常)和Error(错误)的共性和区别:两者都可以被捕捉,但前者可以被应用程序本身处理,后者是严重的,是无法恢复处理的。

最佳实践

1. 用Finally或Try-With-Resource清理资源

我们经常在try语句块使用资源,比如InputStream,使用完后需要关闭。经常犯的错误是在try语句块中关闭资源。如

public void doNotCloseResourceInTry() {
FileInputStream inputStream = null;
try {
File file = new File("./tmp.txt");
inputStream = new FileInputStream(file);
// 使用inputStream读取文件
// 不要这样做
inputStream.close();
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
}

这种方式看似非常完美,不会有异常抛出,所有的语句在try中执行,关闭IputStream释放资源。但试想一下:如果在“inputStream.close()”语句之前就抛出异常,会怎样呢?正常的流程会被中断并跳转,导致InputStream根本没关闭。

因此,应该把清理资源的代码放在finally或try-with-resource语句中。不管是正常执行完try语句块,还是异常处理完毕,都会执行finally语句块,而你需要确保在finally关闭所有打开的资源。

public void doNotCloseResourceInTry() {
FileInputStream inputStream = null;
try {
File file = new File("./tmp.txt");
inputStream = new FileInputStream(file);
// 使用inputStream读取文件
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (Exception e) {
log.error(e);
}
}
}
}

在JDK7引入了try-with-resource的语法,简单来说当一个资源对象(如InputSteam对象)实现了AutoCloseable接口,那么就可以在try关键字后的括号里创建实例,当try-catch语句块执行完毕后,会自动关闭资源,代码也会简洁许多。如下

public void doNotCloseResourceInTry() {
File file = new File("./tmp.txt");
try (FileInputStream inputStream = new FileInputStream(file)) {
// 使用inputStream读取文件
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
}
2. 抛出具体异常

你抛出的异常越具体越好,不熟悉你代码的同事或者几个月之后的你,可能需要调用你这些方法并进行异常处理,所以尽可能多的提供信息,让你的API更容易理解,比如能用NumberFormatException就不要用 IllegalArgumentException,绝对避免直接使用不具体的Exception类。

//不建议
public void doNotDoThis() throws Exception { ... }
//建议
public void doThis() throws NumberFormatException { ... }
3. 做好注释/文档

只要你在方法声明异常,就需要做好Javadoc的注释。这点和上一条最佳实践有相同的目标:提供给调用者尽可能多的信息,便于避免异常或进行异常处理。所以请确保你在Javadoc中添加了"@throws"声明,并且描述了造成异常的情况。

/**
* This method does something extremely useful ...
*
* @param input
* @throws MyBusinessException if ... happens
*/
public void doSomething(String input) throws MyBusinessException { ... }
4. 异常携带可描述的信息

这条最佳实践和前面两条有点相似,但这条提供的信息不单是给方法调用者看的,而更多的是为了给记录日志或监控工具提供的,便于排查异常。实际上一般的异常类名就已经描述了问题的类型,你不必提供大量的附加信息,简洁凝练即可。比如NumberFormatException,当java.lang.Long构造函数抛出异常时会提供一句简短且清晰的文本来描述。

try {
new Long("xyz");
} catch (NumberFormatException e) {
log.error(e);
}

NumberFormatException 的名字就已经告诉你异常的种类,它携带的信息仅告诉你提供的字符串会导致异常,但如果异常名字不能表达异常种类,就需要提供更多的信息。上述的异常信息如下

17:17:26,386 ERROR TestExceptionHandling:52 - java.lang.NumberFormatException: For input string: "xyz"

如果你仔细看下JDK的源码,就会清楚java.lang.Long在构造器中做了各种校验,当某些校验失败会调用NumberFormatException.forInputString,而静态方法forInputString会把java.lang.Long的构造参数格式化后再构造一个新的NumberFormatException实例并抛出

/**
* Factory method for making a <code>NumberFormatException</code>
* given the specified input which caused the error.
*
* @param s the input causing the error
*/
static NumberFormatException forInputString(String s) {
return new NumberFormatException("For input string: \"" + s + "\"");
}
5. 先捕捉子类异常

很多IDE都会帮助你进行最佳实践,如果你先捕捉父类异常再捕捉子类异常,它们会告诉你后面的代码不可到达或者警告已经被捕捉,因为是按照catch在在代码中顺序执行的。

所以如果先捕捉IllegalArgumentException,将不能捕捉到其子类NumberFormatException,因此最佳时间是总是先捕捉更多信息的异常(子类),再捕捉父类。如

public void catchMostSpecificExceptionFirst() {
try {
doSomething("A message");
} catch (NumberFormatException e) {
log.error(e);
} catch (IllegalArgumentException e) {
log.error(e)
}
}
6. 不要捕捉Throwable

从Java异常体系的图中可知,Throwable是所有异常(Exception)和错误(Error)的祖先,Throwable是可以被捕捉,但请不要捕捉。如果你捕捉了Throwable,那么不仅仅是捕捉了异常,还捕捉了错误。但错误是无法恢复,它是被JVM抛出的严重错误,应用程序对这类错误是无能为力的。

//不要捕捉Throwable
public void doNotCatchThrowable() {
try {
// do something
} catch (Throwable t) {
// don't do this!
}
}
7. 不要忽略异常

你是否记得曾几何时,在分析bug时遇到代码只执行了前半部分,但却不知为何。有些开发者经常捕捉了异常,但凭经验认为异常决定不可能发生,导致没有做异常处理

public void doNotIgnoreExceptions() {
try {
// do something
} catch (NumberFormatException e) {
// this will never happen,I'm sure!!
}
}

实际上在大多数情况下它都发生了,因为随着时间和业务逻辑的变更,try代码块的内容变更了,导致了异常发生,而你的自信不仅害了你也害了后来人。建议catch中至少要留一条日志,来告知异常问题,方便排查。

public void logAnException() {
try {
// do something
} catch (NumberFormatException e) {
log.error("This should never happen: "
+ e + ",but I get a mistake!");
}
}
8. 不要在仅仅记录日志后向上抛出异常

“不要在记录日志后向上抛出异常”,这是最佳实践中最容易被忽视的一条。你会发现在大量的代码片段,甚至类库中经常捕捉异常、记录日志,然后抛出异常。

try {
new Long("xyz");
} catch (NumberFormatException e) {
log.error(e);
throw e;
}

直观的感觉是记录异常,然后抛出异常让调用者可以恰当的处理,但同一个异常多处日志记录,会让人迷惑,请参考第4条最佳实践:简洁凝练。

17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)

如果你真的需要给调用者提供更多信息,可以参考下一条最佳实践:包装异常。

9. 包装异常

比较可取的做法是捕捉到标准异常,根据实际业务自定义包装异常再向上抛出。在包装异常时通常把原始异常作为构造参数传进来,否则会丢失栈的跟踪信息,造成分析困难。

public void wrapException(String input) throws MyBusinessException {
try {
// do something
} catch (NumberFormatException e) {
throw new MyBusinessException("A message that describes the error.", e);
}
}

总结

如你所见,当你处理异常或抛出异常是有许多要考虑的事情,大多是从代码的可读性和API可用性来考虑。因此,最好和同事一起讨论异常处理的最佳实践,从而达成共识、步调一致,不仅提高工作效率,还能避免不可预知的异常。

最新、更多漫画请关注微信公众号:码农神说。转载开白可联系助手,微信号:codeceo-01

相关阅读

发布于: 2020 年 07 月 09 日 阅读数: 36
用户头像

码农神说

关注

欢迎关注公众号【码农神说】 2018.11.09 加入

图解码农技术,大话码农故事,漫画感悟码农人生,助力码农翻身!

评论

发布
暂无评论
Java中异常处理的9个最佳实践