首页 > 软件设计原则实战(二) - 开闭原则
头像
牛客7380095号
编辑于 2019-01-14 22:45
+ 关注

软件设计原则实战(二) - 开闭原则

0 联系我

1.Q群【Java开发技术交流】:https://jq.qq.com/?_wv=1027&k=5UB4P1T
2.简书博客:www.shishusheng.com
3.知乎:http://www.zhihu.com/people/shi-shu-sheng-
4.微信公众号:JavaEdge
5.Github:https://github.com/Wasabi1234

1 开闭原则的定义

开闭原则是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统,先来看开闭原则的定义:
Software entities like classes,modules and functions should be open for extension but closed for modifications
定义

初看到这个定义,可能会很迷惑,对扩展开放?开放什么?对修改关闭,怎么关闭?没关系,我会一步一步带领大家解开这些疑惑。

我们做一件事情,或者选择一个方向,一般需要经历三个步骤:What——是什么,Why——为什么,How——怎么做(简称3W原则,How取最后一个w)。对于开闭原则,我们也采用这三步来分析,即什么是开闭原则,为什么要使用开闭原则,怎么使用开闭原则。

2 开闭原则的庐山真面目

定义已经非常明确地告诉我们:软件实体应该对扩展开放,对修改关闭
其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化

那什么又是软件实体呢?软件实体包括以下部分
● 项目或软件产品中按照一定的逻辑规则划分的模块
● 抽象和类
● 方法

一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。
开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

3 实例

  • 课程接口

  • Java 课程实现类

  • 测试类

    原UML 图
    现在想添加一个折扣优惠方法,如果直接修改放在接口,那么每个类都得重新添加方法实现,如下


    然而接口应该是稳定的,不该经常性的修改

  • Java 课程折扣类

    添加折扣类后的UML 图


假如想通过添加方法获取原价

debug发现其真正类型,由于是父类引用,无法直接获得原价方法,需强转

4 书店销售书籍例

书店售书类图

  • 小说类NovelBook是一个具体的实现类,是所有小说书籍的总称

  • BookStore指的是书店

    public interface IBook {   
       //书籍名称
       public String getName();
       //书籍售价
       public int getPrice();
       //书籍作者
       public String getAuthor();
    }
  • 目前书店只出售小说类书籍

    public class NovelBook implements IBook {
       private String name;       
       private int price; 
       private String author;             
       public NovelBook(String _name,int _price,String _author){
               this.name = _name;
               this.price = _price;
               this.author = _author;
       }  
       public String getAuthor() {
               return this.author;
       }
       public String getName() {
               return this.name;
       }
       public int getPrice() {
               return this.price;
       }
    }

    把价格定义为int类型并不是错误,在非金融类项目中对货币处理时,一般取2位精度,通常的设计方法是在运算过程中扩大100倍,在需要展示时再缩小100倍,减少精度带来的误差。

  • 书店售书类

    public class BookStore {
       private  static final ArrayList bookList = new ArrayList();
       static{
               bookList.add(new NovelBook("天龙八部",3200,"金庸"));
               bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
               bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
               bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
       }
       //模拟买书
       public static void main(String[] args) {
               NumberFormat formatter = NumberFormat.getCurrencyInstance();
               formatter.setMaximumFractionDigits(2);
               System.out.println("-----------书店卖出去的书籍记录如下:-----------");
               for(IBook book:bookList){
                       System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" +
          book.getAuthor()+"\t书籍价格:"+ formatter.format (book.getPrice()/
          100.0)+"元");
               }
       }
    }

    在BookStore中声明了一个静态模块,实现了数据的初始化

    书店为了生存开始打折销售:所有40元以上的书籍9折销售,其他的8折销售
    对已经投产的项目来说,这就是一个变化,我们应该如何应对这样一个需求变化?
    有如下三种方法可以解决这个问题:

● 修改接口
在IBook上新增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现该方法
但是这样修改,实现类NovelBook要修改,BookStore中的main方法也修改
同时IBook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能。因此,该方案否定。

● 修改实现类
修改NovelBook类中的方法,直接在getPrice()中实现打折处理,好办法,我相信大家在项目中经常使用的就是这样的办法,通过class文件替换的方式可以完成部分业务变化(或是缺陷修复)。
该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的。
例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对称而出现决策失误的情况。因此,该方案也不是一个最优的方案。

● 通过扩展实现变化
增加一个子类OffNovelBook,覆写getPrice方法,高层次的模块(static静态模块区)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发。好办法,修改也少,风险也小
扩展后的书店售书类图

  • OffNovelBook类继承了NovelBook,并覆写了getPrice方法,不修改原有的代码```
    public class OffNovelBook extends NovelBook {
    public OffNovelBook(String _name,int _price,String _author){
           super(_name,_price,_author);
    }
    //覆写销售价格 @Override public int getPrice(){
           //原价
           int selfPrice = super.getPrice();
           int offPrice=0;
           if(selfPrice>4000){  //原价大于40元,则打9折
                   offPrice = selfPrice * 90 /100;
           }else{
                   offPrice = selfPrice * 80 /100;
           }
           return offPrice;
    }
    }

书店类BookStore需要依赖子类,代码稍作修改

public class BookStore {
     private final static ArrayList bookList = new ArrayList();
     //static静态模块初始化数据,实际项目中一般是由持久层完成
     static{
             bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
             bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
             bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
             bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
     }  
     //模拟书店买书
     public static void main(String[] args) {
             NumberFormat formatter = NumberFormat.getCurrencyInstance();
             formatter.setMaximumFractionDigits(2);
             System.out.println("-----------书店卖出去的书籍记录如下:-----------");
             for(IBook book:bookList){
                      System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + book.getAuthor()+ "\t书籍价格:" + formatter.format (book.getPrice()/100.0)+"元");
             }
     }
}

我们只修改了粗体部分,其他的部分没有任何改动,运行结果如下所示。

打折销售开发完成了。看到这里,各位可能有想法了:增加了一个OffNoveBook类后,你的业务逻辑还是修改了,你修改了static静态模块区域。
这部分确实修改了,该部分属于高层次的模块,是由持久层产生的,在业务规则改变的情况下高层模块必须有部分改变以适应新业务,改变要尽量地少,防止变化风险的扩散。

注意 开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。

我们可以把变化归纳为以下三种类型:

● 逻辑变化
只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是ab+c,现在需要修改为ab*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。

● 子模块变化
一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的,刚刚的书籍打折处理就是类似的处理模块,该部分的变化甚至会引起界面的变化。

● 可见视图变化
可见视图是提供给客户使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引起连锁反应(特别是在国内做项目,做欧美的外包项目一般不会影响太大)。如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。

回顾一下书店销售书籍的程序,首先是我们有一个还算灵活的设计(不灵活是什么样子?BookStore中所有使用到IBook的地方全部修改为实现类,然后再扩展一个ComputerBook书籍,你就知道什么是不灵活了)
然后有一个需求变化,我们通过扩展一个子类拥抱了变化
最后把子类投入运行环境中,新逻辑正式投产。
通过分析,我们发现并没有修改原有的模块代码,IBook接口没有改变,NovelBook类没有改变,这属于已有的业务代码,我们保持了历史的纯洁性。
放弃修改历史的想法吧,一个项目的基本路径应该是这样的:项目开发、重构、测试、投产、运维,其中的重构可以对原有的设计和代码进行修改,运维尽量减少对原有代码的修改,保持历史代码的纯洁性,提高系统的稳定性。

5 为什么要采用开闭原则

依照Java语言的称谓,开闭原则是抽象类,其他五大原则是具体的实现类,开闭原则在面向对象设计领域中的地位就类似于牛顿第一定律在力学、勾股定律在几何学、质能方程在狭义相对论中的地位,其地位无人能及。

5.1 开闭原则对测试的影响

有变化提出时,我们就需要考虑一下,原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?
否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试甚至是验收测试

以上面提到的书店售书为例,IBook接口写完了,实现类NovelBook也写好了,我们需要写一个测试类进行测试,测试类如代码

public class NovelBookTest extends TestCase {
     private String name = "平凡的世界";
     private int price = 6000;
     private String author = "路遥";      
     private IBook novelBook = new NovelBook(name,price,author);
     //测试getPrice方法
     public void testGetPrice() {
             //原价销售,根据输入和输出的值是否相等进行断言
             super.assertEquals(this.price, this.novelBook.getPrice());
     }
}

在单元测试中,有一句非常有名的话,叫做"Keep the bar green to keep the code clean",即保持绿条有利于代码整洁,这是什么意思呢?绿条就是Junit运行的两种结果中的一种:要么是红条,单元测试失败;要么是绿条,单元测试通过。一个方法的测试方法一般不少于3种,为什么呢?首先是正常的业务逻辑要保证测试到,其次是边界条件要测试到,然后是异常要测试到,比较重要的方法的测试方法甚至有十多种,而且单元测试是对类的测试,类中的方法耦合是允许的,在这样的条件下,如果再想着通过修改一个方法或多个方法代码来完成变化,基本上就是痴人说梦,该类的所有测试方法都要重构,想象一下你在一堆你并不熟悉的代码中进行重构时的感觉吧!

在书店售书的例子中,增加了一个打折销售的需求,如果我们直接修改getPrice方法来实现业务需求的变化,那就要修改单元测试类。想想看,我们举的这个例子是非常简单的,如果是一个复杂的逻辑,你的测试类就要修改得面目全非。还有,在实际的项目中,一个类一般只有一个测试类,其中可以有很多的测试方法,在一堆本来就很复杂的断言中进行大量修改,难免会出现测试遗漏情况,这是项目经理很难容忍的事情。

所以,我们需要通过扩展来实现业务逻辑的变化,而不是修改。上面的例子中通过增加一个子类OffNovelBook来完成了业务需求的变化,这对测试有什么好处呢?我们重新生成一个测试文件OffNovelBookTest,然后对getPrice进行测试,单元测试是孤立测试,只要保证我提供的方法正确就成了,其他的我不管

public class OffNovelBookTest extends TestCase {   
     private IBook below40NovelBook = new OffNovelBook("平凡的世界",3000,"路遥");
     private IBook above40NovelBook = new OffNovelBook("平凡的世界",6000,"路遥");      
     //测试低于40元的数据是否是打8折
     public void testGetPriceBelow40() {
             super.assertEquals(2400, this.below40NovelBook.getPrice());
     }  
     //测试大于40的书籍是否是打9折
     public void testGetPriceAbove40(){
             super.assertEquals(5400, this.above40NovelBook.getPrice());
     }
}

新增加的类,新增加的测试方法,只要保证新增加类是正确的就可以了。

5.2 开闭原则可以提高复用性

在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码,然后发出对开发人员“极度失望”的感慨。那怎么才能提高复用率呢?缩小逻辑粒度,直到一个逻辑不可再拆分为止。

5.3 开闭原则可以提高可维护性

一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情,不要让他在原有的代码海洋里游弋完毕后再修改,那是对维护人员的一种折磨和摧残。

5.4 面向对象开发的要求

万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略去应对,怎么快速应对呢?这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。

全部评论

(0) 回帖
加载中...
话题 回帖