设计模式原则(6)开闭原则

定义

开闭原则(OCP,Open Closed Principle):作为系统开发中最基础的设计原则。对各大设计原则起着领袖的作用,它指导着我们设计实现稳定而灵活的系统。
原文定义:Software entities like classs,modules and functions should be open for extension but closed for
modifications;软件实体(类、模块和方法)应该对扩展开放,对修改关闭。

一、什么是开闭原则?

开闭原则主要应对的是需求的变更。根据OCP,一个软件实体应该通过扩展而不是修改原有代码实现变更。
何为软件实体?其包括如下几个部分
1、软件项目中按一定业务粒度划分的模块;软件模块。
2、抽象和类
3、方法

在实际项目开发中往往会因为各种原因引起软件项目的变更,需求的变更可能是PM和开发人员都较为头疼的一件事情了。而合理的架构设计可以大大削减变更成本,这直接考验着架构师对软件开发的认知和团队的协作能力。通过OCP,我们可以拥抱变化。

示例:(餐馆售出各种菜式,假设我们的餐馆主营火锅,这里主要关注火锅名字和价格。HotPot类提供基本的数据模型操作,Restaurant依赖HotPot为上层模块提供相关服务,比如showMealPrice方法提供HotPot价格展示的功能),代码如下:

interface IMeal {
     public String getName();
     public BigDecimal getPrice();
}
//hot pot
class HotPot implements IMeal {
     private String mealName;
     private BigDecimal mealPrice;
     HotPot() {
     }
     // set MV by construction
     HotPot(String name, BigDecimal price) {
          this.mealName = name;
          this.mealPrice = price;
     }
     @Override
     public String getName() {
          return mealName;
     }
     @Override
     public BigDecimal getPrice() {
          return mealPrice;
     }
}
class Restaurant {
     // the meal list
     private List<IMeal> mealList;
     public Restaurant() {
     }
     public Restaurant(List<IMeal> mealList) {
          this.mealList = mealList;
     }
     public void showMealPrice() {
          for (IMeal iMeal : mealList) {
              System.out.println("name:" + iMeal.getName() + " price:" + iMeal.getPrice());
          }
     }
}

高层模块代码如下:(mock data 代码模拟Dao从数据库提取数据)

public class Client {
     public static void main(String[] args) {
          // mock data to meal list
          List<IMeal> mealList = new ArrayList<IMeal>();
          mealList.add(new HotPot("h1", new BigDecimal("99.99")));
          mealList.add(new HotPot("h2", new BigDecimal("333.33")));
          mealList.add(new HotPot("h3", new BigDecimal("888.88")));
          // initial restaurant
          Restaurant restaurant = new Restaurant(mealList);
          restaurant.showMealPrice();
     }
}

以上算是一个最简单的代码示例。
我们可以看到在Restaurant中直接依赖IMeal抽象接口,而不是具体的类,为后续的解耦提供了基本条件。同样的可以看到Restaurant并没有进一步的公共抽象设计,这会为后续的扩展带来一定的麻烦。
以上代码中,进行价格展示是没有问题的。

但社会的大环境总是会变化的,就比如现在正在发生的冠状病毒疫情对餐饮的冲击还是挺大的;我们的餐馆打个九折吧!

那怎么实现这种需求的变更呢?以下有三种可行但不一定适用的方法:

1、修改接口
IMeal接口新增一个getDiscountPrice方法。其破坏了接口作为契约的稳定性,很容易导致变更风险扩散。Pass

2、修改实现类
直接修改实现类中getPrice方法。这可能是比较常用的方法了,简单、直接,太暴力。在一个高度协调且成熟的开发团队里采用这种方法或许可以取得不错的效果,但大部分的开发团队都无法达到这种水平;这是技术和管理的高度协调。另一方面,这种方法会带来一个弊端,假如采购人员需要查看原价进而去预估成本的时候该怎么办呢?毕竟直接修改展示的是打折之后的价格了。Pass

3、通过扩展实现变化
新增一个DiscountHotPot继承IMeal接口进而重写getPrice。Restaurant菜单展示的高层模块中只需要通过新增的DiscountHotPot生成新的对象即可达到目标,并且在此也不会影响原有的模块。采用此方法。

新增DiscountHotPot类代码如下:(实际上如果有多次售价变动而增加了多个类似的类时还会导致代码冗余问题,此时可以使用抽象类对这些IMeal实现类进行二级封装,即在IMeal与各实现类间增加一个抽象类封装)

//discount
class DiscountHotPot implements IMeal {
     private String mealName;
     private BigDecimal mealPrice;
     DiscountHotPot() {
     }
     // set MV by construction
     DiscountHotPot(String name, BigDecimal price) {
          this.mealName = name;
          this.mealPrice = price;
     }
     @Override
     public String getName() {
          return mealName;
     }
     @Override
     public BigDecimal getPrice() {
          // for discount
          return mealPrice.multiply(new BigDecimal("0.9"));
     }
}

客户端修改代码如下:(仅改变了实例化的类,此处属于数据持久层Dao,为高层模块)

public class Client {
     public static void main(String[] args) {         
          // mock data to meal list
          List<IMeal> mealList = new ArrayList<IMeal>();
          mealList.add(new DiscountHotPot("h1", new BigDecimal("99.99")));
          mealList.add(new DiscountHotPot("h2", new BigDecimal("333.33")));
          mealList.add(new DiscountHotPot("h3", new BigDecimal("888.88")));
          // initial restaurant
          Restaurant restaurant = new Restaurant(mealList);
          restaurant.showMealPrice();
     }
}

其他代码不变。在此新增一个类的同时也导致了Client的修改(虽然修改的范围较小)。反过来想一想,如果不是提供了IMeal接口,所有服务的提供利用具体实现类,可以看看需要修改多少代码;是不是有点似曾相识,这不就是LSP吗。我们知道,底层模块的修改必然随着高层模块的耦合变更,否则底层模块就是一个孤立无意义代码段而已

何为变更?我们可以把变更总结为以下三种类型

1、逻辑变化
只变化一个逻辑而不涉及其他模块,就比如需要将a+b的计算逻辑变更为a*b,这可以通过修改原有类来完成;也就是利用上述需求变更方法2修改实现类方式。当然这种方法的前提条件是所有依赖或关联类都按照相同的逻辑处理;牵一发而动全身,使用场景并不多。

2、子模块变化
通常,一个模块的变化会对其他模块造成影响;特别是底层模块的变化往往直接引起高层模块的改变。就像上述的餐馆打折销售。

3、可见视图变化
可见视图也就是直接和用户交互的界面,如web界面、app界面。单纯的界面变化倒是可以将变更控制在前端有限的范围内,而某些业务耦合的变化往往涉及到后端相关业务逻辑的变更。比较极端的一种示例是:在进行前端数据展示时,客户突然提出需要在原有报表界面上添加一个新的指标,而这个指标需要跨越多张表并经过复杂的计算才能得到。
针对业务耦合引起的变化,我们基于一个灵活的代码设计可以更好的适应;一个灵活的设计可以实现真正的拥抱变化。就像上面的代码示例中,我们针对菜品提供了IMeal接口,哪怕面对变化,还是可以灵活的遵循OCP进行处理。而Restaurant却并没有做进一步的抽象提取,如果是针对外卖平台开发的话,那这个Restaurant的设计是不太合适的。

二、为什么要使用开闭原则?

很简单的一个问题:你更愿意参与陌生系统的二次开发,还是自己从头到尾参与的系统研发?或者说在大型项目中你更愿意选择通过修改实现类实现变更还是通过扩展实现变化?(上述代码只是最简单的业务逻辑,可以试着去回想在复杂业务逻辑中排错的场景)
OCP是基于前五大原则的一个抽象,是前五大原则的精神领袖;OCP对于前五大原则,就像是牛顿第一定律在力学中的地位。我们可以简单理解为:前五大原则是OCP的实现类

OCP为何如此重要,现通过以下几个方面来理解

1、开闭原则对测试的影响
业务代码的变更往往意味着测试代码的变更。就比如说在单元测试中,我们需要对上述新增的打折类DiscountHotPot进行测试,那我们可能只需要新增一个测试实现类即可,无需过多考虑历史测试代码和用例的影响;测试保证新增加的类符合业务预期即可。而如果变更基于原有实现类进行。那原有的测试类该如何处理呢?也跟着变化吗?测试用例呢?历史是难以修改的!

2、开闭原则可以提高复用性
这里主要基于业务粒度的划分。我们知道,在面向对象设计中,一定范围内业务逻辑划分越细致、粒度越小,代码复用的可能性就越大;那这里就涉及到前五大原则中的SRP和ISP了。若是有着明晰恰当的粒度划分,我们在进行变更时就可以更快的定位变更代码,进而采取合适的变更策略,减少了变更的成本

3、开闭原则可以提高可维护性
这又回到了上面的问题。修改还是扩展?对于投产后的系统,维护人员往往会趋向于通过扩展而不是修改类去实现变更。毕竟你我都知道:在庞大复杂的业务代码中,读懂别人的代码进而去修改是一件很痛苦的事情,还不如干脆一点自己重写一个新的功能类呢。

4、面向对象开发的要求
我们知道万物皆是运动的,而在面向对象的世界里,万物皆对象。如此一来,变化成了对象的一个特性。在开发中我们针对对象的抽象,也就是类进行变动也就成了很常见的事情。进而在面向对象的开发思想下,我们的系统想要更贴近现实的运用,就必然要做到拥抱变化。而在OCP的开发指引下,我们在系统设计之初就在一定程度上考虑到了未来可能会有的变更,进而可以留下接口,为日后的变更提供灵活的支持。

三、如何使用开闭原则?

综合上述可知,OCP本身其实是一个非常抽象的概念。作为其他五条原则的指导性原则,OCP略显缥缈虚无。总体来说它还是要基于前五大原则进行进一步的具体运用,更像是作为前五大原则的超类或抽象接口,起着契约的作用

那如何将将这种看起来虚无缥缈的契约运用到实际的研发中呢?
以下有几条可行的方法。

1、抽象约束
这条相信不言而喻了。作为扩展的前提条件,抽象约束以抽象类或接口的形式将一组事物的抽象共性(接口或抽象类)进行高度提取。由于没有具体的实现,表明着这个提取出来的共性有着许多的实现可能性。同样的,通过这种共性的提取可以对一组可能变化的行为进行约束,并实现对扩展开放。原则上在实现类中不允许出现共性之外的public方法,同时共性作为一种契约而不允许轻易变动,其可以对扩展的边界进行约束。同样的,使用共性作为参数类型可以获得更好的设计灵活性,进而实现扩展兼容(运用OOD的多态),就比如上面在可见视图变化中关于IMeal接口的设计简述。

2、使用元数据(metadata)控制模块行为
何为元数据?似乎针对不同的方向有着不同的定义,在数据开发中,个人习惯将元数据表述为描述数据的数据。而在系统设计中可能表述为描述环境和数据的数据,具体体现为配置参数。配置参数的来源有很多,如文本(XML)、数据库。记得在spring容器中使用配置文件作为依赖注入(IOC),其实就是使用这种方式了。

3、制定项目管理
这里越来越趋近于软件工程思想了。在一个成熟的开发团队中,约定俗成的章程是必备的,从每个人负责的模块到方法变量的命名,变更的流程等等都有章可循。约定优于配置

4、封装变化
也就是将相同的变化封装到同一个抽象共性中,而不同的变化封装到不同的抽象共性中;不应该有不同的变化出现在同一个抽象共性里面。为此我们需要在设计中做大量的工作以充分认知我们将要开发的项目,尽可能多地预判到在未来可能出现的变化,进而将这些可能出现的变化进行封装。常见的23种设计模式正是从不同的角度对预计的变化进行封装。

四、最佳实践

纵观软件生命周期的全过程,可能最难应对的就是项目变更,不可预见的变更。恐怕再如何优秀的架构师、项目经理都难于尽数预见所有的变更吧!所幸软件危机从“没有银弹”以来,我们的软件工程技术越趋成熟。通过日积月累的实践探索,前辈大师们给我们总结出了6大基本设计原则和23种常见设计模式封装未来的变化,让我们得以站在巨人的肩膀上看这个世界。

通过一下六条设计原则的学习:
1、单一职责(SRP)
2、里氏替换原则(LSP)
3、依赖倒置原则(DIP)
4、接口隔离原则(ISP)
5、迪米特法则(LoD)
6、开闭原则(OCP)

我们初步了解如何去设计一个稳定的(SOLID)、健壮的系统。同时我们要知道OCP作为前五大原则的高度抽象、风向标,是最基础的原则

在使用OCP时我们要注意以下几个问题(不如说是遵循所有的原则我们都要注意的问题)
1、原则也仅仅是原则
原则是服务于项目开发的,而拥抱变化,实现项目稳定性、健壮性可以有很多种方法,要说软件设计原则何止这6种呢。只是就目前而言,利用这6大基本原则就可以应对大多数的变化了。就像前面说的:原则是可以遵循的,但绝对不是严格执行的。我们需要根据具体的情况分析作出最合适的策略选择

2、重视项目规章
这里的项目规章绝对不是刻板的条条框框,而是在实践中根据团队、项目特点而总结出来的约定。如此首先就需要有稳定的团队成员,进而才能建立高效的团队协作文化。好的团队与个人之间一定是相互成就的

3、预知变化
这是一件很难的事情,很多时候需要架构师、项目经理有着丰富的经验和敏锐的观察分析能力。就比如曾经发生的变化是否会在当前这个项目中发生呢?这个从未接触过的领域模块是否需要留下变更的接口呢?这个世界唯一的不变就是变化,架构设计优良才能有更多的底气和信心去拥抱变化。拥抱变化绝对不仅仅是口号,背后需要的是坚持不懈、常年累月的探索,和学习

再如何优秀的架构师、项目经理都难于尽数预见所有的变更。但我们朝着OCP这个目标前行依旧可以设计出良好的架构!最终实现拥抱变化!

参考文献
秦小波《设计模式之禅 》第二版

发布了132 篇原创文章 · 获赞 41 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_34901049/article/details/104206276