这是理解
SOLID
原则,介绍什么是
开闭原则以及它为什么能够在对已有的软件系统或者模块提供新功能时,避免不必要的更改(重复劳动)。
开闭原则是什么
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.软件实体(类、模块、函数等)都应当对扩展具有开放性,但是对于修改具有封闭性。
首先,我们假设在代码中,我们已经有了若干抽象层代码,比如类、模块、高阶函数,它们都仅做一件事(还记得单一职责原则吗?),并且都做的十分出色,所以我们想让它们始终处于简洁、高内聚并且好用的状态。
但是另一方面,我们还是会面临改变,这些改变包含范围(译者注:应当是指抽象模块的职责范围)的改变,新功能的增加请求还有新的业务逻辑需求。
所以对于上面我们所拥有的抽象层代码,在长期想让它处于一成不变的状态是不现实的,你不可避免的会针对以上的需要作出改变的需求,增加更多的功能,增加更多的逻辑和交互。在上一篇文章,我们知道,改变会使系统复杂,复杂会促使模块间的耦合性上升,所以我们迫切地需要寻找一种方法能够使我们的抽象模块不仅可以扩大它的职责范围,同时还能够保持当前良好的状态(简洁、高内聚、好用)。
这便是开闭原则存在的意义,它能够帮助我们完美地实现这一切。
如何实践开闭原则
当你需要对已有代码作出一些修改时,请切记以下两点:
- 保持函数、类、模块当前它们本身的状态,或者是近似于它们一般情况下的状态(即不可修改性)
- 使用组合的方式(避免使用继承方式)来扩展现有的类,函数或模块,以使它们可能以不同的名称来暴露新的特性或功能
这里关于继承,我们特意增加了一个注释,在这种情况下使用继承可能会使模块之间耦合在一起,同时这种耦合是可避免的,我们通常在一些预先有着良好定义的结构上使用继承。(译者注:这里应该是指,对于我们预先设计好的功能,推荐使用继承方式,对于后续新增的变更需求,推荐使用组合方式)
举个例子(译者注:我对这里的例子做了一些修改,原文中并没有详细的说明)
interface IRunner {
run: () => void;
}
class Runner implements IRunner {
run(): void {
console.log("9.78s");
}
}
interface IJumper {
jump: () => void;
}
class Jumper implements IJumper {
jump(): void {
console.log("8.95,");
}
}
例子中,我们首先声明了一个IRunner
接口,之后又声明了IJumper
,并分别实现了它们,并且实现类的职能都是单一的。
假如现在我们需要提供一个既会跑又会跳的对象,如果我们使用继承的方式,可以这么写
class RunnerAndJumper extends Runner {
jump: () => void
}
或者
class RunnerAndJumper extends Jumper {
run: () => void
}
但是使用继承的方式会使这个RunnerAndJumper
与Runner
(或者Jumper
)耦合在一起(耦合在一起的原因是因为它会因它的父类改变而改变),我们再来用组合的方式试试看,如下:
class RunnerAndJumper {
private runnerClass: IRunner;
private jumperClass: IJumper;
constructor(runner: IRunner, jumper: IJumper) {
this.runnerClass = new runner();
this.jumperClass = new jumper();
}
run() {
this.runnerClass.run();
}
jump() {
this.jumperClass.jump();
}
}
我们在RunnerAndJumper
的构造函数中声明两个依赖,一个是IRunner
类型,一个是IJumper
类型。
最终的代码其实和依赖倒置原则中的例子很像,而且你会发现,RunnerAndJumper
类本身并没有与任何别的类耦合在一起,它的职能同样是单一的,它是对一个即会跑又会跳的实体的抽象,并且这里我们还可以使用DI(依赖注入)
技术进一步的优化我们的代码,降低它的耦合度。
反思
开闭原则所带来最有用的好处就是,当我们在实现我们的抽象层代码时,我们就可以对未来可能需要作出改变的地方拥有一个比较完整的设想,这样当我们真正面临改变时,我们所对原有代码的修改,更贴近于改变本身,而不是一味的修改我们已有的抽象代码。
在这种情况下,由于我们节省了不必要的劳动和时间,我们就可以将更多的精力投入到关于更加长远的事宜计划上面,而且可以针对这些事宜需要作出的改变,提前和团队沟通,最终给予一套更加健壮、更符合系统模块本身的解决方案。
在整个软件开发周期中(比如一个敏捷开发周期),你对于整个周期中的事情了解的越透彻、越多,则越好。身为一个工程师,在一个开发冲刺中,为了在冲刺截止日期结束前,实现一个高效的、可靠的系统,你不会期望作出太多的改变,因此往往你可能会“偷工减料”。
从另一个角度来讲,我们也应当致力于在每一次面临需求变更的情况下,不需要一而再,再而三的更改我们已有的代码。所有新的功能都应当通过增加一个新的组合类或方法实现,或者通过复用已有的代码来实现。
插件与中间件
充分贯彻开闭原则的另一个例子,便是插件与中间件架构,我们可以从三个角度来简单分析这种架构是如何运作的:
- 内核或者容器:往往是核心功能的实现的前提,一般会成为整个系统最核心的部分
- 插件:在实现容器的基础上,往往一些核心功能都是以内置的插件实现的,并且,通过实现一套通用的网关类接口,我们可以使插件具有可插拔性,这样在需要新增特性和功能时,只需要实现新的插件并添加到容器即可,比如支持插件扩展功能的浏览器
Chrome
。 - 中间件:中间件我们可以通过一个例子来说明,比如我们拥有一个请求 - 响应周期,我们可以通过中间件,在周期中添加中间业务逻辑,以便为应用程序提供额外的服务或横切关注点,比如
Redux
、express
还有很多框架都支持这样的功能。
总结
希望这篇文章能够帮助你学会如何应用开闭原则并且从中收益。设计一个具有可组合性的系统,同时提供具有良好定义的扩展接口,是一种非常有用的技术,这种技术最关键的地方在于,它使我们的系统能够在保持强健的同时,提供新功能、新特性,但是却不会影响它当前的状态。
译者注
开闭原则是面向对象编程中最重要的原则之一,有多重要呢?这么说吧,很多的设计原则和设计模式所希望达成的最终状态,往往符合开闭原则,因此许多原则都可以作为实现开闭原则的一种手段,在原文的例子中,我们可以很明显的体会到,在实现开闭原则所提倡的理念的过程中,我们不经意地使用之前两篇文章中涉及的原则,比如:
- 保持对象的单一性(单一职责)
- 实现依赖于抽象(依赖倒置原则)
我之前一直是做后端相关工作的,所以对于开闭原则接触较早,这两年转行做了前端,随着nodejs
的发展,框架技术日新月异,但是其中脱颖而出的优秀框架往往是充分贯彻了开闭原则,比如express
、webpack
还有状态管理容器redux
,它们均是开闭原则的最佳实践。
另外一方面,在这两年的工作也感受到,适当的使用函数式编程的思想,往往是贯彻开闭原则一个比较好的开始,因为函数式的编程中的核心概念之一便是compose(组合)
。以函数式描述业务往往是原子级的指令,之后在需要描述更复杂的业务时,我们复用并组合之前已经存在的指令以达到目的,这恰恰符合开闭原则所提倡的可组合性。
最后再分享一些前端工作中,经常需要使用开闭原则的最佳业务场景,
- UI组件的表单组件:对于表单本身以容器来实现,表单项以插件来实现,这样对于表单项如何渲染、如何加载、如何布局等功能,均会封闭与表单容器中,而对于表单项如何校验、如何取值、如何格式化等功能,则会开放与表单项容器中。
- API服务:一般我们可能会在项目中提供自定义修改请求头部的工具方法,并在需要的时候调用。但这其实是一种比较笨的方法,如果可能的话,建议使用拦截器来完成这项任务,不仅会提供代码的可读性,同时还会使发接口的业务层代码保持封闭。
-
事件驱动模型:对于一些复杂的事件驱动模型,比如拖拽,往往使用开闭原则会达到意想不到的效果。最近有一个比较火的拖拽库draggable,提供的拖拽体验相比其他同类型的库简直不是一个级别。我前段时间去读它的源码,发现它之所以强大,是因为在它内部,针对多种拖拽事件,封装了独立的事件发射器(其内部称作
Sensor
),之后根据这些发射器指定了一套独立的抽象事件驱动模型,在这个模型基础上,针对不同的业务场景提供不同的插件,比如:- 原生拖拽(Draggable)
- 拖拽排序(Sortable)
- 拖拽放置(Droppable)
- 拖拽交换(Swappable)
- 还有若干提高用户体验的其他插件,这一切均是以开闭原则而实现的。
能想到的大概就这么多,希望可以抛砖引玉,如有错误,还望指正。
关注公众号 全栈101,只谈技术,不谈人生