JAVA 异常处理的认知学习过程
-
没有异常处理
学生时代,我编写的java代码中,很少会有try catch.最主要的原因如下:
- 应用的规模很小
- 没有不确定因素
- 代码可控性高
如果规模小,往往就没有复杂的逻辑链路,整个软件的分层也很浅.很多地方的问题都是"编码"的问题.其次,学生时代的作品中,往往没有复杂的组件:数据库连接本地,没有rpc调用,没有微服务化的需求,而这些,往往容易带来网络、服务端的不确定因素.最后,整个工程往往由一个人编写,所以对哪些整个处理流程的全链路都可以掌控到,明白调用的模块到底有没有抛出异常.
很容易理解,这些特性决定了,即使完全不使用java的异常机制,整个应用也能跑起来.
这就是一个很简单的无异常处理的示例代码:
public boolean saveStudentInfo(File file) { // 读取本地一个csv文件 List<StudentInfo> infoList = readFile(file); // 通过网络发送到某个服务器 sendSocket(infoList); }
-
全量try catch
到了工作的时候,第一堂课就是一定要捕获异常.如果spring MVC的controller不捕获异常,tomcat就会直接把抛出来的异常打印到页面上.对于一些面向用户的系统,会造成很大的影响.所以那个时候,从dao层,service层,到controller层,恨不得整个调用链路上的所有方法都通过捕获
Exception
来捕获一切异常,代码写成了这样:public boolean saveStudentInfo(File file) { try { List<StudentInfo> infoList = readFile(file); sendInfo(infoList); return true; } catch (Exception e) { logger.error("保存学生信息服务调用失败", e); return false; } }
如果这么做的话,有以下几点问题:
- 无法针对不同异常做不同的处理
- 吞并了异常信息
- 无效化某些异常应该的走中断流程
-
整块代码块分别try catch
为了解决上述的第一个问题,我们会在代码块内部根据不同的异常情况,抛出不同的异常.然后在catch代码块中分别处理.
public boolean saveStudentInfo(File file) { try { List<StudentInfo> infoList = readFile(file); sendInfo(infoList); return true; } catch (FileNotFoundException e1) { logger.error("保存学生信息失败:文件没有找到", e1); } catch (SocketTimeoutException e2) { logger.error("保存学生信息失败:网络超时", e2); } catch (Exception e3) { logger.error("保存学生信息失败:未知异常", e3); } return false; }
通过上述的做法,我们就可以区分不同的异常了.定位问题也能更准确.
-
分块 try catch
但上述方案依然有个明显的弊端:如果代码块中有多个地方可能抛出同一种异常,在catch到异常后也无法真正做到区分.为了解决这个问题,我们不仅要从catch的角度进行拆分,还要从try的角度进行拆分.
public boolean saveStudentInfo(File file) { try { List<StudentInfo> infoList = readFile(file); } catch (FileNotFoundException e) { logger.error("没有找到文件:{}", file); return false; } try { sendInfo(infoList); } catch (SocketTimeoutException e) { logger.error("服务器连接超时"); return false; } }
非常有意思的一点是,为什么在两次调用中分别只catch了
FileNotFoundException
和SocketTimeoutException
?为什么不在最后catch住Exception
?其实是我想说明这样一个问题:异常catch精细化.所谓精细化,其实是对代码质量进行控制的一个结果.换句话说:为了提高代码质量,你应该尽可能弄清楚每块代码可能抛出什么异常,并进行针对的处理.上面这个例子中.如果你很清楚
List<StudentInfo> infoList = readFile(file);
这句调用只可能产生FileNotFoundException
这个异常.那么你就该精细到这个异常本身.专门去catch这个异常.并作出对应的处理.当然如果这里有第二种异常可能被抛出,你也应当专门去catch.当然这里也有个逻辑分层的概念,一般的业务代码遵循上述原则.但在调用链路上的某个重要环节,比如Controller,是有可能需要捕获全量异常的.
-
不catch异常
到现在,我们依然面临这些问题:
- 无法真正做到catch全部异常
- catch异常导致信息被吞没
- 中断流程无效化
第一点,如果调用别人的代码,就不知道运行时到底会抛出什么异常,或者RPC调用,可能混入中间件本身的异常.等等这些情况导致无法真正做到把所有异常都分类进行catch.所以,除非catch住
Exception
这个异常,否则必然会有一些漏网之鱼没有被catch到.第二点,有的异常比如:InterruptedException
,如果你catch住了,但是什么也不做(打印日志在某些情况下也约等于什么也不做),是有一定问题的.第三点,有的异常被设计出来就是要中断当前业务逻辑的.如果你catch住了,但是没有正确中断当前流程,会导致更严重的问题.要真正解决上述的问题,需要明白:异常处理本质上想达到的目的不是消除所有的异常本身,而是有效地向上传递错误信息和正确地中断当前处理流程.在前面讲的通过catch
Exception
来处理异常,实际上就是犯了想要消除异常本身的错误.也就是说,考量一个异常到底有没有被正确处理,指导思想是:
- 关于异常的信息有没有被正确传递
- 当前的处理流程有没有被正确中断
到这里,不catch异常,反倒成了某些情形下处理异常的最佳解决方案.
-
到底catch不catch?
那么,究竟是什么因素决定了是应该catch住异常,还是继续抛出异常呢?如果用最简单的语言描述,答案就是:这个异常当前能不能处理,如果不能,就继续往上抛.于是问题就变成了对处理的定义.一个经常出现的疑惑是:catch一个异常打印一行日志,到底算不算处理?
这个问题要从我们上文中的指导思想中找答案.首先看第二个维度,如果需要中断流程,而只是打印了日志,很明显就不是正确的处理.这个相对比较容易理解.接着再看第一个维度.关于这个信息.可能有人会认为:日志已经打印出去了,按理说信息已经成功传递了啊?
从宏观上,把错误日志打印出去≠100%正确传递信息.比如说某个很严重的问题发生了,虽然及时打印了日志,但是没有及时通知到人身上,而是导致服务挂掉,用户不能访问应用,反馈过来,才发现了日志文件中大量的异常.这就是:虽然信息传递出去了,但是却没有正确传递.所谓正确传递,应该是按照不同重要性,以不同方式,正确及时地通知到正确的处理方.比如用户输入个人信息,名字中带了特殊符号,前端直接提醒用户.这里的重要性就是低,方式就是前端反馈,处理方是用户.
这里看上去和java的try catch无关.但是实质上,却有相通之处.
对应到我们之前说的打印一行日志是否是正确传递信息的问题.如果你确定,应用对于这个异常的处理,只需要打印一样日志就ok了.既没有逻辑回滚,保证操作原子性的需要,也没有必须马上通知处理方的需要,就可以认为这里的处理是合适的.
-
结束了吗?
最后最有意思的一点来了.上述的东西都不复杂,为什么在实际应用中却难以真正做好呢?
我总结了一个有意思的原因:即使不通过异常,java代码也可以实现异常信息的传递和流程的中断,正是因为有两种方式并行存在,反倒让人疑惑到底应该使用哪一种.
A: if (condition == -1) { throw new Exception("异常信息"); } B: try { // do something } catch (Exception e) { logger.error("异常信息"); return false; } // do something
上面的代码,分别就是这两种方式.都可以做到中断流程和信息传递.由于第二种的存在,很多时候反倒让人不愿意使用第一种,它看上去更复杂,更危险.而其实在正确考量之后,应该放心大胆得抛出异常.