设计原则之【开放封闭原则】
设计原则是指导我们代码设计的一些经验总结,也就是“心法”;面向对象就是我们的“武器”;设计模式就是“招式”。
以心法为基础,以武器运用招式应对复杂的编程问题。
来吧,通过生活中一个小场景,一起系统学习这6大设计原则。
表妹今天上班又忘记打卡了
表妹:哥啊,我真的是一点记性都没有
我:发生什么事啦?
表妹:今天上班又忘记打卡了,又是白打工的一天,做什么事都提不起劲来。
你看,传统的上下班打卡制,这种模式将按时上下班作为考核指标之一,虽然强化了企业的管理,但是却限制了员工的时间自由,每个员工的情况和工作状态都不同,强制的上班时间容易导致员工为了应付打卡而打卡,实则工作效率却不高。
按时上下班其实不是老板希望达到的目的,老板希望的是,所有员工的绩效达标,最终企业能够盈利,而上下班打卡制只不过是为了达到这一目标的其中一个方法而已。
明确了将绩效作为考核指标。那么,绩效至少达标,这个是不可以修改的,在这个基础上,员工的上下班时间是可以自由安排的,这样就可以提高员工的生产效率了。这就是弹性上班制,对业绩成效的修改关闭,而对时间制度扩展的开放。
你看,这不就是我们软件开发中的开放-封闭原则嘛。
是说软件实体(类、模块、函数等)应该可以扩展,但是不可以修改。
这是一条最难理解和掌握,但是又最有用的设计原则。
之所以说难理解,是因为,“怎样的代码改动才被定义为扩展?怎样的代码改动才被定义为修改?怎样才算满足开闭原则?修改代码就一定违反了开闭原则吗?”等问题。
之所以说难掌握,是因为,“如何做到对扩展开放,修改封闭?,如何在项目中灵活地应用开闭原则,在保证扩展性的同时又不影响代码的可读性?”等问题。
之所以说最有用,是因为,扩展性是代码质量最重要的衡量标准之一。在23种经典设计模式之中,大部分设计模式都是为了解决代码的扩展性问题而存在的。
如何理解“对扩展开放、修改关闭”?
比如,书店销售图书。
图书有三个属性:书名、价格和作者。IBook是获取图书三个属性的接口,如下所示:
1 public interface IBook { 2 // 图书的名称 3 public String getName(); 4 5 // 图书的售价 6 public int getPrice(); 7 8 // 图书的作者 9 public String getAuthor(); 10 }
小说类图书NovelBook是一个具体的实现类,如下所示:
1 public class NovelBook implements IBook { 2 // 图书的名称 3 private String name; 4 5 // 图书的价格 6 private int price; 7 8 // 图书的作者 9 private String author; 10 11 // 通过构造函数传递书籍数据 12 public NovelBook(String _name,int _price,String _author){ 13 this.name = _name; 14 this.price = _price; 15 this.author = _author; 16 } 17 18 // 获得作者是谁 19 public String getAuthor() { 20 return this.author; 21 } 22 23 // 获得书名 24 public String getName() { 25 return this.name; 26 } 27 28 // 获得图书的价格 29 public int getPrice() { 30 return this.price; 31 } 32 }
接下来,我们看一下,书店是如何销售图书的:
1 public class BookStore { 2 private final static ArrayList<IBook> bookList = new ArrayList<IBook>(); 3 4 // 静态模块初始化,项目中一般是从持久层初始化产生 5 static{ 6 bookList.add(new NovelBook("天龙八部",3200,"金庸")); 7 bookList.add(new NovelBook("巴黎圣母院",5600,"雨果")); 8 bookList.add(new NovelBook("悲惨世界",3500,"雨果")); 9 bookList.add(new NovelBook("平凡的世界",4300,"路遥")); 10 } 11 12 //模拟书店买书 13 public static void main(String[] args) { 14 NumberFormat formatter = NumberFormat.getCurrencyInstance(); 15 formatter.setMaximumFractionDigits(2); 16 System.out.println("------------书店中的小说类图书记录如下:---------------------"); 17 for(IBook book:bookList){ 18 System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + 19 book.getAuthor()+ "\t书籍价格:" + formatter.format(book.getPrice()/100.0)+"元"); 20 } 21 } 22 }
注:在BookStore中声明了一个静态模块,实现了数据的初始化,这部分应该是从持久层产生的,由持久层工具进行管理。
运行结果如下:
------------书店中的小说类图书记录如下:--------------------- 书籍名称:天龙八部 书籍作者:金庸 书籍价格:¥32.00元 书籍名称:巴黎圣母院 书籍作者:雨果 书籍价格:¥56.00元 书籍名称:悲惨世界 书籍作者:雨果 书籍价格:¥35.00元 书籍名称:平凡的世界 书籍作者:路遥 书籍价格:¥43.00元
但是,最近书店的小说类图书销量下滑很严重,所以,书店希望通过打折来刺激消费:所有40元及以上的小说类图书8折销售,40元以下的按9折销售。
对于已经投产的项目来说,这就是一个变化,那么,我们应该怎么应对呢?
有三种方法可以解决这个问题:
修改接口
在IBook上新增一个getOffPrice()的方法,专门进行打折处理。
首先,IBook作为接口应该是稳定可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了意义。
其次,修改了接口,NovelBook实现类也要做相应的修改,这样,为了实现这个需求,改动的面积是比较大的。
修改实现类
修改NovelBook实现类中getPrice()的方法,这样,改动的面积相对比较小了,仅仅局限在NovelBook实现类中。但是这样的话,用户就无法获得图书的原价了。
通过扩展实现变化
增加一个子类OffNovelBook,复写getPrice()方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象。如下所示:
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; } }
你看,仅仅扩展一个子类并复写getPrice()方法,就可以完成新增的业务。接下来看一下BookStore类的修改:
public class BookStore { private final static ArrayList<IBook> bookList = new ArrayList<IBook>(); // 静态模块初始化,项目中一般是从持久层初始化产生 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)+"元"); } } }
上面只修改了静态模块初始化部分,其他部分没有修改。运行结果如下:
------------书店中的小说类图书记录如下:--------------------- 书籍名称:天龙八部 书籍作者:金庸 书籍价格:¥25.60元 书籍名称:巴黎圣母院 书籍作者:雨果 书籍价格:¥50.40元 书籍名称:悲惨世界 书籍作者:雨果 书籍价格:¥28.00元 书籍名称:平凡的世界 书籍作者:路遥 书籍价格:¥38.70元
上面这个例子,通过一处扩展,一处修改,实现了打折的新需求。可能有同学就会问:“这不还是修改了代码吗?”
修改代码就意味着违反了开闭原则吗?
BookStore类确实修改了,这部分属于高层次的模块。在业务规则改变的情况下,高层模块必须有部分改变以适应新业务。添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的程序,这部分代码的修改是在所难免的。
我们要做的是,尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
如何做到“对扩展开放、修改关闭”?
实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。
在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
扩展意识:在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
抽象意识:提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换老的实现即可,上游系统的代码几乎不需要修改。
封装意识:在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如:装饰、策略、模板、责任链、状态等)。
设计模式这一块,我们另外再分享。今天重点学习一下,如何利用多态、依赖注入、基于接口而非实现编程,来实现“对扩展开放、对修改关闭”。
假如,我们现在要开发一个通过Kafka来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将Kafka替换成RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。
// 这一部分体现了抽象意识 public interface MessageQueue { //...} public class KafkaMessageQueue implements MessageQueue { //...} public class RocketMQMessageQueue implements MessageQueue { //...} public interface MessageFromatter { //...} public class JsonMessageFromatter implements MessageFromatter { //...} public class ProtoBufMessageFromatter implements MessageFromatter { //...} public class Demo { private MessageQueue msgQueue; // 基于接口而非实现编程 public Demo(MessageQueue msgQueue) { // 依赖注入 this.msgQueue = msgQueue; } // msgFormatter:多态、依赖注入 public void sendNotification(Notification notification, MessageFormatter msg) { //.. } }
当然,开闭原则也不是免费的,有时候,代码的扩展性会跟可读性冲突。这个时候,我们就需要在两者之间做一个权衡。总之,没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。
如何预留扩展点?
前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点,那么问题是,应该如何才能识别出所有可能的扩展点呢?
如果开发业务导向的系统,比如电商系统、物流系统、金融系统等,要想识别尽可能多的扩展点,就需要对业务本身有足够多的了解。
如果开发通用、偏底层的框架、类库、组件等,就需要了解它们会被如何使用,日后可能会添加什么功能。
“唯一不变的就是变化本身”,尽管我们对业务系统、框架功能有足够多的了解,也不能识别出所有的扩展点。即便我们能够识别出所有的扩展点,为这些地方做预留扩展点的设计,成本都是很大的,这就叫做“过度设计”。
合理的做法,应该是对于一些比较确定的,短期内可能就会扩展,或者需要改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候,我们就可以事先做预留扩展点设计。但是对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以通过重构代码的方式来支持扩展的需求。
好啦,设计原则是否应用得当,应该根据具体的业务场景,具体分析。
总结
对扩展开放,是为了应付变化(需求);
对修改封闭,是为了保证已有代码的稳定性;
最终结果是为了让系统更有弹性!
参考
《大话设计模式》
极客时间专栏《设计模式之美》
https://blog.csdn.net/sinat_20645961/article/details/48239347