异常处理及思考

Java中应该如何处理异常,这个话题看似简单,不就是try...catch嘛,但是往往BUG更容易出现在一些简单地、我们更容易忽略的地方。

一个好的异常处理能让开发人员快速定位异常信息和修复问题,同时也能更好的让使用人员进进行捕获和处理异常信息。

使用finally或try…with…resource关闭资源

如果我们在try代码块中需要使用到一些资源,比如InputStream,在使用完之后我们需要将资源关闭。

<font color="red">错误示例如下:​**</font>**

1
2
3
4
5
6
7
8
9
10
11
12
13
public void incorrectRead() {
FileInputStream inputStream = null;
try {
File file = new File("d:\\a.txt");
inputStream = new FileInputStream(file);
// read something
inputStream.close();
} catch (FileNotFoundException e) {
log.error("文件未找到", e);
} catch (IOException e) {
log.error("文件读取异常", e);
}
}

在上面这段代码中,只要在文件读取时没有出现异常,这段代码是可以正常工作的,但是只要在try块中的close()方法中抛出异常,资源就不会被关闭。

所以这种情况我们应该将资源关闭的代码放在finally中或者使用try…with…resource语句。

应该使用finally,在finally块中的代码不管是否出现异常,都会被执行,因此可以确保资源对象被关闭。

正确示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void correctRead() {
FileInputStream inputStream = null;
try {
File file = new File("d:\\a.txt");
inputStream = new FileInputStream(file);
// read something
} catch (FileNotFoundException e) {
log.error("文件未找到", e);
} catch (IOException e) {
log.error("文件读取异常", e);
}finally{
inputStream.close();
//或者使用IoUtil工具等关闭流,eg:IoUtil.close(inputStream);
}
}

使用try…with…resource

随着资源使用的增多,当打开多个资源是对应的资源关闭也是一个问题。因为资源打开的越多,finally中嵌套的次数越多,这将导致大量的无用代码,从而导致代码的臃肿。

try…with…resource正是java 1.7中新增的语法糖功能,而通过这个语法糖功能,无需我们手动的关闭资源,程序会自动关闭我们打开的资源。

对应的条件:

  • <font color="red">资源(resource)是指在程序完成后,必须关闭的对象。try-with-resources 语句确保了每个资源在语句结束时关闭​**</font>**
  • <font color="red">所有实现了 java.lang.AutoCloseable 接口(其中,它包括实现了 java.io.Closeable 的所有对象),可以使用作为资源​**</font>**
  • <font color="red">越晚声明的对象,会越早被close掉,即先开后闭原则​**</font>**

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void correctRead() {
File file = new File(javaFile);
int fileLen = (int) file.length();
byte[] bytes = new byte[fileLen];
try (FileInputStream is = new FileInputStream(file)) {
is.read(bytes);
String content = new String(bytes, "UTF-8");
System.out.println("content = \n" + content);
} catch (IOException ioException) {
log.error("流关闭异常", ioException);
ioException.printStackTrace();
};
}

使用更明确的异常

如果我们的方法需要向外抛出异常,那么异常类型越具体越好。因为在外部调用你代码的其他人对你内部的实现逻辑可能并不清楚,所以要确保能提供给他尽可能多的信息,可以让别人在使用你的方法时更容易理解,这样调用方可以更好地处理抛出的异常。

比如,在你的方法内容抛出NumberFormatException比抛出IllegalArgumentException或者直接抛出Exception,所代表的含义就会更明确。

方法注释中对异常进行说明

如果你的方法声明了可能会抛出异常,那么在方法的文档注释中,应该对异常进行说明。这和上一条的目的一样,都是为了让方法的调用者能提前获得更多的信息,方便他避免在调用你方法时出现异常,或者更明确如果进行异常处理。

所以,我们应该在方法的文档注释中添加@throws声明,并说明什么情况下会抛出对应的异常。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取数据库连接
* @param cfg 数据库信息
* @return 数据库连接信息
* @throws ClassNotFoundException 数据库驱动获取异常
* @throws SQLException 数据库连接异常
*/
public static Connection getConnect(DataSourceConfig cfg) throws ClassNotFoundException, SQLException {
Class.forName(cfg.getDriverClass());
return DriverManager.getConnection(cfg.getJdbcUrl(), cfg.getUserName(), cfg.getPassword());
}

在异常中携带足够的描述信息

这一点和前两条做法的目的类似。在异常中携带足够的描述信息,是为了在出现该异常时,能够在日志文件中查看异常信息时,能看到更有用的信息。

所以我们应该尽可能准确地描述出为什么抛出了这个异常,并提供最相关的数据信息让别人定位。

当然这里也不能太极端,你洋洋洒洒写一篇小作文,应该使用简短的一段信息描述,让运维同事能了解到这个问题的严重性,更轻松地分析问题所在。

也不用提供一堆额外的冗余信息,尽量做到足够精准。比如当你再创建一个Long对象时如果传入一个字符串,就会抛出NumberFormatException

NumberFormatException的类名已经告诉我们出现的是数字格式化异常,所以在message中只需要提供输入的字符串。如果你定义的异常类名不能很明确的表达出是什么异常,比如BusinessException,你就应该在message中表达出更多的信息。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
try {
Long sss = Long.valueOf("sss");
} catch (NumberFormatException e) {
throw new CustomException("数字格式化异常", e);
}
}

/**
* 自定义异常
*/
static class CustomException extends RuntimeException {
public CustomException() {
super();
}

public CustomException(String message) {
super(message);
}

public CustomException(String message, Throwable cause) {
super(message, cause);
}
}

控制台打印信息如下:

1
2
3
4
5
6
7
Exception in thread "main" ExceptionTest$CustomException: 数字格式化异常
at ExceptionTest.main(ExceptionTest.java:77)
Caused by: java.lang.NumberFormatException: For input string: "sss"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.valueOf(Long.java:803)
at ExceptionTest.main(ExceptionTest.java:75)

先捕获更明确的异常

一般在我们使用的IDE中,如果当你在做异常捕获时,先捕获了不太具体的异常比如Exception,然后再捕获更具体的异常如IOException,都会提示我们后面的catch块无法到达。所以我们应该先捕获最具体的异常类,将不太具体的异常类的捕获放在最后。

1
2
3
4
5
6
7
8
9
public void catchException() {
try {
doSomthing(...)
} catch (NumberFormatException e) {
log.error("格式异常", e);
} catch (IllegalArgumentException e) {
log.error("非法参数", e);
}
}

不要捕获Throwable

Throwable是所有ExceptionError的父类。

虽然可以在catch块中捕获它,但是我们不应该这样去做。因为如果使用了Throwable,那么不仅会对所有抛出的Exception进行捕获,还会捕获所有的Error

而当我们的程序抛出Error时表示是一个无法处理的严重问题,例如典型的OutofMemoryErrorStackOverflowError等,这两个Error都是由程序无法控制并且不能处理的情况引起的。所以说,最好不要在你的catch中捕获Throwable,除非你非常确定try块中的代码抛出的是可以处理的异常情况。

<font color="red">错误示例如下:​**</font>**

1
2
3
4
5
6
7
public void catchThrowable() {
try {
// 一些业务代码
} catch (Throwable t) {
// 不要这样做
}
}

不要将异常忽略

在你开发的时候可能非常确定不会抛出异常,并且在你开发时确实没有发生过抛出异常的情况,所以你在catch块中没有对异常做任何处理。

1
2
3
4
5
6
7
public void doNotIgnoreExceptions() {
try {
// 一些业务代码
} catch (NumberFormatException e) {
// 认为永远不会执行到这里
}
}

但是,你其实不确定在将来会不会有人在你的try块中添加新的代码,并且他可能也不会意识到他添加的代码会导致有异常抛出,这将会导致在线上真的有异常产生,但是没有一个人知道。

所以,你至少应该在catch中打印一行日志,告诉同事,“警报,这里出现了一个不可能会出现的异常”。

1
2
3
4
5
6
7
public void doNotIgnoreExceptions() {
try {
// 一些业务代码
} catch (NumberFormatException e) {
log.error("警报,这里出现了一个不可能会出现的异常", e);
}
}

不要打印日志后又将异常抛出

这一条可能绝大多数人都会犯过,我见过非常多别人的代码在异常处理时,先打印了一行异常日志,然后将异常抛出,或者转成一个RuntimeException抛出。

甚至在一些开源框架中都有出现过。

1
2
3
4
5
6
7
8
public void testCatchEx() {
try {
new Long("will");
} catch (NumberFormatException e) {
log.error("数字格式异常", e);
throw e;
}
}

你可能会认为这样做很直观,也没什么错,让调用你方法的人去处理就好了。但是这样一来,在日志中会对抛出的一个异常打印多条错误信息。

重复的日志并没有带来任何有价值的信息,参考上面第4条中描述,在异常信息中应该携带足够的信息,并且要做到精准。如果需要在添加其他信息,你应该将捕获到的异常封装在你的自定义异常中再进行抛出。

1
2
3
4
5
6
7
public void wrapException(String input) throws CustomException {
try {
// do something
} catch (NumberFormatException e) {
throw new CustomException("数字格式转换异常", e);
}
}

所以,我们应该只有在想对异常进行处理时捕获,否则就应该在抛出去,并且在方法前面上加以说明,让调用方去处理。

在包装异常时使用原始异常

通常在项目开发时,都会有一套自定义的异常,用于将API中的标准异常封装到自定义异常中,可以用于在外层做一些统一的异常处理。

但是我们在使用自定义异常对原始异常进行封装时,需要确保将原始异常作为cause保存在自定义异常中,否则你在外层将会丢失原始异常的堆栈跟踪信息,到你你无法通过异常信息分析抛出异常的具体原因。

1
2
3
4
5
6
7
8
public void wrapException(String input) throws CustomException {
try {
// do something
} catch (NumberFormatException e) {
// 将e作为构造参数中的cause
throw new CustomException("数字格式转换异常", e);
}
}

总结

在抛出或者捕获异常时,我们应该考虑很多不同的事情,上面所说的大多数都是为了提高代码的可读性和提供给别人的API更易用。

通常异常不光是一种错误处理机制,同时还具备一定的信息媒介作用。我们应该遵循这些异常处理的规则和最佳实践,写出更规范,不让别人吐槽的好代码。


异常处理及思考
https://github.com/yangxiangnanwill/yangxiangnanwill.github.io/2024/01/03/好好码代码吖/JAVA/JAVA特性/异常处理及思考/
作者
will
发布于
2024年1月3日
许可协议