i. 什么是开闭原则?
The Open/Close Principle,OCP
Software entities (modules, classes, functions, etc) should be open for extension, but closed for modification.
软件中的实体(类、模块、函数等等)应该对于扩展是开放的,但是对于修改是封闭的。
应该尽量通过扩展软件实体的行为来应对变化,满足新的需求,而不是通过修改现有代码来完成变化。
(开闭原则的一种描述)
开闭原则是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
(进行的是何种约束)
软件中的实体(类、模块、函数等等)应该对于扩展是开放的,但是对于修改是封闭的。
ii. 为什么要遵守开闭原则?
「软件」--[迭代]-->「修改」--(复杂度)-->「耦合」
一个产品只要在其生命周期内,都会不断发生变化。发生变化是一个既定的事实,发生改变会使系统变复杂,复杂会使模块间的耦合度上升。
所以我们需要让软件能够适应变化,在设计时尽可能的兼容这些变化发生的可能性,寻找一种方法能够使模块既可以扩大其职责范围,同时还保持良好(简洁,内聚)的状态。提高系统的扩展性和灵活性,当变化发生时,依旧保持系统的稳定性。
我们遵守设计原则,实践设计模式的目的是建立更加稳定灵活的系统,开闭原则在其中起到的作用是使系统具有扩展性。开闭原则所推崇(或者说要求)的是模块(业务)的确定性和稳定性。我们可以修改模块代码的缺陷(俗称叫修BUG),但不要随意调整模块的业务范畴,增加功能或者减少功能都不鼓励。
开闭原则认为业务变更是需要极其谨慎的,与其修改模块的业务,不如实现一个新的业务。只要业务的分解一直被正确执行(模块划分合理)的话,实现一个新的业务模块来负责新的业务范畴,是一件极其轻松且正确的事情。
开闭原则鼓励写只读的业务模块,一经设计就不可修改,如果要修改业务就直接废弃掉它,转而实现新的业务模块。
同时,只读能够让我们沉淀积累越来越多可复用的业务模块,通过不同模块的组合实现上层业务的组装。
这种只读的思想有很多实践。例如 git 的版本管理、基于容器的服务治理都是通过只读的设计来改善系统的治理难度。
开闭原则强调的是把变化点抽离出来,封装给其他模块,与单一职责原则所描述的是相同问题的不同层面。
iii. 怎样才算遵守开闭原则?
iii.i. 扩展?修改?
怎样的代码改动叫做「扩展」?
怎样的代码改动叫做「修改」?
修改代码一定会违反「开闭原则」吗?
enum NotificationEmergencyLevel {SEVERE = 'SEVERE',URGENCY = 'URGENCY',NORMAL = 'NORMAL',TRIVIAL = 'TRIVIAL',}class AlertRule {}class Notification {}class Alert {rule: AlertRule;notification: Notification;constructor(rule: AlertRule, notification: Notification) {this.rule = rule;this.notification = notification;}check(api: string,requestCount: number,errorCount: number,durationOfSeconds?: number = 1) {const tps: number = requestCount / durationOfSeconds;if (tps > rule.getMatchedRule(api).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "紧急");}if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, "严重");}}}const alert = new Alert();alert.check(...);
这段代码逻辑很简单,有一个 Alert 警告类,我们只关注其中的 check 方法,其他几个类和方法以及具体的通知行为我们不做讨论。
方法会根据 api 确定选择对应的规则并作判断,当 tps 大于阈值时,报紧急;当错误数大于阈值时,报严重。
很简单,很直接,理解一下有没有问题。
那好现在需求变了,“哎呀,我们最近超时的接口很多,我们都不知道,也加个警报吧”,我们怎么改
// ...check(api: string,requestCount: number,errorCount: number,// AtimeoutCount: number,// AdurationOfSeconds?: number = 1,) {const tps: number = requestCount / durationOfSeconds;if (tps > rule.getMatchedRule(api).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, '紧急');}if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, '严重');}// Bconst timeoutTps = timeoutCount / durationOfSeconds;if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, '紧急');}// B}// ...
那么这个改动有没有什么问题?
接口 check 接口发生了改变,对应的,单元测试和已经存在的调用都需要调整。(没问题吧)
check(api: string, requestCount: number, errorCount: number, durationOfSeconds?: number): void;check(api: string, requestCount: number, errorCount: number, timeoutCount: number, durationOfSeconds?: number): void;
那有人说,我调一下参数顺序,反正是新加的我放到最后作为可选,不就行了,大家想一下是不是这样就没问题了。
(当然不是 -.-)
这叫什么?修改?还是扩展?
现在给这段代码重构一下
/*** Api 信息类*/interface ApiStatInfo {api: string;requestCount: number;errorCount: number;durationOfSeconds: number;}/*** 检查器*/abstract class AlertHandler {rule: AlertRule;notification: Notification;constructor(rule: AlertRule, notification: Notification) {this.rule = rule;this.notification = notification;}abstract check(apiStatInfo: ApiStatInfo): void;}/*** 警告类*/class Alert {alertHandlers: AlertHandler[] = [];addHandler(alertHandler: AlertHandler) {this.alertHandlers.push(alertHandler);}check(apiStatInfo: ApiStatInfo) {for (const handler of this.alertHandlers) handler.check(apiStatInfo);}}/*** tps 检查器*/class TpsAlertHandler extends AlertHandler {constructor(rule: AlertRule, notification: Notification) {super(rule, notification);}check({ requestCount, durationOfSeconds, api }: ApiStatInfo) {const tps: number = requestCount / durationOfSeconds;if (tps > rule.getMatchedRule(api).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "紧急");}}}/*** 异常检查器*/class ErrorAlertHandler extends AlertHandler {// ...check({ errorCount, api }: ApiStatInfo) {if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, '严重');}}}const alert = new Alert();alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));alert.check(...);
现在再进行一次上面的修改
/*** Api 信息类*/interface ApiStatInfo {api: string;requestCount: number;errorCount: number;durationOfSeconds: number;// <- AtimeoutCount: number;// -> A}abstract class AlertHandler {...}class Alert {...}class TpsAlertHandler extends AlertHandler {...}class ErrorAlertHandler extends AlertHandler {...}// <- Bclass TimeoutAlertHandler extends AlertHandler {// ...check({ timeoutCount, durationOfSeconds, api }: ApiStatInfo) {const timeoutTps = timeoutCount / durationOfSeconds;if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, '紧急');}}}// -> Bconst alert = new Alert();alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));// <- Calert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));// -> Calert.check(...);
我们再来看一看发生的改动。
A
软件中的实体(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。
给 ApiStatInfo 添加了 timeoutCount,对类来说,我们进行了修改;
但是我们把粒度缩小,到类的内部去看,增加的属性并没有干扰到原本的逻辑,没有修改原有的属性和方法,没有破坏原本单元测试,从这一层来看我们进行的是扩展。
(粗暴一些,除了新加的部分测试覆盖度和通过率没受影响 √)
B
扩展 没啥疑问吧
C
改动 没啥疑问吧
(对 Alert 没有影响,但是对 const alert = new Alert(); 的上下文作了修改)
重构之后,当我们添加新的规则时,Alert 完全不需要修改,只需要扩展一个新的 Handler,我们将 Alert 和 Handler 合并起来视为一个模块,那么这个模块完全满足开闭原则。
同时,改变一个功能,不可能任务实体(类、模块、函数等等)都不发生修改,很明显做不到,功能不能凭空蹦出来(除非是依赖某个 BUG 实现的功能)。
iii.i. 核心
在第一部分我们提到过,“开闭原则是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则”,开闭原则是一种约束;第二部分我们理解了开闭原则是一种思想。事实上经典的设计模式大部分都是为了解决代码的扩展性而总结的方法,这些方法都实际上都是在实践开闭原则,也就是这些方法指导我们如何理解上面的几个问题。
我们根据「变化」对代码进行划分,将代码分为可变和不可变两个部分。(哪个部分是修改哪个部分是扩展?)
(使用上面的例子分析一下这两部分)
iv. 怎么对扩展开放,对修改封闭?
扩展 抽象 封装
区分代码的可变和不可变部分,对可变的部分进行封装,隔离变化点,提供抽象的不可变接口,给上层使用。
提供抽象的不可变接口,所指的是将可变的部分进行抽象,从中提取出不可变的接口,并不是对不可变的部分进行抽象。
v. 合理
不论业务系统还是框架、组件和类,想要做出合理的设计都要对业务场景、使用场景有足够的了解,只有了解了「要做什么」「为什么这么做」才能准确的识别出可能的变化和扩展。(系统可能会扩展什么功能、框架可能被如何使用、可能期望哪些新的功能)
假设我们现在具备这样的视野和能力(一眼望过去我知道之后的所有可能性),那我们要为所有的变化点预留扩展方式吗?(当然不是)
为所有的变化预留扩展,代码中会充斥着大量的设计,各种原则和设计模式的实践,有一些可能在不久的将来就发挥了它的作用,但是有些点可能软件的生命周期结束了都没用上。要合理的进行设计,不要为一些臆想的需求提前优化,导致过度设计。
开闭原则在提高扩展性的同时也会降低可读性。
vi. 实践
单一职责原则 实现类要职责单一
里氏替换原则 不要破坏继承体系
依赖倒置原则 面向接口编程
接口隔离原则 设计接口的时候要精简单一
迪米特法则 降低耦合
参考资料:
https://time.geekbang.org/column/article/175236
https://zixizheng.net/?p=836
https://www.codetd.com/article/12166738
https://segmentfault.com/a/1190000013123183
https://doc.yonyoucloud.com/doc/wiki/project/java-design-pattern-principle/principle-6.html
https://www.kancloud.cn/sstd521/design/193512
http://liumh.com/2021/08/26/design-principles-ocp-md/
https://time.geekbang.org/column/article/176075?utm_term=zeusA8LQ4&utm_source=wangzhenglaoyonghu&utm_medium=liquan
