跳转至

62  重新认识开闭原则 (OCP)

你好,我是七牛云许式伟。

架构的本质是业务的正交分解。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,架构分解中有两大难题:其一,需求的交织。不同需求混杂在一起,也就是存在所谓的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

我们可能经常会听到各种架构思维的原则或模式。但,为什么我们开始谈到架构思维了,也不是从那些耳熟能详的原则或模式谈起?

因为,万变不离其宗。

就架构的本质而言,我们核心要掌握的架构设计的工具其实就只有两个:

  • 组合。用小业务组装出大业务,组装出越来越复杂的系统。
  • 如何应对变化(开闭原则)。

开闭原则(OCP)

今天我们就聊聊怎么应对需求的变化。

谈应对变化,就不能不提著名的 “开闭原则(Open Closed Principle,OCP)”。一般认为,最早提出开闭原则这一术语的是勃兰特·梅耶(Bertrand Meyer)。他在 1988 年在 《面向对象软件构造》 中首次提出了开闭原则。

什么是开闭原则(OCP)?

软件实体(模块,类,函数等)应该对于功能扩展是开放的,但对于修改是封闭的。

一个软件产品只要在其生命周期内,都会不断发生变化。变化是一个事实,所以我们需要让软件去适应变化。我们应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现 “拥抱变化”。

开闭原则告诉我们,应尽量通过扩展软件实体的行为来应对变化,满足新的需求,而不是通过修改现有代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

为什么会有这样的架构设计原则?它背后体现的架构哲学是什么?

本质上,开闭原则的背后,是推崇模块业务的确定性。我们可以修改模块代码的缺陷(Bug),但不要去随意调整模块的业务范畴,增加功能或减少功能都并不鼓励。这意味着,它认为模块的业务变更是需要极其谨慎的,需要经得起推敲的。

我个人非常推崇 “开闭原则”。它背后隐含的架构哲学,和我说的 “架构的本质是业务的正交分解” 一脉相承。

与其修改模块的业务,不如实现一个新业务。只要业务的分解一直被正确执行的话,实现一个新的业务模块来完成新的业务范畴,是一件极其轻松的事情。从这个角度来说,开闭原则鼓励写 “只读” 的业务模块,一经设计就不可修改,如果要修改业务就直接废弃它,转而实现新的业务模块。

这种 “只读” 思想,大家可能很熟悉。比如基于 Git 的源代码版本管理、基于容器的服务治理都是通过 “只读” 设计来改善系统的治理难度。

对于架构设计来说同样如此。“只读” 的架构分解让我们逐步沉淀下越来越多可复用的业务模块。如此,我们不断坚持下去,随着时间沉淀,我们的组织就会变得很强大,组装复杂业务系统也将变得越来越简单。

所以开闭原则,是架构治理的根本哲学。

CPU 背后的架构思维

一种广泛的误解认为,开闭原则是一种 “面向对象编程(OOP)” 领域提出来的编程思想。但这种理解显然太过狭隘。虽然开闭原则的正式提出可能较晚,但是在信息科技的发展历程中,开闭原则思想的应用就太多了,它是信息技术架构的基本原则。注意我这里没有用 “软件架构” 而是用 “信息技术架构”,因为它并不只适用于软件设计的范畴。

我们在 “02 | 大厦基石:无生有,有生万物” 一讲介绍冯·诺依曼体系的规格时就讲过:

从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。

冯·诺依曼体系的中央处理器(CPU)的设计完美体现了 “开闭原则” 的架构思想。它表现在:

  • 指令是稳定的,但指令序列是变化的,只有这样计算机才能够实现 “解决一切可以用 ‘计算’ 来解决的问题” 这个目标。
  • 计算是稳定的,但数据交换是多变的,只有这样才能够让计算机不必修改基础架构却可以适应不断发展变化的交互技术革命。

体会一下:我们怎么做到支持多变的指令序列的?我们由此发明了软件。我们怎么做到支持多变的输入输出设备的?我们定义了输入输出规范。

我们不必去修改 CPU,但是我们却支持了如此多姿多彩的信息世界。

多么优雅的设计。它与面向对象无关,完全是开闭原则带来的威力。

CPU 的优雅设计远不止于此。在 “07 | 软件运行机制及内存管理” 这一讲中,我们介绍了 CPU 对虚拟内存的支持。通过引入缺页中断,CPU 将自身与多变的外置存储设备,以及多变的文件系统格式进行了解耦。

中断机制,我们可以简单把它理解为 CPU 引入的回调函数。通过中断,CPU 把对计算机外设的演进能力交给了操作系统。这是开闭原则的鲜活案例。

插件机制

一些人对开闭原则的错误解读,认为开闭原则不鼓励修改软件的源代码来响应新需求。

这个说法当然有点极端化。开闭原则关注的焦点是模块,并不是最终形成的软件。模块应该坚持自己的业务不变,这是开闭原则所鼓励的。

当然软件也是一个业务系统,但对软件系统这个大模块来说,如果我们坚持它的业务范畴不变,就意味着我们放弃进步。

让软件的代码不变,但业务范畴却能够适应需求变化,有没有可能?

有这个可能性,这就是插件机制。

常规我们理解的插件,通常以动态库(dll/so)形式存在,这种插件机制是操作系统引入的,可以做到跨语言。当然部分语言,比如 Java,它有自己的插件机制,以 jar 包的形式存在。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,微软的大部分软件,以 Office 和 Visual Studio 为代表,都提供了二次开发能力。

这些二次开发接口构成了软件的插件机制,并最终让它成为一个生态型软件。

一般来说,提供插件机制的二次开发接口需要包含以下三个部分。

其一,软件自身能力的暴露,也就是我们经常说的 DOM API。插件以此来调用软件已经实现的功能,这是最基础的部分,我们这里不进一步展开。

其二,插件加载机制。通常,这基于文件系统,比如我们规定把所有插件放到某个目录下。在 Windows 平台下会多一个选择,把插件信息写到注册表。

其三,事件监听。这是关键,也是难点所在。没有事件,插件没有机会介入到业务中去。但是应该提供什么样的事件,提供多少个事件,这非常依赖架构能力。

原则来说,在提供的能力相同的情况下,事件当然越少越好。但是怎么做到少而精,这非常有讲究。一般来说,事件分以下三类:

其一,界面操作类。最原始的是鼠标和键盘操作,但它们太过于底层,提供出去会是双刃剑,一般对二次开发接口来说会选择不提供。更多的时候会选择暴露更高级的界面事件,比如菜单项或按钮的点击。

其二,数据变更类。在数据发生变化的时候,允许捕获它并做点什么。最为典型的是 onSelectionChanged 这个事件,基本上所有的软件二次开发接口都会提供。当然它属于界面数据变更,只能说是数据变更的特例。如果我们回忆一下 MVC 框架(参见 “22 | 桌面程序的架构建议”),就能够记得 Model 层会发出数据变更通知,也就是 onDataChanged 类的事件出来给 View 或 Controller。

其三,业务流程类。它通常发生在某个业务流的中间某个环节,或者业务流完成之后。比如对 Office 软件来说,打开文件之初或之后,都可能发出相应的事件,以便插件做些什么。

通过以上分析可以看出,完整的插件机制还是比较庞大的。但实际应用中插件机制未必要做得如此之重。

比如,Go语言中的 image 包,它提供的 Decode 和 DecodeConfig 等功能都支持插件,我们可以增加一种格式支持,而无需修改 image 包。

这里面最大的简化,是放弃了插件加载机制。我们自己手工来加载插件,比如:

import "image"
import _ "image/jpeg"
import _ "image/png"

这段代码为 image 包加载了两个插件,一个支持 jpeg,一个支持 png 格式。

如果大家仔细研究过我们实战案例 “画图程序” 的代码(参见 “加餐 | 实战:画图程序的整体架构”)就会发现,类似的插件机制的运用有很多。我们说的架构分解,把复杂系统分解为一个最小化的核心系统,加上多个相互正交的周边系统,它背后的机制往往就是我们这里提的插件机制。

插件机制的确让核心系统与周边系统耦合度大大降低。但插件机制并非没有成本。插件机制本身也是核心系统的一个功能,它本身也需要考虑与核心系统其他功能的耦合度。

如果某插件机制没有多少客户,也就是说,没有几个功能基于它开发,而它本身代码又散落在核心系统的各个角落,那么投入产出就显然不成比例。

所以维持足够的通用性,是提供插件机制的重大前提。

单一职责原则

到此为止,相信大家已经对开闭原则(OCP)非常了解了。总结来说就两点:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。

第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

平常,我们大家也经常会听到 “单一职责原则(Single Responsibility Principle,SRP)”,它强调的是每个模块只负责一个业务,而不是同时干多个业务。而开闭原则强调的是把模块业务的变化点抽离出来,包给其他的模块。它们谈的本质上是同一个问题的两个面。

结语

从来没有人这样去谈架构的本质,也没有人这样解读开闭原则(OCP),对吧?

其实对于这部 “架构课” 的革命性,我自己从没怀疑过。它的内容是精心设计的,为此我准备了十几年。我们用了四章内容来谈信息科技的需求与架构的演进,然后才进入正题。

用写文章的角度来说,这个伏笔的确够深的。

当然这不完全是伏笔。如果我们把整个信息科技看作最大的一个业务系统,我们有无数人在为之努力奋进,迭代它的架构。大家在竟合中形成自然的分工。学习信息科技的演进史,是学习架构的必要组成部分。我们一方面从中学习怎么做需求分析,另一方面也从中体悟做架构的思维哲学。

当然,还有最重要的一点是,我们要知道演进的结果,也就是信息科技最终形成的基础架构。

作为架构师,我们除了做业务架构,还有一个同等难度的大事,就是选择合适的基础架构。基础架构+业务架构,才是你设计的软件的全部。作为架构师,千万不要一叶障目,不见泰山,忘记基础架构选择的重要性。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “接口设计的准则”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

精选留言(15)
  • Jxin 👍(22) 💬(4)

    笔记: 将开闭原则上移到业务系统。业务对外只读,意味着不可变,但不变的业务生命周期是很短暂的,所以要可扩。要扩展还要不变,就倒逼着要做兼容,而兼容可能会导致现有的功能职责不单一,这又倒逼着要对现有的功能做再抽象,以适应更广的“单一职责”。 所以不改是不可能的,只是改的结果应当是让项目往更稳定去发展。然而这里面其实好难,无论是新的抽象的定义还是职责范围的扩张,这都需要有强大的分析能力和精湛的设计思维、重构手法、调优能力以及站在核心目标上的权衡来支撑。然而难亦是乐趣所在。

    2019-12-06

  • leslie 👍(22) 💬(2)

    老师的课程中提及的两方面是我觉得自己理解的最不好的:一方面"基础架构 + 业务架构,才是你设计的软件的全部",另一方面"一方面从中学习怎么做需求分析,另一方面也从中体悟做架构的思维哲学"。 如同老师所说的"架构课 的革命性,我自己从没怀疑过。它的内容是精心设计的,为此我准备了十几年",学习的过程中我其实同样在整体梳理自己作为DBA&&OPS十余年松散的知识体系。老师开课的这半年多不断的适度扩展梳理去破未知,完成了20余门功课的学习;除了画图部分的知识都是在不断的循环梳理。虽不断学习和梳理,但是依然觉得老师今天课程中提及的两方面其实是最难。如同前几天DevOps课程的石老师课程提出的Plan-Do-Check-Act时,我说这个顺序其实可以改变且石老师的回复中对此非常认可一样。这个认知其实是原来许老师课程的循环反复,梳理中悟出的东西。 结合老师上堂课所提及"任何功能都是可以正交分解的,即使我目前还没有找到方法,那也是因为我还没有透彻理解需求"-可以理解为业务方面的。《全局性功能的架构设计》和《重新认识开闭原则》两章内容在强调今天课程结束老师的"基础架构 + 业务架构,才是你设计的软件的全部"。 以上是个人结合这两节课的知识对于今天课程结束部分老师的理解:谢谢老师的分享和教诲。期待老师的下次分享。

    2019-12-06

  • 风翱 👍(9) 💬(1)

    活字印刷术,也是开闭原则应用的一个例子。 字是稳定的,字的排序是变化的。

    2021-05-26

  • K战神 👍(8) 💬(1)

    许大,希望出书。买来收藏。 时不时枕边翻阅体会大佬的思想。 多年以后,庆幸自己这段时间跟着许大的专栏,有了新的想法和思想。

    2019-12-06

  • Yayu 👍(5) 💬(4)

    如何理解“只读”模块?

    2019-12-06

  • swchen 👍(3) 💬(2)

    对于程序员而言,三种思维最为基础: 1.DRY (Don’t Repeat Yourself) 。 这是好程序员的根本追求,永久的驱动力。 2.分而治之。 这是人类解决复杂问题的普遍方式。 3.开闭原则。 这是应对变化(主动的变化如功能扩展,被动的变化如故障修复)的最佳手段。 其他各种原则/方法/模式/最佳实践,全部都是以此三者为基础,结合具体领域/场景/时代的更具操作性的推论。

    2022-05-26

  • 另存为…… 👍(3) 💬(0)

    老师,最近在了解区块链相关的知识,感悟到了一些开闭原则上的应用,跟您探讨下: 比特币旨在构建新一代的数字货币,而以太坊的目标则是要成为新一代点对点的分布式计算基础服务(终极目标是成为 web3.0 的标准),基于此发展除了智能合约,所谓“智能合约”,实际就是可编程的合约,跟我们普遍理解的“智能”没啥关系,那既然是程序,必定会涉及到升级或修复,业务数据存储结构变更,而区块链的特性决定了“变更”是一项成本极其高昂甚至几乎无法完成,我们称之为不可篡改特性,因此现在做dapp涉及一般设计为数据合约和程序逻辑合约组合,做到程序和数据分离(由此也可以看出对比中心化的程序还是相当原始和初级的),这样在程序更新的时候直接弃用旧的合约地址,改用新合约,数据也不用迁移,但是当我们的需求需要持续更新合约的时候,如果能懂得运用开闭原则,这件事情会优雅很多,因为无论是 以太坊的 ERC20 还是 ERC721 标准等等,都规定了相当有限且简单的接口标准,这就是架构的不可变部分,我们不能把所有的逻辑都堆在这个我们针对于接口的实现上,而应该采用组合的方式,通过合理的架构设计,多个合约组合出强大的功能,我看了几条最新的以太坊社区提案,一些针对于 NFTs 的提案,比如 ERC-998,旨在让多个 NFT 组合的新 NFT 具备不可拆解的特性,使新的NFT可以整体交易,这会极大的简化物品转移的处理,其实都是可以遵循开闭原则通过良好合理的设计组合合约来实现,没必要升级为最基础的接口标准,当然这也会带来 gas 成本的增加,因为运行在区块链上的程序是需要支付 gas 费用的,所以币安推出 bsc 币安智能链的原因之一,降低 gas 费用。

    2021-12-25

  • 何磊 👍(2) 💬(1)

    老师对于开闭原则,我也在思考这里的开、闭到底是针对什么的。 首先对于bug坑定是需要修复的;那么如文中提到的,对于需求的变化引起需要修改利用插件机制。但是实际的业务中,可能很多是需要在原有代码中增加一个if判断、或者类型转化、或者额外的数据处理。 针对这些问题,肯定不可避免要去对源代码产生修改。这里很难去控制什么时候可以改,什么时候该用插件

    2020-07-16

  • Geek_adf1c9 👍(1) 💬(2)

    开闭的核心还是明确“变与不变”,这很难,需要大量的需求分析

    2022-05-19

  • Bravery168 👍(1) 💬(1)

    需求稳定点与变化点,正交分解,开闭原则,我理解内在本质是一脉相承的。这种架构看看作一种生命体,在基础构造上是稳定的,但又具备足够的柔性和灵活性。在业务变化时能够灵活适应,同时又不会散发臭味。有了这种特质,这种架构才可以说是有生命力的。

    2020-02-28

  • K战神 👍(1) 💬(1)

    许大,我们真的要考虑,将老的需求放入版本库。对于新的重新实现一个新的。 会不会最后系统会有很多名字类似业务有所区别的接口?

    2019-12-11

  • Geek_88604f 👍(1) 💬(1)

    在业务正交分解的过程中必然会遇到以前分解好的模块需要调整的情况,比如说随着新模块的加入发现和老模块的部分实现有重复的情况。这种情况下是保持新老模块的重复部分呢还是抽取出共同的部分作为更基础的支撑模块呢?如果要抽取共同的模块必然会涉及老模块的修改,这种情况是否有违反了开闭原则呢?更进一步开闭原则和重构的关系应该如何处理?

    2019-12-10

  • tt 👍(1) 💬(1)

    本课感受最深的一句话: “第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统 + 多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。” 由业务到数据,由核心到周边,再把这个过程映射到开闭选择,再把开放性具体到插件与接口,下节课讲接口。老师这三节课真是步步深入啊。: 偷懒得说,要是画图代码在通用一些就好了

    2019-12-08

  • 靠人品去赢 👍(1) 💬(1)

    这么一想,微服务其实也是单一职责原则的实现。像普通业务的话,不清晰以后的方向,可能现在是工具类,后来又搞商城类,侧重点变化可能无法一下子就确定那些是稳定点,做着做着又是一大坨,哪里不行搞哪里,这种怎么解?

    2019-12-06

  • 沫沫(美丽人生) 👍(1) 💬(3)

    许老师好,很感谢您上次关于PaSS问题的回复,读您的文章让我受益匪浅。还想请教一个问题,像Salesforce是基于元数据来构建系统的,元数据在信息架构里属于什么范畴呢,可以展开来讲一讲吗?盼复。

    2019-12-06