软件设计原则之美-开闭原则

1 定义

来个需求就改一次代码,似乎都习惯了,甚至觉得理所当然。反正修改也容易,只要按之前的代码再CV一份,不费脑子。但每人每次改一点点,日积月累,再来个新需求,后人改动量就很大了。每个人都很无辜,都只是简单修改一点点。但最终导致伤害后来接盘侠,代码已无法维护,直接推翻老系统,写新系统了。

既然“修改”会带来这么多问题,那可以不修改吗?开放封闭原则就是一种值得努力的方向。

​Software entities like classes,modules and functions should be open for extension but closed for modifications​​ 一个软件实体如类、模块和方法应该对扩展开放,对修改关闭。

这是Bertrand Meyer在其著作《面向对象软件构造》(Object-Oriented Software Construction)中提出,它给软件设计提出了一个极高要求:不修改代码。对扩展开放?开放什么?对修改关闭,怎么关闭?

不修改代码,那怎么实现新需求?

扩展,即新需求用新代码实现。开放封闭原则向我们描述的是个结果:可不修改代码,而仅靠扩展就完成新功能。这前提是要在软件内部留好扩展点,这就是需要设计的地方。每个扩展点都是一个需要设计的模型。

用抽象构建框架,用实现扩展细节

一个软件实体应该通过扩展来实现变化,而不是通过修改已有代码来实现变化。它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

2 案例

2.1 书籍

书籍接口

软件设计原则之美-开闭原则

Java书籍实现类

软件设计原则之美-开闭原则

测试类

软件设计原则之美-开闭原则

现在想添加一个折扣优惠方法:若直接修改原接口,则每个实现类都得重新添加方法实现。

但接口应该是稳定的,不应频繁修改!

Java 书籍折扣类

软件设计原则之美-开闭原则

现在 UML

软件设计原则之美-开闭原则

​接口应该是稳定且可靠的,不应该经常发生变化​​,否则接口作为契约的作用就失去了效能。

修改实现类

直接在getPrice()中实现打折处理,大家应该经常这样,通过class文件替换的方式即可完成部分业务变化(或缺陷修复)。

该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷:例如采购书籍人员也要看价格,由于该方法已实现打折处理价格,因此采购人员看到的也是折后价,会​​因信息不对称而出现决策失误​​的情况。因此,这也不是最优方案。

通过扩展实现变化

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

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

变化的类型

逻辑变化

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

子模块变化

一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的。

可见视图变化

可见视图是提供给客户使用的界面,如Swing。若仅是按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化。

所以放弃修改历史的想法吧,一个项目的基本路径:项目开发、重构、测试、投产、运维。其中的重构可对原有设计和代码进行修改,运维尽量减少对原有代码的修改,保持历史代码的纯洁性,提高系统的稳定性。

会员案例

开发一个酒店预订系统,针对不同的用户,计算出不同房价。比如:

  • 普通用户是全价
  • 金卡是8折
  • 银卡是9折

代码可能如下:

软件设计原则之美-开闭原则

来新需求,增加白金卡会员,给出75折,一脉相承写法:

软件设计原则之美-开闭原则

这就是修改代码的做法,每增加一个新类型,就修改次代码。但一个有各种级别用户的酒店系统肯定不只是房价不同,提供的服务也可能有区别,比如是否有早餐?预付还是现付?优惠券力度、连住优惠价格?。可以预见,每增加一个用户级别,要改的代码散布各地。

该怎么办呢?

应该考虑如何把它设计成一个可扩展模型。既然每次要增加的是用户级别,而且各种服务差异都体现在用户级别,就需要一个用户级别模型。前面代码,用户级别只是个简单枚举,丰富一下:

软件设计原则之美-开闭原则

原代码即可重构成:

软件设计原则之美-开闭原则

这样一来,再增加铂金用户,只需新写一个类:

软件设计原则之美-开闭原则

可以这么做,是因为在代码里留好了扩展点:UserLevel:把原来只支持枚举值的UserLevel,升级成了一个有行为的UserLevel。

改造后,HotelService的getRoomPrice方法就稳定了,无需根据用户级别不断地调整。一旦有稳定的构造块,就能在后续将其当做一个稳定模块复用。

构建扩展点

其实我们修改代码效果不佳,但真到自己写代码了,就晕了。若问你,你开发的系统有问题吗?相信大部人都会不假思索地说有。但又问:你会经常主动优化它吗?大部人却又开始沉默了。它虽然垃圾,但在线上运行得好好的,万一我一优化,优化坏了咋办,今年绩效可就 3.25 了呀。 你看,现实就是这样 ,系统宏观层面人人都懂,而在代码落地层,却总是习惯忽视。

所以,写软件系统,就应该提供一个个稳定小模块,然后,将它们组合。一个经常变动的模块是不稳定的,用它去构造更大模块,必后患无穷。

为什么我们这一懂了很多大道理,却依旧写不好代码呢?

阻碍我们构造稳定模块的,是构建模型的能力。回想产生变化的UserLevel,是如何升级成一个有行为的UserLevel的。

封装的要点是行为,数据只是实现细节,而很多人习惯性面向数据写法,这也是导致很多人设计缺乏扩展性。

构建模型的难点:

  1. 分离关注点
  2. 找到共性

要构建起抽象就要找到事物的共同点,业务处理过程发现共性对大部分人就已经开始有难度了。

我们再来看个例子

报表服务

软件设计原则之美-开闭原则

相信很多日常写代码就是这种风格,这个代码流程肯定是特别僵化。 只要出现新需求,基本都需要调整这段。 现在就来了个新需求:把统计信息发给另外一个内部系统,该内部系统可将统计信息展示出来,供外部合作伙伴查阅。

分析

发给另一个系统的内容是统计信息。原代码里:

  • 前两步是获取源数据,生成统计信息
  • 后两步生成报表,将统计信息通过邮件发出去

后两步和即将添加的步骤有个共同点,都使用了统计信息。所以,就可以用一个共同模型去涵盖它们,如OrderStatisticsConsumer:

软件设计原则之美-开闭原则

这样,新需求也只需添加一个新类,而非 if/else:

软件设计原则之美-开闭原则

该案例中,第一步要做的还是分解:把一个个步骤分开,然后找出步骤间相似点,并构建一个新模型。

实际项目代码可能比这复杂,但并非一定是业务逻辑复杂,而是代码写得就复杂。所以,要先根据单一职责原则,将不同需求来源引起的变动拆分到不同方法,形成一个个小单元,再做这里的分析。

实际项目中,要达到开放封闭原则也并非一朝一夕。这里只是因为有需求变动,才提取出一个OrderStatisticsConsumer。

未来可能还有其它变动,如生成报表的逻辑。那时,也许还会提取一个新OrderStatisticsGenerator的接口。但不管怎样,每做一次这种模型构建,最核心的类就会朝着稳定发展。

好的设计都会提供足够扩展点给新功能去扩展。《Unix 编程艺术》就提倡“提供机制,而非策略”,这就体现了开放封闭原则。

很多系统有插件机制,如IDEA和VS Code,都体现开放封闭原则。去了解它们的接口,即可看到这个软件给我们提供的各种能力。

开放封闭原则还可帮助我们优化系统,可通过查看Git,找出那些最经常变动的文件,它们通常都没有满足开放封闭原则,这就可以成为你系统优化的起航点。

为什么选择开闭原则

开闭原则对测试的影响

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

以上面提到的书店售书为例,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());
}
}

若加个打折销售需求,直接修改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());
}
}

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

提高复用性

OOP中,所有逻辑都是从原子逻辑组合而来,而非在一个类中独立实现一个业务逻辑。只有这样代码才可复用,粒度越小,被复用可能性越大。

  • 为什么要复用?减少代码量,避免相同逻辑分散,避免后来的维护人员为修改一个小bug或加个新功能而在整个项目中到处查找相关代码,然后发出对开发人员“极度失望”的感慨。
  • 如何才能提高复用率?缩小逻辑粒度,直到一个逻辑不可再拆分为止。

提高可维护性

一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而非修改一个类,甭管原有代码写得好坏,让维护人员读懂原有代码,然后再修改,是炼狱!不要让他在原有代码海洋里瞎游完毕后再修改,那是对维护人员的摧残。

OOP

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

  • 优点提高软件系统的可复用性及可维护性

总结

若说单一职责原则主要看封装,开放封闭原则就必须有多态参与。要想提供扩展点,就需面向接口编程。

java的SPI给开发者提供了不错的扩展机制,像spring boot 和dubbo就在此基础上做了改进,各自提供了扩展点,spring boot允许用户自定义starter,dubbo可以自定义协议等

发表评论

相关文章