Categories
程式開發

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle)


割裂

想必不少许多开发者都有一个梦想:

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle) 1

而现实是:

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle) 2

n 个版本后的开发者:

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle) 3

Emm,现实和梦想总是存在这种割裂式的差异,开发者与需求方之间经常存在一种对立的格局。

对于一个团队来说,这显然是一种不好的信号——如果开发者象征着技术架构,需求方象征着业务架构,那么当这两种架构产生了矛盾,且每次只能通过相互妥协或者一方不断让步才能调和的话,整个项目只会通往一条泥泞不堪、充满陷阱的道路。

要知道,世界上没有一劳永逸的技术方案,也没有一成不变的业务可以取得成功。理想状态下,业务应当驱使着技术的进步,同时技术的进步也促使业务有更多的可能,二者应当呈现为一种相辅相成并充满活力的状态。

All systems change during their life cycles. This must be borne in mind when developing systems expected to last longer than the first version.所有的系统都会在其生命周期内有所改变。这一点必须牢记,尤其当你开发的系统预计会比第一版运行得更长久时。—— Ivar Jacobson

整体稳定,适合扩展

在如今这个技术进步飞快,并且思维愈加开放的时代,无论是哪个赛道,想要成功,业务需求的创新是一个绝对重要的因素。因此我们的技术架构也应当呈现一种在系统整体稳定的情况下,适合扩展的形态。

而开放关闭原则便是实现这种架构的一个重要设计原则。

开放关闭原则(OCP,Open-Closed Principle)

PS:为节省篇幅并易于识别,以下都简称 OCP 。

OCP 由 Bertrand Meyer 在 1988 年提出,他说到:

Software entities(Classes, Modules, Functions, ETC.) should be open for extension, but closed for modification.软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。

Uncle Bob 在他 1996 年的文章《The Open-Closed Principle》中对 OCP 进行了进一步的阐释,他说到遵循 OCP 的模块应当包含两项主要特性:

对扩展开放

模块可以被扩展,这使得模块可以根据应用的需求有不同的行为,或者满足新的应用需求

对修改关闭

模块的源码是不可污染的。不允许任何人修改源码

这意味着,系统组件(即软件实体)的核心逻辑应当是封闭的,当你希望实现更多功能时,应当通过扩展的方式,而不是直接修改这些核心内容,那么组件理所应当的要对扩展友好。

这样一来,系统整体总体将由两部分组成:

不可变组件:这类组件往往有着最全面的测试,是组成系统基本功能的核心可变组件(扩展):这类组件通常通过扩展不可变组件来实现,在不破坏系统整体稳定性的情况下实现特定的需求。

这就形成前文提到的“系统整体稳定,并适合扩展”。

反过来,如果每当有了新的需求,你就不得不修改一连串的依赖模块来满足它,那么你在本次修改前的测试将作废,新的需求将引起整体系统的动摇,程序将变得脆弱、臃肿、不灵活、不可预期并难以复用。

Talk is cheap,Show me the code!

老调悠扬

我们先引用一个经典的例子来探讨 OCP 的一般思路,这个例子来自于前面提到 Uncle Bob 的那篇《The Open-Closed Principle》,在他多年来各种场合的演讲中也偶有提及。

这是一个关于绘制形状的程序,原文中是 C++ 实现的,我们这里用 TypeScript 来呈现。

假定我们需求是绘制圆和矩形,那么我们将有 圆圈 和 矩形 两种图形接口,程序将根据这两种接口选择绘制方式:

enum ShapeType {
Circle,
Square
}

interface Point {
x: number,
y: number
}

interface Shape {
type: ShapeType;
}

interface Cirle extends Shape {
type: ShapeType;
radius: number;
center: Point;
}

interface Square {
type: ShapeType;
side: number;
topLeft: Point;
}

function drawSquare(square: Square) { /* draw square */ }
function drawCircle(circle: Circle) { /* draw circle */ }

function drawShapes(list: Shape[], n: number) {
let i;
for (i = 0; i < n; i ++) { const shape = list[i]; switch (shape.type) { case ShapeType.Circle: drawCircle(shape); break; case ShapeType.Square: drawSquare(shape); break; default: break; } } }

好,到目前为止,这段代码可以完全满足我们的期望,看起来没什么问题——仅对当前版本来说。我们先明确一点,这段代码里的 drawShapes 方法是主要的业务逻辑,但它并没有遵循 OCP 原则,因为每当有一个新的图形需要扩展,这个方法就必须同步做出调整,在这个情况下会发生什么呢?

随着需求的增多,某一天你可能需要新增两个图形的支持,基于这段代码你将:

扩展两个图形的绘制方法在 drawShapes 方法中新增两路 case 分支测试这两个图形各自的绘制方法;与此同时,由于 drawShapes 改变了,因此对于它的测试需要重新进行

重新测试 drawShapes 似乎有点笨拙,但看起来好像也不怎么麻烦——仅对第二个版本来说。

但如果该项目计划持续许多年,而这段代码最终会发展成对数以千计形状的绘制支持,在重复测试 drawShapes 上所消耗的时间就相当可观!并且真实情况下的 switch-case 语句通常不会这么整齐划一,你需要处理各种各样的情况,因此其实际代价比起看着这段代码来估计的会更难以想象。

为了避免这种“累赘”带来的不必要的损失,我们应当在一开始就尝试遵循着 OCP 原则来设计这块业务。实际上这并不复杂,我们按照 OCP 的两个要素来分析我们的需求:

在这个例子中,drawShapes 方法尽管是核心业务逻辑,但其实它只做了一件事——将形状从列表中拿出来并调用相应的绘制方法,它其实并不关心当前的形状的名称。而整体业务上,主要的改变都来自于形状的扩展。

现在我们可以进一步写出两个要素对应的落地点了:

对修改关闭

我们要尽量使图形的扩展不会引起 drawShapes 方法的改变,因此所有的形状应该有一个统一的接口供它调用以实现绘制,这个统一的接口也应当是不可变的

对扩展开放

对形状的扩展实际上就是新增一种名称及一种绘制方法,为了使这一实现更加灵活可控,我们让形状自己绘制自己是最好的——就好像专业的事交给专业的人去做

如此一来,我们修改代码至如下:

abstract class Shape {
draw();
}

class Square extends Shape {
draw() {
// draw square
};
}

class Circle extends Shape {
draw() {
// draw cirle
}
}

function drawShapes(list: Shape[]) {
list.forEach(shape => shape.draw());
}

非常简洁!

现在,我们的形状有了统一的基类 Shape,扩展形状只需从 Shape 派生即可,形状名称即类名,而形状所需参数定义在对应的类中,在其实例化的时候已经被装载到对象中,浑然一体。而抽象类 Shape 提供了统一的 draw 方法,这使得 drawShapes 的工作变得非常清晰——让列表中的形状依次绘制自己,不再需要关心是什么形状!

由此一来,我们在扩展一个形状的时候要做的就是:

新增一个类,使它派生自 Shape测试这个类

这样既避免了级联的依赖模块调整,又使得新需求变得独立——你只需要测试新增的内容,代码整体既稳定又易于扩展。

其中 Shape 作为子模块(各种形状)的依赖,实际上还包含了对依赖反转原则(DIP)的践行,该原则与 OCP 通常是相辅相成的存在——而 Shape 与其派生类的关系是 DIP 的,而整体的实现又应当具有 OCP 的特点,有兴趣可以查找相关资料进一步了解。

神游

我们随着这个例子延伸一下思路,哪些场景能与 OCP 完美契合呢?

信息流

大部分的内容型 App、网站都具有一个特定页面或者模块用于信息流的展示,有时候甚至就是首页。那么这些信息流有什么特点呢?其实与画形状一样——无论是通过推荐算法也好,还是按序读取也好,最终到前端的,通常就是一个列表罢了,而不同点,则在于这些列表项本身的数据类型,它们或许需要有不同的展示风格来达到不同的视觉目的,甚至可能需要和广告列表合并。这样一来,这些数据项便是前面例子中的“形状”,而整个信息流的读取便是 drawShapes 。

聊天室

其实聊天室与信息流很相似,都是按序展示列表里的信息,在这里提及是因为笔者正好曾在同事的相关业务逻辑中看到过用 switch-case 来进行不同种类消息渲染,当时这块儿功能的代码占了整个应用的 30% 左右,并且功能性 BUG 层出不穷,维护起来又非常麻烦。在随后的改版中,笔者与同事一起设计并采用了更加 OCP 的方案,整体代码量减少了近一半(当然也包含许多细节的优化),最重要的是,第一期测过后,直到新的相关需求提出,这块功能都没有再报过功能性 BUG,同时新需求的开发从一连串的依赖项修改变为了新增一个用于编写新组件的文件,之后只需在适当的地方注册即可。

跨平台框架

近年来,跨平台应用程序框架层出不穷,其中 React 与 Flutter 在跨移动平台这一领域尤为耀眼。而这两者实际上在架构上都有一个共同点,即基于顶层抽象来实现不同平台的具体实现,如图所示:

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle) 4

在这个架构下,扩展一种平台的思路便是:

确认该平台支持 React(JS)或 Flutter(Dart)顶层抽象的行为如果支持,实现该平台的渲染方式

由于各平台的实现都依赖于顶层抽象(又 cue 到 DIP),在这样的情况下,修改顶层代码用牵一发而动全身来形容是完全不过分的,因此其迭代需要非常谨慎——前面说应当对这样的代码关闭修改权,为什么这里又说 迭代要谨慎 呢?

很简单,因为一成不变的逻辑是基本不存在的,我们有时候不得不去修改源码来达成一些核心功能的调整,这时候就引出了 OCP 的一个延伸概念——策略闭包,我们稍后来看。

策略模式

啊哈,虽然名字相似,这里并不是要开始讲策略闭包😬

策略模式是面向对象编程中经常会使用到的一种行为型设计模式,它将各种行为按一定的抽象进行归类,在该抽象的前提下,这些行为可以相互替换,而无须对主逻辑进行调整。

举个例,假设我们要对输入的数据进行加、减或除运算,而每次输入只使用一种计算方案,运用策略模式,其架构大致如下:

核心稳定、易扩展——开放关闭原则(The Open-Closed Principle) 5

可以看到,主逻辑依赖于 Strategy 抽象,而 Add、Substract 和 Divide 三种策略都是基于 Strategy 实现的,那么主逻辑则仅需选其一,然后根据 Strategy 的规则调用 operation 方法即可。如此,如果我们要新增乘法,则只需要基于 Strategy 实现一个 Multiply 即可,而无须去主逻辑中进行任何更改——因此策略模式的内涵其实就是一种遵循 OCP 的行为设计方案(以及 DIP 🐶)。

我们再来看一个真实的策略模式实践。

hapijs

hapijs" 是由沃尔玛实验室团队研发的 nodejs-based 后端框架,其内置的用户身份认证方案则采用了策略模式实现,其策略注册方式如下:

server.auth.strategy('session', 'cookie', {
name: 'sid-example',
password: '!wsYhFA*C2U6nz=Bu^%[email protected]^F#SF3&kSR6',
isSecure: false
});

其中 strategy 方法的声明为 server.auth.strategy(name, scheme, [options])。

hapijs 暴露了 strategy 这个接口来派生新的策略,其中 name 为策略标识(名称),scheme 是通过 server.auth.scheme 注册的方案名称,其需要满足 Authentication scheme" 定义的接口才能正常运行。

在这个架构下,要修改用户认证方案的时候,只需要修改或新增 scheme 即可,并且这个新的方案是可以单独测试的,因此主逻辑受到的影响很小(或许你需要在主逻辑中修改几个字符使得新策略生效),非常安全。

好了,神游告一段落,这里仅发散了一些较为实在的例子,或许你的思维已经跳跃到了各种更庞大的系统架构,但这里只是为了阐释 OCP 本身,便不再展开。实际上,复杂的架构正是由各种各样粒度的组件构筑起来的,而这些组件是否遵循了可靠的设计原则便在相当程度上决定了架构的可靠性,当我们真正的理解了设计原则,必然是可以以小见大的。

策略闭包(Strategic Closure)

前面我们提到了,像 React、Flutter 这类应用,其顶层抽象不会是一成不变的,技术方案的调整、设备的进化等各种原因都有可能导致其源码的调整或补充,甚至重构,因此 通常不会存在 100% 的封闭。因此在设计闭包的时候需要有一定的 策略性。简单来说,就是寻找 需要封闭的行为或数据。

是的,这非常考验代码设计者的水平——但这并不一定是纯粹的编码能力,还在于设计者是否能准确的找到业务与技术之间的对接点,这就像 DNA 的双螺旋结构一样,螺旋延伸的同时有着无数的碱基对通过氢键将双链紧紧相连。也正是这些如 DNA 般的 业务-技术 双螺旋结构,构成了项目的基因,隐藏着项目成败的秘密。因此在这个问题上经验往往占了相当大的比重。

如此看来,这显然很难有什么标准的分析步骤,因此笔者在此引用一下《The Open-Closed Principle》中提到的两个思路:

运用抽象实现显式的封闭运用“数据驱动”实现封闭

运用抽象实现显式的封闭

前面的内容其实已经蕴含了这条思路,即对特定的逻辑进行抽象,避免经常

回顾前面的提到的关于信息流的例子。

在这种场景下,还有一个必不可少的需求——埋点。

我们通常的做法可能是以下两种:

监听整个列表的点击事件,根据触发点冒泡上来的特征进行上报,类似于全埋点的思路监听每一个数据项的点击事件实现上报

实际上,无论是哪一种思路,都有一件事要做——对数据项的特征进行归纳。

对于第一种方案,如果将归纳算法写在单一的点击事件中,那么一旦某个类型数据的特征定义发生了调整,就可能影响到整体的埋点逻辑;而第二种方案,如果将特征和上报行为都独立地写在交互组件中,不仅在开始就会因代码的重复(或许可以称为不够 DRY)造成不必要的时间损耗,而且一旦埋点的方案发生了改变——如 Api 调用方式、新数据平台的引入等——就会波及 n 处代码,又得写一堆重复的代码。

显然,为了避免这些问题,我们显然应当考虑对这些点击事件加以封装,形成一个抽象的实体,但是,不同的数据类型点击后的行为很可能是不一样的,也就是说这个实体不能 100% 的封闭,这时候,我们就要策略性的来寻找“应当封闭”的逻辑。

那么根据 OCP 的思路,我们先来确认两点问题:

有什么是会改变的?

对于不同的数据类型,点击后的行为可能是不一样的

有什么是不会改变的?

每一次点击产生的埋点上报,除了参数以外,其他都一样

Bravo!思路已经出来,我们抽象出的这个实体,要对埋点封闭,对点击事件开放!

以 React 为例,我们试着用 React Hook 来解决这个问题:

首先我们需要有一个 TrackEvent 类用于埋点上报,这个实体通常由数据平台提供,我们简单示意一下

interface UserInfo {}
enum Platform {}

class TrackEvent {
static instance: TrackEvent; // 实现为单例会更好管理一些

userInfo: UserInfo;
platform: Platform;

constructor() {
if (!TrackEvent.instance) {
TrackEvent.instance = this
}

return TrackEvent.instance;
}

init() {
// 将用户信息、平台标识等属性初始化
}

private send(event, data) {
// 将 event、data 作为参数进行上报
}

trackClick(props) {
// 可以在这里清洗或做一些参数转码之类的工作
this.send('click', props);
}

// ...
}

通过自定义 Hook,我们可以在不同的组件中很容易的复用一些逻辑,并加以扩展。在这个例子中,我们要复用的就是埋点,要扩展的则是点击事件,因此前者应当是封装在实体(Hook)内部,点击事件及埋点参数应当是传入的:

import { useCallback } from 'react'

const trackEvent = new TrackEvent()

function useTrackClick(callback, props /* 这里传入一些埋点参数 */) {
// 运用 useCallback 对 clickProxy 进行缓存,这样 props 发生改变的时候,
// clickProxy 也能得到及时的更新
const clickProxy = useCallback((ev) => {
trackEvent.trackClick(props)
callback(...ev)
}, [props, callback])

return clickProxy
}

现在,我们在组件中使用这个 Hook:

function FeedItem(props) {
const handleClick = useTrackClick((ev) => {
history.push(/* 一些用于跳转 */)
}, props)

return (


{/* wubbalubbadubdub */}


)
}

好了,整个过程非常轻松。这样一来,我们就在定义 click handler 的时候通过代理函数 clickProxy 将埋点方法也注入了。一旦我们的特征算法发生了改变,去修改 useTrackClick 即可。

综上,我们策略性地实现了对埋点的封闭,对点击事件的开放。

运用“数据驱动”实现封闭

什么是数据?数据就是具有一定 特征 的信息,在计算机中它可以是:

数值字符对象数组……

那么如何理解用“数据驱动”实现封闭呢?

我们先来考虑这样一个功能,回到前面的信息流需求上。

在实际开发中,我们可能同时从多个数据源获取数据,因此拿到后我们很可能需要对它们进行一定规则下的排序。根据 OCP 的思路,我们可以确认,这个排序逻辑应当是需要封闭的。Ok,我们试试看:

先将数据的统一接口示意一下:

interface IFeedData {
from: String,
title: String,
key: String,
id: number | String
// ...
}

这是我们要排序的数组:

const feeds: IFeedData[] = []

假设要属性 from 值为 'A' 的数据排在最前面,现在我们来实现这个排序算法:

在 JavaScript 中,数组的排序可以通过调用 sort 方法实现,而这个 sort 方法可以传入一个用于自定义比较算法的函数参数,即我们接下来要实现的方法

function sortFeeds(a, b) {
if (a.from === 'A' && b.from !== 'A') {
return -1
}

if (a.from !== 'A' && b.from === 'A') {
return 1
}

return 0
}

feeds.sort(sortFeeds)

好了,就这么简单,通过实现 sortFeeds 方法,我们将排序逻辑封闭了起来,想要扩展数据只需使其满足 IFeedData 接口即可。

数据驱动

现在来看看 sortFeeds 这个方法。

啊!显然这个方法本身并没有遵循 OCP——一旦哪天需要让 'B' 在前面了,这个算法就得调整,这还是只需要一种来源排在最前面的情况,如果要对多种来源进行优先级规范,岂不是得再次笨笨地修改算法?

灵活性太差了!

我们用 OCP 来分析一下这个问题:

什么是会改变的?

来源的优先顺序

什么是不会改变的?

sortFeeds 再怎么调整,最终也是返回大小比较的结果

浮出水面了,我们就是要让这个优先顺序更易扩展,让 sortFeeds 尽量稳定。那么既然标识符是个字符,最简单的方式就是将它们排好序放在数组中——数组,数据!

既然标识符是个字符,那么最简单的办法就定义一个数组咯?

const orderedSource = [
'A',
'B',
'D',
'C',
'Z',
// ...
]

有了这个数组,我们的 sortFeeds 就非常简单了:

function sortFeeds(a, b) {
const indexA = orderedSources.indexOf(a)
const indexB = orderedSources.indexOf(b)

if (indexA indexB) return -1
else return 0
}

这样处理,我们几乎就永远无须再修改 sortFeeds 方法了,需要调整优先级的时候去对 orderedSources 数组做做手脚就行,我们成功的通过“数据驱动”的方式将 sortFeeds 封闭了起来!

面向对象呢?

有些同学可能有一些疑问,当我们谈到设计模式、设计原则等概念的时候,经常会和面向对象的编程范式联系在一起,但本文并没有提到过多与其相关的东西。

实际上,OCP 并不与任何编程范式绑定在一起,无论是结构化编程、面向对象编程还是函数式编程,都可以通过践行 OCP 来实现优质的代码。

比如前面的 useTrackClick 一例中,React Hook 就是一种相当函数式的技术,但我们对埋点的封装却使用了 TrackEvent 这种更面向对象的方式来做,因此即便是一个程序也可以存在多种编程范式,建立在程序设计之上的各种原则便更不会落于桎梏。

但面向对象编程作为目前应用最广泛的编程范式,许多优质的设计原则实践理所当然的会出现这个范式下开发出的各种程序中,对开发者来是非常良好的学习资源,不过要记住的是,学习的是原则,而非公式。

总结

OCP 所蕴含的仅以本文是不足万分之一的,但本文希望通过一些较为实在的例子向大家阐释这种设计原则在开发稳定、易扩展、易维护的程序时所起到的指导作用。它不仅可以提升我们对需求的分析能力,也在不断提升着我们的实践经验,就像前面说的,许多时候经验会在应用 OCP 时占很大的比重。也正因如此,开发者需要不断的审视自己的代码,甚至不断的拆解它们来逐渐的找到最适合自己的 OCP 实践方式。

在实际需求研发过程中,程序复杂度自然会比文中所提到的各个例子复杂得多,但是越简单的例举,越容易让我们寻找到其中最原始的部分,这些部分往往是我们在抽象过程中的思维起点。因此笔者也建议大家在分析 OCP 的时候,可以先将需求按倒三角型的结构来寻找到最简单的部分作为基础着手研发,然后一层一层的回归到顶部来丰满整个功能。

而 OCP 这种寻找可变与不可变的思维方式,其实正是各种设计模式的基石,因此对 OCP 的理解,也有益于对各种设计模式的学习,裨益甚佳。

最后,希望拙文能有抛砖引玉之用,助大家去发掘充满智慧的设计原则金矿。