[5+1]开闭原则(一)
前言
面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。
这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。
所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。
=================================
↑前言↑
↓正文↓
=================================
开闭原则(Open-Closed Principle)
开闭原则指的是“对扩展开放、对修改关闭”。
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
—— Object-Oriented Software Construction, Robert C. Martin
什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。
什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。
实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。
例如,把一个很大的方法拆分成三个小方法,但方法的调用流程、业务功能等都没有变。这个操作当然会导致类重新编译,但还算可以接受,不必对它亮红灯。但如果在某个方法中加一个if-else,只在某种情况下沿用原有逻辑、其它情况下使用新的功能,这时,我们就应该像甘道夫那样,挥舞起“开闭原则”之剑,高呼“You shall not pass”了。
=================================
↑开闭原则:是什么↑
↓开闭原则:为什么↓
=================================
为什么
有那么一段时间,我几乎成天跟我们老大抱怨。
“这个if-else的条件重复出现了至少五次了,为什么不重构一下啊?”
“这个同步请求的处理时间太长了,可以改用异步轮询来处理吧?”
“这个表的读写比这么高,考虑下给它加个缓存吧。”
……诸如此类。
而我老大呢,一般都是这样答复的:
“你有时间就去改一改吧。不过要注意,不要影响业务功能。”
于是我就一直没有改。
一方面,我一直没有时间。业务需求一个接一个,每次都要伤筋动骨地改一大堆代码。而且,我们经常遇到改着改着发现产品漏提了一项需求、测着测着发现开发漏改了一处代码这样的问题,开发简直一刻不得闲,哪儿腾得出手来做技术上的重构优化?更何况,重构优化完了之后,测试组还要进行回归验证。即使开发能改得过来,测试也测不过来。
另一方面,只要修改了代码,谁敢保证不影响业务功能呢?我曾经遇到过在某处加一行代码、结果在一百多行外引发bug的情况;也遇到过开发的单元测试和QA的回归测试都安然无恙、偏偏在线上环境爆出bug的情况。谁能保证重构优化后的代码能够在重重考验下全身而退呢?
开闭原则可以缓解第一个问题。遵循了开闭原则的话,开发和测试都只需要专注于新的代码、新的功能,而不用操心修改老代码对老逻辑会有什么样的影响。因而,大家可以用较少的时间来完成业务需求,从而腾出工夫来做重构优化。
但是,开闭原则只能帮助开发环节、以及下游的测试环节做一些改进。要真正解决产品需求络绎不绝、每个需求都要伤筋动骨、产品和开发顾此失彼等问题,需要产品、开发、测试甚至运维等环节全线联动,用一套完整方案理顺整个产品上线流程。不过这里只聊开闭,这个问题暂且按下不表。
虽然只能缓解第一个问题,但开闭原则可以很好地解决第二个问题。
既然修改代码会影响业务功能,那就不修改现有代码。连一行代码都没修改,现有功能总不会变了吧?这就是开闭原则中的“对修改关闭”的意义。
然后,通过实现接口、继承父类等方式,为系统引入新的功能。这就是开闭原则中“对扩展开放”的意义。
在单元测试、回归测试和金丝雀测试都通过之后,祖传的老代码就可以“寿终正寝”,系统也就正式而平滑地过渡到重构优化后的代码上来了。
借助开闭原则,我们用设计模式重构了无数个if-else,用消息队列、异步回调和异步轮询改写了无数个同步请求,用缓存替代了无数次数据库查询……我们甚至用这种方法,把一套老系统代码平滑地合并到了新的系统内,并逐步地用新代码和新配置替代老系统的代码和配置。当老系统的代码和配置都不再被使用时,它就彻底地退出历史舞台了。
当然,不光是重构优化,在开发新需求时,开闭原则也有很大的帮助。究其根本,是“对扩展开放、对修改关闭”的做法能够使我们的系统兼具扩展性和稳定性。扩展性能帮助系统轻松地吸纳新技术、实现新需求;而稳定性则能够降低系统中的新技术、新需求在业务功能、架构设计、核心代码等方面的影响,从而减少系统bug、需求范围和开发工作量。
“求木之长者,必固其根本;欲流之远者,必浚其泉源”。无论是做业务需求,还是做重构优化,“开闭原则”都是我们把系统做成、做完、做好的“根本”和“泉源”。
=================================
↑开闭原则:为什么↑
↓开闭原则:怎么做↓
=================================
怎么做
要把开闭原则运用到开发实践中,跟所谓的工作闭环“计划-实施-检查-处理”非常相似。
首先,我们要做出“开闭计划”,也就是判断你的代码中,哪些地方会发生变化、会发生什么样的变化。然后,根据这个“开闭计划”,把不会改变的代码严密地封装起来;并根据可能发生的变化、以及发生变化的方式,在代码中预留好扩展点。接着,合理地运用预留的扩展点来实现业务需求或者重构优化。最后,必要时,根据实际情况去调整当初设计的扩展点。
开闭计划
在开闭原则的“闭环”中,“计划”是这四个步骤中最困难、但也是最重要的一步。
说它困难,是因为除了“未来一定会不一样”这一点之外,谁都不知道未来会怎样。也许下个月这个系统就要重做了;也许下半年这项业务就要下线了;也许明年就要封路封城、在家隔离了;也许我们现在认为不会改的代码,明天就被改得面目全非了;也许当改变真的降临时,我们现在预留的扩展点完全是在帮倒忙……
说它重要,是因为如果对未来毫无规划,我们只能走一步算一步,走到哪算哪儿。而在这种情况下,绝大部分时候,我们所做的每一件事都是在为未来挖坑,都是在增加自己的“技术债”。接口方法返回值类型声明为Long,数据库表中引入一个传递依赖,在逻辑相似的两个类之间大段地copy代码……当新的需求要求修改这部分代码时,我们除了“待从头,收拾旧山河”,似乎也没有什么更好的办法。
这就好像螃蟹这类的甲壳动物一样,眼前这身甲壳虽然合身,但是当身体逐渐长大,原本“合身”的甲壳逐渐变成了牢笼,最后只能把它脱掉、再长一幅新的来用。
因为做“开闭计划”会很困难,所以敏捷开发模式提出了“简单设计”的口号;在互联网开发中也有“快速试错”的传统。但是简单设计不等于没有设计。它提倡的是不对未来做过多预测、也不根据这些预测做过多设计。快速试错也不是技术上的决策。它指的是找不准市场方向时,对业务进行快速迭代、用市场反应来为业务指路。
而且,即使是简单设计和快速试错,也要求我们的系统遵循开闭原则。否则,每次需求都要拆墙砸柱重做水电,敏捷项目怎么能敏捷得起来?快速试错又怎么能快速起来?这也是其重要性的一种体现。
那么,“开闭计划”到底要怎么做呢?
“未来的种子深埋在过去当中”。虽然无法预见未来,但我们可以总结过去。无论是业务还是系统,我们总能从它们过去的发展轨迹中找到一点变化的模式。通过对这些模式进行分析和总结,我们就能够把未来大部分的可能性握在手中了。
但是,如果业务和系统都刚刚起步,还没有可用于分析总结的过去,我们要怎么办呢?“太阳底下没有新鲜事”,我们的业务和前人的业务、我们的系统和前人的系统,多多少少都有相似之处。而前人已经对这些业务和系统做过很多分析和总结了——比如这里说的设计原则,比如以后要分享的设计模式。“它山之石可以攻玉”,我们把这些成果“拿来主义”,当遇到前人经历过的变化时,就可以轻松应对了。
但是,如果对设计模式不熟悉,或者碰到了哪种设计模式都不适用的极端情况,那又该怎么办呢?
如果真的遇到了这种情况,那么可以考虑考虑下面这些建议。
第一,面向接口编程。接口的“开闭”性是不言自明的。虽然具体的实现类也可以“对扩展开放、对修改关闭”,但是相比接口,还是要略逊一筹。
第二,不要用基本数据类型做接口方法的入参和返回值。这一点相信是众所周知的,不多啰嗦。
第三,在实现接口时,区分处理“数据结构”和“算法”。
在设计数据结构时,我们应该忠于现实:数据结构在现实中是怎样的,那么在代码中就应该是怎样的。现实中是正整数,代码中就应该是Integer或Long,而不应该是String;现实中是日期,代码中就应该是Date或LocalDateTime,而不应该是String;现实中是1:1的关系,代码中就应该是1:1的关系;现实中是1:N:M的关系,代码中就应该是1:N:M的关系。
数据是信息的载体。它承载信息的方式不仅仅是数据的取值,还包括数据的类型和数据之间的关系。当看到整数类型的“有效期”时,我们会很自然地认为它是“从某个日期开始的有效期天数”;而在看到日期类型的“有效期”时,我们又会很自然地把它理解为“有效期截止日”。这就是数据类型所承载的信息。当银行卡与用户之间是1:1的关系时,说明多笔借款申请可以共用一张银行卡,那么用户只需绑定一次即可;但若银行卡与借款申请之间的关系是1:1,说明借款申请之间不能复用银行卡数据,则用户每次申请借款时,都需要重新绑定一次银行卡。这就是数据关系所承载的信息。
如果我们在系统设计时,不使用实际的数据结构、而是定义某种特殊的数据结构,那就意味着我们将某种特殊的信息隐式地固定在了这种设计中。而这种信息势必会对后续扩展带来一定的约束。如果后续扩展时的数据结构与这种约束相抵牾,那么光是如何处理历史数据的问题就足够大家喝一壶了。
这种“隐藏的、特殊的信息”,比较常见于传递依赖中。例如,用户、账号、银行卡之间实际的数据关系是1:1:N。但为了查询方便,我们库表中只存了用户id和银行卡号。这时,表中就出现了“传递依赖”。当某一天,用户和账号之间的数据关系扩展为1:N的时候,只存了用户id和银行卡号的表就遇上麻烦了。这也是为什么在数据表设计时,我们要遵循第三范式的原因之一:传递依赖把一部分信息变成了某种隐藏的、固定的数据约束。这种约束不仅是对当前数据的约束,也是对未来扩展的约束。
忠于现实的数据结构也有可能遇到这种问题。但是相对来说,概率要小得多。因为在这种情况下,数据所承载的信息和约束都会直接体现在数据上,因而会更加直白、灵活。这就像装修时水电都走明线一样,虽然不如走暗线那么“优雅”,但是当需要改水改电时,明线的优势就一目了然了。
说完数据结构,我们说说算法。对业务系统来说,“算法”实际就是业务功能在系统中的流程逻辑。与设计数据结构时应该忠于现实的做法相反,设计系统流程时恰恰不能照抄业务流程图。业务流程图仅仅是根据当前的业务流程来制作的。如果系统流程直接照抄业务流程,那就意味着这个系统彻底失去了对未来的扩展性、以及做出扩展的自主性。
例如,下图是我们某个系统中“用户注销需求”的两份简要流程设计。图中左侧的是业务流程,而右侧是系统流程。对比之下,我们可以发现:如果按业务流程来开发,那么,每当需要删除信息的表发生变更时——加一张表、少一张表、改一个查询条件等,我们就要修改一次代码;而按系统流程来开发的话,大多数情况下,这些变更都只需要修改配置即可。显然,后者更加的“开闭”,对开发和测试也更加的友好。
不照抄的话,要怎么办呢?我在左耳朵耗子的一篇文章中看到过这样的观点:把业务流程拆分为“控制单元”和“处理单元”两类逻辑。这是设计算法时的一种思路。放在“开闭计划”的视角下,“控制单元”是易变的、需要预留扩展点的;而“处理单元”是不那么容易变的、可以封装为“黑盒”的。这样,在做业务需求或重构优化时,不用修改已有的处理单元、而是通过扩展控制单元来引入新的处理单元,也就很好地遵守了“开闭原则”。
除了按“控制+处理=系统流程”这种思路来设计之外,在单一职责原则一文中提到的“逻辑简单、结构复杂”也是一种符合开闭原则的设计思路。这种思路在前文中已经介绍过,这里就不赘述了。
把“逻辑复杂度”转变为“结构复杂度”,就是降低逻辑单元内的逻辑复杂度、提高逻辑单元间的结构复杂度。或者简单来说,就是把系统由“复杂逻辑、简单结构”转变成“简单逻辑、复杂结构”。除了优化算法之外,重构函数、系统分层、拆分服务等方式,本质上都是在按这种思路来应对系统复杂度。
花园的景昕,公众号:景昕的花园[5+1]单一职责原则
=================================
↑正文↑
↓往期索引↓
=================================
往期索引
面向对象概述
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?公众号:景昕的花园面向对象是什么
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
花园的景昕,公众号:景昕的花园抽象
高内聚与低耦合
《高内聚与低耦合》
《细说几种内聚》
《细说几种耦合》
"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
花园的景昕,公众号:景昕的花园高内聚与低耦合
封装继承多态
《封装》
《继承》
《多态》
——“面向对象的三大特性是什么?”——“封装、继承、多态。”
[5+1]SOLID设计原则+迪米特法则
单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。花园的景昕,公众号:景昕的花园[5+1]单一职责原则