JAVA 异常处理的认知学习过程

  1. 没有异常处理

    学生时代,我编写的java代码中,很少会有try catch.最主要的原因如下:

    • 应用的规模很小
    • 没有不确定因素
    • 代码可控性高

    如果规模小,往往就没有复杂的逻辑链路,整个软件的分层也很浅.很多地方的问题都是"编码"的问题.其次,学生时代的作品中,往往没有复杂的组件:数据库连接本地,没有rpc调用,没有微服务化的需求,而这些,往往容易带来网络、服务端的不确定因素.最后,整个工程往往由一个人编写,所以对哪些整个处理流程的全链路都可以掌控到,明白调用的模块到底有没有抛出异常.

    很容易理解,这些特性决定了,即使完全不使用java的异常机制,整个应用也能跑起来.

    这就是一个很简单的无异常处理的示例代码:

     public boolean saveStudentInfo(File file) {
         // 读取本地一个csv文件
         List<StudentInfo> infoList = readFile(file);
         // 通过网络发送到某个服务器
         sendSocket(infoList);
     }
    
  2. 全量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;
         }
     }
    

    如果这么做的话,有以下几点问题:

    • 无法针对不同异常做不同的处理
    • 吞并了异常信息
    • 无效化某些异常应该的走中断流程
  3. 整块代码块分别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;
     }
    

    通过上述的做法,我们就可以区分不同的异常了.定位问题也能更准确.

  4. 分块 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,是有可能需要捕获全量异常的.

  5. 不catch异常

    到现在,我们依然面临这些问题:

    • 无法真正做到catch全部异常
    • catch异常导致信息被吞没
    • 中断流程无效化

    第一点,如果调用别人的代码,就不知道运行时到底会抛出什么异常,或者RPC调用,可能混入中间件本身的异常.等等这些情况导致无法真正做到把所有异常都分类进行catch.所以,除非catch住Exception这个异常,否则必然会有一些漏网之鱼没有被catch到.第二点,有的异常比如: InterruptedException,如果你catch住了,但是什么也不做(打印日志在某些情况下也约等于什么也不做),是有一定问题的.第三点,有的异常被设计出来就是要中断当前业务逻辑的.如果你catch住了,但是没有正确中断当前流程,会导致更严重的问题.

    要真正解决上述的问题,需要明白:异常处理本质上想达到的目的不是消除所有的异常本身,而是有效地向上传递错误信息和正确地中断当前处理流程.在前面讲的通过catch Exception来处理异常,实际上就是犯了想要消除异常本身的错误.

    也就是说,考量一个异常到底有没有被正确处理,指导思想是:

    • 关于异常的信息有没有被正确传递
    • 当前的处理流程有没有被正确中断

    到这里,不catch异常,反倒成了某些情形下处理异常的最佳解决方案.

  6. 到底catch不catch?

    那么,究竟是什么因素决定了是应该catch住异常,还是继续抛出异常呢?如果用最简单的语言描述,答案就是:这个异常当前能不能处理,如果不能,就继续往上抛.于是问题就变成了对处理的定义.一个经常出现的疑惑是:catch一个异常打印一行日志,到底算不算处理?

    这个问题要从我们上文中的指导思想中找答案.首先看第二个维度,如果需要中断流程,而只是打印了日志,很明显就不是正确的处理.这个相对比较容易理解.接着再看第一个维度.关于这个信息.可能有人会认为:日志已经打印出去了,按理说信息已经成功传递了啊?

    从宏观上,把错误日志打印出去≠100%正确传递信息.比如说某个很严重的问题发生了,虽然及时打印了日志,但是没有及时通知到人身上,而是导致服务挂掉,用户不能访问应用,反馈过来,才发现了日志文件中大量的异常.这就是:虽然信息传递出去了,但是却没有正确传递.所谓正确传递,应该是按照不同重要性,以不同方式,正确及时地通知到正确的处理方.比如用户输入个人信息,名字中带了特殊符号,前端直接提醒用户.这里的重要性就是低,方式就是前端反馈,处理方是用户.

    这里看上去和java的try catch无关.但是实质上,却有相通之处.

    对应到我们之前说的打印一行日志是否是正确传递信息的问题.如果你确定,应用对于这个异常的处理,只需要打印一样日志就ok了.既没有逻辑回滚,保证操作原子性的需要,也没有必须马上通知处理方的需要,就可以认为这里的处理是合适的.

  7. 结束了吗?

    最后最有意思的一点来了.上述的东西都不复杂,为什么在实际应用中却难以真正做好呢?

    我总结了一个有意思的原因:即使不通过异常,java代码也可以实现异常信息的传递和流程的中断,正是因为有两种方式并行存在,反倒让人疑惑到底应该使用哪一种.

     A:
     if (condition == -1) {
         throw new Exception("异常信息");
     }
     
     B:
     try {
         // do something
     } catch (Exception e) {
         logger.error("异常信息");
         return false;
     }
     
     // do something
    

    上面的代码,分别就是这两种方式.都可以做到中断流程和信息传递.由于第二种的存在,很多时候反倒让人不愿意使用第一种,它看上去更复杂,更危险.而其实在正确考量之后,应该放心大胆得抛出异常.

posted @ 2018-03-08 21:28  DavieTiming  阅读(304)  评论(0编辑  收藏  举报