zoukankan      html  css  js  c++  java
  • 编写高质量代码:改善Java程序的151个建议(第8章:异常___建议110~113)

      不管人类的思维有多么缜密,也存在" 智者千虑必有一失 "的缺憾。无论计算机技术怎么发展,也不可能穷尽所有的场景___这个世界是不完美的,也是有缺陷的。完美的世界只存在于理想中。

      对于软件帝国的缔造者来说,程序也是不完美的,异常情况会随时出现,我们需要它为我们描述例外事件,需要它处理非预期的情景,需要它帮我们建立“完美世界”。

    建议110:提倡异常封装

      Java语言的异常处理机制可以去确保程序的健壮性,提高系统的可用率,但是Java API提供的异常都是比较低级的(这里的低级是指 " 低级别的 " 异常),只有开发人员才能看的懂,才明白发生了什么问题。而对于终端用户来说,这些异常基本上就是天书,与业务无关,是纯计算机语言的描述,那该怎么办?这就需要我们对异常进行封装了。异常封装有三方面的优点:

    (1)、提高系统的友好性

      例如,打开一个文件,如果文件不存在,则回报FileNotFoundException异常,如果该方法的编写者不做任何处理,直接抛到上层,则会降低系统的友好性,代码如下所示:

    public static void doStuff() throws FileNotFoundException {
            InputStream is = new FileInputStream("无效文件.txt");
            /* 文件操作 */
        }

      此时doStuff的友好性极差,出现异常时(如果文件不存在),该方法直接把FileNotFoundException异常抛到上层应用中(或者是最终用户),而上层应用(或用户要么自己处理),要么接着抛,最终的结果就是让用户面对着" 天书 " 式的文字发呆,用户不知道这是什么问题,只是知道系统告诉他"  哦,我出错了,什么错误?你自己看着办吧 "。

      解决办法就是封装异常,可以把异常的阅读者分为两类:开发人员和用户。开发人员查找问题,需要打印出堆栈信息,而用户则需要了解具体的业务原因,比如文件太大、不能同时编写文件等,代码如下: 

    public static void doStuff2() throws MyBussinessException{
            try {
                InputStream is = new FileInputStream("无效文件.txt");
            } catch (FileNotFoundException e) {
                //方便开发人员和维护人员而设置的异常信息
                e.printStackTrace();
                //抛出业务异常
                throw new MyBussinessException();
            }
            /* 文件操作 */
        }

    (2)、提高系统的可维护性

      看如下代码: 

    public  void doStuff3(){
            try{
                //doSomething
            }catch(Exception e){
                e.printStackTrace();
            }
            
        }

      这是大家很容易犯的错误,抛出异常是吧?分类处理多麻烦,就写一个catch块来处理所有的异常吧,而且还信誓旦旦的说" JVM会打印出栈中的错误信息 ",虽然这没错,但是该信息只有开发人员自己看的懂,维护人员看到这段异常时基本上无法处理,因为需要到代码逻辑中去分析问题。

      正确的做法是对异常进行分类处理,并进行封装输出,代码如下:  

    public  void doStuff4(){
            try{
                //doSomething
            }catch(FileNotFoundException e){
                log.info("文件未找到,使用默认配置文件....");
                e.printStackTrace();
            }catch(SecurityException e1){
                log.info(" 无权访问,可能原因是......");
                e1.printStackTrace();
            }
        }

      如此包装后,维护人员看到这样的异常就有了初步的判断,或者检查配置,或者初始化环境,不需要直接到代码层级去分析了。

    (3)、解决Java异常机制自身的缺陷

      Java中的异常一次只能抛出一个,比如doStuff方法有两个逻辑代码片段,如果在第一个逻辑片段中抛出异常,则第二个逻辑片段就不再执行了,也就无法抛出第二个异常了,现在的问题是:如何才能一次抛出两个(或多个)异常呢?

      其实,使用自行封装的异常可以解决该问题,代码如下: 

    class MyException extends Exception {
        // 容纳所有的异常
        private List<Throwable> causes = new ArrayList<Throwable>();
    
        // 构造函数,传递一个异常列表
        public MyException(List<? extends Throwable> _causes) {
            causes.addAll(_causes);
        }
    
        // 读取所有的异常
        public List<Throwable> getExceptions() {
            return causes;
        }
    }

      MyException异常只是一个异常容器,可以容纳多个异常,但它本身并不代表任何异常含义,它所解决的是一次抛出多个异常的问题,具体调用如下:

    public void doStuff() throws MyException {
            List<Throwable> list = new ArrayList<Throwable>();
            // 第一个逻辑片段
            try {
                // Do Something
            } catch (Exception e) {
                list.add(e);
            }
            // 第二个逻辑片段
            try {
                // Do Something
            } catch (Exception e) {
                list.add(e);
            }
            // 检查是否有必要抛出异常
            if (list.size() > 0) {
                throw new MyException(list);
            }
        }

      这样一来,DoStuff方法的调用者就可以一次获得多个异常了,也能够为用户提供完整的例外情况说明。可能有人会问:这种情况会出现吗?怎么回要求一个方法抛出多个异常呢?

      绝对有可能出现,例如Web界面注册时,展现层依次把User对象传递到逻辑层,Register方法需要对各个Field进行校验并注册,例如用户名不能重复,密码必须符合密码策略等,不要出现用户第一次提交时系统显示" 用户名重复 ",在用户修改用户名再次提交后,系统又提示" 密码长度小于6位 " 的情况,这种操作模式下的用户体验非常糟糕,最好的解决办法就是异常封装,建立异常容器,一次性地对User对象进行校验,然后返回所有的异常。

    建议111:采用异常链传递异常

      设计模式中有一个模式叫做责任链模式(Chain of Responsibility) ,它的目的是将多个对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止,异常的传递处理也应该采用责任链模式。

      上一建议中我们提出了异常需要封装,但仅仅封装还是不够的,还需要传递异常。我们知道,一个系统友好性的标志是用户对该系统的" 粘性",粘性越高,系统越友好,粘性越低系统友好性越差,那问题是怎么提高系统的“粘性”呢?友好的界面和功能是一个方面,另外一个方面就是系统出现非预期情况的处理方式了。

      比如我们的JavaEE项目一般都有三层结构:持久层,逻辑层,展现层,持久层负责与数据库交互,逻辑层负责业务逻辑的实现,展现层负责UI数据库的处理,有这样一个模块:用户第一次访问的时候,需要从持久层user.xml中读取信息,如果该文件不存在则提示用户创建之,那问题来了:如果我们直接把持久层的异常FileNotFoundException抛弃掉,逻辑层根本无法得知发生了何事,也就不能为展现层提供一个友好的处理结果了,最终倒霉的就是发展层:没有办法提供异常信息,只能告诉用户说“出错了,我也不知道出什么错了”___毫无友好性可言。

      正确的做法是先封装,然后传递,过程如下:

    (1)、把FIleNotFoundException封装为MyException。

    (2)、抛出到逻辑层,逻辑层根据异常代码(或者自定义的异常类型)确定后续处理逻辑,然后抛出到展现层。

    (3)、展现层自行决定要展现什么,如果是管理员则可以展现低层级的异常,如果是普通用户则展示封装后的异常。

      明白了异常为什么要传递,那接着的问题就是如何传递了。很简单,使用异常链进行异常的传递,我们以IOException为例来看看是如何传递的,代码如下:

    public class IOException extends Exception {
    
        public IOException() {
            super();
        }
        //定义异常原因
        public IOException(String message) {
            super(message);
        }
        //定义异常原因,并携带原始异常
        public IOException(String message, Throwable cause) {
            super(message, cause);
        }
        //保留原始异常信息
        public IOException(Throwable cause) {
            super(cause);
        }
    }

      在IOException的构造函数中,上一个层级的异常可以通过异常链进行传递,链中传递异常的代码如下所示:

           try{
                //doSomething
            }catch(Exception e){
                throw new IOException(e);
            }

      捕捉到Exception异常,然后把它转化为IOException异常并抛出(此种方式也叫作异常转译),调用者获得该异常后再调用getCause方法即可获得Exception的异常信息,如此即可方便地查找到产生异常的基本信息,便于解决问题。

      结合上一建议来说,异常需要封装和传递,我们在进行系统开发时不要" 吞噬 " 异常,也不要赤裸裸的抛出异常,封装后再抛出,或者通过异常链传递,可以达到系统更健壮,更友好的目的。

    建议112:受检异常尽可能转化为非受检异常

      为什么说是" 尽可能"的转化呢?因为" 把所有的受检异常(Checked Exception)"都转化为非受检异常(Unchecked Exception)" 这一想法是不现实的:受检异常是正常逻辑的一种补偿手段,特别是对可靠性要求比较高的系统来说,在某些条件下必须抛出受检异常以便由程序进行补偿处理,也就是说受检异常有合理存在的理由,那为什么要把受检异常转化为非受检异常呢?难道说受检异常有什么缺陷或者不足吗?是的,受检异常确实有不足的地方:

    (1)、受检异常使接口声明脆弱

      OOP(Object Oriented Programming,面向对象程序设计) 要求我们尽量多地面向接口编程,可以提高代码的扩展性、稳定性等,但是涉及异常问题就不一样了,例如系统初期是这样设计一个接口的:  

    interface User{
        //修改用户密码,抛出安全异常
        public void changePassword() throws MySecurityException;
    }

      随着系统的开发,User接口有了多个实现者,比如普通的用户UserImpl、模拟用户MockUserImpl(用作测试或系统管理)、非实体用户NonUserImpl(如自动执行机,逻辑处理器等),此时如果发现changePassword方法可能还需要抛出RejectChangeException(拒绝修改异常,如自动执行正在处理的任务时不能修改其代码),那就需要修改User接口了:changePassword方法增加抛出RejectChangeException异常,这会导致所有的User调用者都要追加了对RejectChangeException异常问题的处理。

      这里产生了两个问题:一、 异常是主逻辑的补充逻辑,修改一个补充逻辑,就会导致主逻辑也被修改,也就是出现了实现类 " 逆影响 " 接口的情景,我们知道实现类是不稳定的,而接口是稳定的,一旦定义了异常,则增加了接口的不稳定性,这是面向对象设计的严重亵渎;二、实现的变更最终会影响到调用者,破坏了封装性,这也是迪米特法则所不能容忍的。

    (2)、受检异常使代码的可读性降低

      一个方法增加可受检异常,则必须有一个调用者对异常进行处理,比如无受检异常方法doStuff是这样调用的:

    public static void main(String[] args) {
            doStuff();
        }

      doStuff方法一旦增加受检异常就不一样了,代码如下: 

    public static void main(String[] args) {
            try{
                doStuff();
            }catch(Exception e){
                e.printStackTrace();
            }
        }

      doStuff方法增加了throws Exception,调用者就必须至少增加4条语句来处理该异常,代码膨胀许多,可读性也降低了,特别是在多个异常需要捕捉的情况下,多个catch块多个异常处理,而且还可能在catch块中再次抛出异常,这大大降低了代码的可读性。

    (3)、受检异常增加了开发工作量

      我们知道,异常需要封装和传递,只有封装才能让异常更容易理解,上层模块才能更好的处理,可这会导致低层级的异常没玩没了的封装,无端加重了开发的工作量。比如FileNotFoundException进行封装,并抛出到上一个层级,于是增加了开发工作量。

      受检异常有这么多的缺点,那有没有什么方法可以避免或减少这些缺点呢?有,很简单的一个规则:将受检异常转化为非受检异常即可,但是我们也不能把所有的受检异常转化为非受检异常,原因是在编码期上层模块不知道下层模块会抛出何种非受检异常,只有通过规则或文档来描述,可以这样说: 

    • 受检异常提出的是" 法律下的自由 ",必须遵守异常的约定才能自由编写代码。
    • 非受检异常则是“ 协约性质的自由 ”,你必须告诉我你要抛什么异常,否则不会处理。

      以User接口为例,我们在声明接口时不再声明异常,而是在具体实现时根据不同的情况产生不同的非受检异常,这样持久层和逻辑层抛出的异常将会由展现自行决定如何展示,不再受异常的规则约束了,大大简化开发工作,提高了代码的可读性。

      那问题又来了,在开发和设计时什么样的受检异常有必要化为非受检异常呢?" 尽可能 " 是以什么作为判断依据呢?受检异常转换为非受检异常是需要根据项目的场景来决定的,例如同样是刷卡,员工拿着自己的工卡到考勤机上打考勤,此时如果附近有磁性物质干扰,则考勤机可以把这种受检异常转化为非受检异常,黄灯闪烁后不做任何记录登记,因为考勤失败这种情景不是" 致命 "的业务逻辑,出错了,重新刷一下即可。但是到银行网点取钱就不一样了,拿着银行卡到银行取钱,同样有磁性物质干扰,刷不出来,那这种异常就必须登记处理,否则会成为威胁银行卡安全的事件。汇总成一句话:当受检异常威胁到了系统的安全性,稳定性,可靠性、正确性时,则必须处理,不能转化为非受检异常,其它情况则可以转化为非受检异常。

      注意:受检异常威胁到系统的安全性,稳定性、可靠性、正确性时,不能转换为非受检异常。

    建议113:不要在finally块中处理返回值

      在finally代码块中处理返回值,这是考试和面试中经常出现的题目。虽然可以以此来出考试题,但在项目中绝对不能再finally代码块中出现return语句,这是因为这种处理方式非常容易产生" 误解 ",会误导开发者。例如如下代码:  

    public class Client113 {
        public static void main(String[] args) {
            try {
                System.out.println(doStuff(-1));
                System.out.println(doStuff(100));
            } catch (Exception e) {
                System.out.println("这里是永远不会到达的");
            }
        }
        //该方法抛出受检异常
        public static int doStuff(int _p) throws Exception {
            try {
                if (_p < 0) {
                    throw new DataFormatException(" 数据格式错误 ");
                } else {
                    return _p;
                }
    
            } catch (Exception e) {
                // 异常处理
                throw e;
            } finally {
                return -1;
            }
        }
    }

      对于这段代码,有两个问题:main方法中的doStuff方法的返回值是什么?doStuff方法永远都不会抛出异常吗?

      答案是:doStuff(-1)的值是-1,doStuff(100)的值也是-1,调用doStuff方法永远都不会抛出异常,有这么神奇?原因就是我们在finally代码块中加入了return语句,而这会导致出现以下两个问题:

    (1)、覆盖了try代码块中的return返回值

      当执行doStuff(-1)时,doStuff方法产生了DataFormatException异常,catch块在捕捉此异常后直接抛出,之后代码执行到finally代码块,就会重置返回值,结果就是-1了。也就是出现先返回,再重置返回的情况。

      有人可能会思考,是不是可以定义变量,在finally中修改后return呢?代码如下: 

    public static int doStuff() {
            int a = 1;
            try {
                return a;
            } catch (Exception e) {
    
            } finally {
                // 重新修改一下返回值
                a = -1;
            }
            return 0;
        }

      该方法的返回值永远是1,不会是-1或0(为什么不会执行到" return 0 " 呢?原因是finally执行完毕后该方法已经有返回值了,后续代码就不会再执行了),这都是源于异常代码块的处理方式,在代码中try代码块就标志着运行时会有一个Throwale线程监视着该方法的运行,若出现异常,则交由异常逻辑处理。

      我们知道方法是在栈内存中运行的,并且会按照“ 先进后出 ”的原则执行,main方法调用了doStuff方法,则main方法在下层,doStuff方法在上层,当doStuff方法执行完" return a " 时,此方法的返回值已经确定int类型1(a变量的值,注意基本类型都是拷贝值,而不是引用),此时finally代码块再修改a的值已经与doStuff返回者没有任何关系了,因此该方法永远都会返回1.

      继续追问,那是不是可以在finally代码块中修改引用类型的属性以达到修改返回值的效果呢?代码如下: 

    class Person {
        private String name;
    
        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
    }
    public static Person doStuffw() {
            Person person = new Person();
            person.setName("张三");
            try {
                return person;
            } catch (Exception e) {    
    
            } finally {
                // 重新修改一下值
                person.setName("李四");
            }
            person.setName("王五");
            return person;
        }

      此方法的返回值永远都是name为李四的Person对象,原因是Person是一个引用对象,在try代码块中的返回值是Person对象的地址,finally中再修改那当然会是李四了。

    (2)、屏蔽异常

      为什么明明把异常throw出去了,但main方法却捕捉不到呢?这是因为异常线程在监视到有异常发生时,就会登记当前的异常类型为DataFormatException,但是当执行器执行finally代码块时,则会重新为doStuff方法赋值,也就是告诉调用者" 该方法执行正确,没有产生异常,返回值为1 ",于是乎,异常神奇的消失了,其简化代码如下所示:  

        public static void doSomeThing(){
            try{
                //正常抛出异常
                throw new RuntimeException();
            }finally{
                //告诉JVM:该方法正常返回
                return;
            }
        }
    public static void main(String[] args) {
            try {
                doSomeThing();
            } catch (RuntimeException e) {
                System.out.println("这里是永远不会到达的");
            }
        }
        

      上面finally代码块中的return已经告诉JVM:doSomething方法正常执行结束,没有异常,所以main方法就不可能获得任何异常信息了。这样的代码会使可读性大大降低,读者很难理解作者的意图,增加了修改的难度。

      在finally中处理return返回值,代码看上去很完美,都符合逻辑,但是执行起来就会产生逻辑错误,最重要的一点是finally是用来做异常的收尾处理的,一旦加上了return语句就会让程序的复杂度徒然上升,而且会产生一些隐蔽性非常高的错误。

      与return语句相似,System.exit(0)或RunTime.getRunTime().exit(0)出现在异常代码块中也会产生非常多的错误假象,增加代码的复杂性,大家有兴趣可以自行研究一下。

      注意:不要在finally代码块中出现return语句。

  • 相关阅读:
    20135213 20135231 信息安全系统设计基础课程第二次实验报告
    20135213——信息安全系统设计基础第十二周学习总结
    20135213——信息安全系统设计基础第十一周学习总结
    20135220谈愈敏Blog5_系统调用(下)
    20135220谈愈敏Linux Book_5
    20135220谈愈敏Blog4_系统调用(上)
    20135220谈愈敏Linux Book_1&2
    20135220谈愈敏Blog3_构造一个简单的Linux系统MenuOS
    20135220谈愈敏Blog2_操作系统是如何工作的
    20135220谈愈敏Blog1_计算机是如何工作的
  • 原文地址:https://www.cnblogs.com/selene/p/5945357.html
Copyright © 2011-2022 走看看