设计模式学习笔记 - 设计原则 - 2.开闭原则

前言

今天学习 SOLID 中的第二个原则:开闭原则。他是 SOLID 中最难理解、最难掌握的,同时又是最有用的一条原则。

在 23 种设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

在这里插入图片描述


如何理解“对扩展开放、修改关闭”?

开闭原则的英文全称是 Open Close Principle,简称 OCP。它的意思是:软件实体(模块、类、方法等)应该“对扩展开放,对修改关闭”。

详细描述一下就是,添加一个新功能应该是,在已有代码的基础上扩展代码(新增模块、类、方法等),而非修改已有的代码(修改模块、类、方法等)。

接下来,举一个 API 接口监控告警的例子。其中 AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SERVE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。

public class Alert {
	private AlertRule rule;
	private Notification notification;
	
	public Alert(AlertRule rule, Notification notification) {
		 this.rule = rule;
		 this.notification = notification;
	}
	
	public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
		long tps = requestCount / durationOfSeconds;
		if (tps > rule.getMatchedRule(api).getMaxTps()) {
			notification.notify(NotificationEmergencyLevel.URGENCY, "...");
		}
		if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
			notification.notify(NotificationEmergencyLevel.SERVE, "...");
		}
	}
}

上面这段代码非常简单,业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大值时,就会出发告警,通知接口的相关负责人或者团队。

现在,如果要添加一个功能,当每秒接口超时请求个数,超过预先设置的最大阈值时,也要出发告警发送通知。这个时候该如何修改代码?主要改动点有两处:

  • 第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCOunt ,表示超时接口请求数
  • 第二处是在 check() 函数中添加新的告警逻辑。
public class Alert {
	private AlertRule rule;
	private Notification notification;
	
	public Alert(AlertRule rule, Notification notification) {
		 this.rule = rule;
		 this.notification = notification;
	}
	
	// 改动一:添加参数 timeoutCount
	public void check(String api, long requestCount, long errorCount, long durationOfSeconds, long timeoutCount) {
		long tps = requestCount / durationOfSeconds;
		if (tps > rule.getMatchedRule(api).getMaxTps()) {
			notification.notify(NotificationEmergencyLevel.URGENCY, "...");
		}
		if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
			notification.notify(NotificationEmergencyLevel.SERVE, "...");
		}
		// 改动二:添加接口超时处理逻辑
		if (timeoutCount > rule.getMatchedRule(api).getMaxTimeoutCount()) {
			notification.notify(NotificationEmergencyLevel.URGENCY, "...");
		}
	}
}

这样的代码修改其实问题挺多的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的改动。另一方面,修改了 check() 函数,相应的单元测试都要修改。

上面的代码改动是基于“修改”的方式来实现新功能的。这不符合“对扩展开发,对修改关闭”的开闭原则。那该如何通过“扩展”的方式,来实现同样的功能呢?

先重构下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包括两部分:

  • 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类。
  • 第二部分是引入 Handler 概念,将 if 判断逻辑分散在这个 handler 中。
public class Alert {
	private List<AlertHandler> alertHandlers = new ArrayList<>();
	
	public void addAlertHandler(AlertHandler alertHandler) {
		this.alertHandlers.add(alertHandler);
	}
		
	public void check(ApiStatInfo apiStatInfo) {
		for (AlertHandler alertHandler : alertHandlers) {
			alertHandler.check(apiStatInfo);
		}
	}
}

public class ApiStatInfo { // 省略 getter/setter/constructor ...
	private String api;
	private long requestCount;
	private long errorCount;
	private long durationOfSeconds;
}

public abstract class AlertHandler {
	protected AlertRule alertRule;
	protected Notification notification;
	
	public AlertHandler(AlertRule rule, Notification notification) {
		 this.rule = rule;
		 this.notification = notification;
	}
	
	public abstract void check(ApiStatInfo apiStatInfo);
}

public class TpsAlertHandler extends AlertHandler {
	public TpsAlertHandler(AlertRule rule, Notification notification) {
		super(rule, notification);
	}
	
	@Override
	public void check(ApiStatInfo apiStatInfo) {
		long tps = apiStatInfo.getRequestCount() / apiStatInfo.getDurationOfSeconds();
		if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
			notification.notify(NotificationEmergencyLevel.URGENCY, "...");
		}
	}
}

public class ErrorAlertHandler extends AlertHandler {
	public ErrorAlertHandler(AlertRule rule, Notification notification) {
		super(rule, notification);
	}
	
	@Override
	public void check(ApiStatInfo apiStatInfo) {
		long tps = apiStatInfo.getRequestCount() / apiStatInfo.getDurationOfSeconds();
		if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
			notification.notify(NotificationEmergencyLevel.SERVER, "...");
		}
	}
}

上面的代码是对 Alert 的重构,重构之后的使用具体代码也写了。

其中,ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。

public class ApplicationContext {
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
        alertRule = new AlertRule();
        notification = new Notification();
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
    }

    public Alert getAlert() { return alert; }

    // 饿汉式单例
    private static final ApplicationContext instance = new ApplicationContext();
    private ApplicationContext() { initializeBeans(); }
    public static ApplicationContext getInstance() { return instance; }
}

public class Demo {
    public static void main(String[] args) {
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // 省略设置 apiStatInfo 数据值的代码
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒接受超时请求个数超过某个最大阈值,又该如何改动代码呢?主要有四处改动:

  • 第一处改动:在 ApiStatInfo 类中添加新的属性 timeoutCount
  • 第二处改动:添加新的 TimeoutAlertHandler 类。
  • 第三处改动:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 注册新的 timeoutAlertHandler
  • 第四处改动:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

改动之后的代码如下所示:

public class Alert { /*代码未改动...*/ }

public class ApiStatInfo { // 省略 getter/setter/constructor ...
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationOfSeconds;
    private long timeoutCount; // 改动一:添加字段
}

public abstract class AlertHandler { /*代码未改动...*/ }
public class TpsAlertHandler extends AlertHandler { /*代码未改动...*/ }
public class ErrorAlertHandler extends AlertHandler { /*代码未改动...*/ }
// 改动二:添加新的handler
public class TimeoutAlertHandler extends AlertHandler { /*省略代码...*/ }


public class ApplicationContext {
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
        alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
        notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
        // 改动三:注册新的handler
        alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
    }

    public Alert getAlert() { return alert; }

    // 饿汉式单例
    private static final ApplicationContext instance = new ApplicationContext();
    private ApplicationContext() { initializeBeans(); }
    public static ApplicationContext getInstance() { return instance; }
}

public class Demo {
    public static void main(String[] args) {
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // 省略设置 apiStatInfo 数据值的代码
        apiStatInfo.setTimeoutCount(100); // 改动四: 设置 timeoutCount 值
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

重构之后的代码更加灵活和易扩展。如果要添加新的告警逻辑,只需要基于扩展的方式创建新的 hanlder 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加测试单元,老的单元测试都不会失败,也不用修改。

修改代码就意味着违背开闭原则吗?

看了上面的重构之后的代码,你可能会有疑问:在添加新的告警逻辑时,尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的,但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,那改动一、三、四不就违背开闭原则了吗?

先分析改动一:往 ApiStatInfo 类中添加新的属性 timeoutCount

改动一不仅仅添加了属性,还添加了 getter/setter 方法。那给这个类添加新的属性和方法,算作“修改”还是“扩展”?

开闭原则可以应用在不同粒度的代码中,可以是模块、类,也可以是方法(及其属性)。同样一个代码改动,在粗粒度的代码中,被认定为修改,在细代码粒度下,又可以被认定为“扩展”。

就比如改动一,添加属性和方法相当于修改类,在类层面属于“修改”;但这个代码并没有修改已有方法和属性,在方法(及属性)层面,又可以被认定是“扩展”。

实际上,我们不必纠结某个代码的改动是“修改”还是“扩展”。应回到设计原则的初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。

再分析下改动三和改动四:在 ApplicationContrext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

这两处的改动都是在方法内保进行的,不管从哪个层面(类、方法、属性)来讲,都不能算是扩展,而是修改。不过,有些修改是在所难免的,可以接受。这是为什么呢?

在重构 Alert 代码中,我们的核心逻辑在 Alert 类及其各个 Handler 中,当我们再添加新的告警逻辑的时候,Alert 类完全不需要修改,而只需扩展一个新的 Handler 类。如果把 Alert 及各个 Handler 类合起来看做一个“模块”,那模块本身在添加新的功能时,完全满足开闭原则。

而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并做一些初始化操作,才能构件成可运行的程序。我们要做的是尽量让修改更集中、更少、更上层,尽量让最核心和最复杂的那部分代码满足开闭原则。

如何做到“对扩展开放、修改关闭”?

对于刚刚的例子,如果没有太多的代码设计和开发经验,可能会有这样的疑问:这样的代码设计思路,我怎么想不到呢?

这就需要理论知识和实战经验,需要慢慢学习和积累。对于如何做到“对扩展开放,对修改关闭”,也有一些指导思想和具体的方法论,我们一起看一下。

实际上,开闭原则讲的就是代码的可扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码,在应对需求变化时,能做到“对扩展开放,对修改关闭”,那就说明这段代码的扩展性好。

为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要

在写代码的时候,我们要多花点时间往前思考下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码的整体结构、做到最小代码改动的情况下,新的代码能够灵活地插入到扩展点上。

还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化时,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。

现在,我们再来看下开闭原则更加具体的方法论。

在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。今天重点讲下,如何利用多态、依赖注入、基于接口而非实现编程,来实现:对扩展开放、对修改关闭。

实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同角度、不同层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”。

接下来,通过一个例子来解释下,如何利用这几个设计思想或原则来实现“对扩展开放,对修改关闭”。

比如,我们代码中通过 Kafka 来发送异步消息。对于这样的一个功能的开发,我们要学会将其抽象成一组跟具体消息队列无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka 换成 RocketMQ,可以很方便的拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:

// 这一部分体现了抽象意识
public interface MessageQueue { /*...*/ }
public class KafkaMessageQueue implements MessageQueue { /*...*/ }
public class RocketMQMessageQueue implements MessageQueue { /*...*/ }

public interface MessageFormatter { /*...*/ }
public class JSONMessageFormatter implements MessageFormatter { /*...*/ }
public class XMLMessageFormatter implements MessageFormatter { /*...*/ }

public class Demo {
    private MessageQueue messageQueue; // 基于接口而非实现编程
    public Demo(MessageQueue messageQueue) { // 依赖注入
        this.messageQueue = messageQueue;
    }
    
    // msgFormatter: 多态、依赖注入
    public void sendNotification(Notigfication notification, MessageFormatter msgFormatter) {
        // ...
    }
}

如何在项目中灵活应用开闭原则?

前面我们提到,写出支持“对扩展开放,对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?

如果你开发的是业务导向系统,比如金融系统、电商系统、物流系统等,要识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下及未来可能要支持的业务需求。如果你开发的是和业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解它们被如何使用?你今后打算添加哪些新功能?使用者未来会有哪些更多的功能需求?

不过,即便我们对业务、对系统有足够了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点的也是有成本的。没必要为一些不一定发生的需求去提前买单,过度设计。

合理的做法是,对一些比较确定的、短期内可能会扩展的,或者需求改动对代码结构影响较大的情况,或者实现成本不高的,在编写代码的时候,我们可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展,我们可以等到有需求驱动的时候,再通过重构的方式来支持扩展的需求。

而且,开闭原则不是免费的。有些情况下,扩展性和可读性相冲突。比如,之前的 Alert 告警的例子。为了更好的支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂的多,理解起来也更有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,扩展性更重要,我们可以适当地牺牲一些代码的可读性。有些场景下,可读性更加重要,那我们就要适当地牺牲一些代码的可扩展性。

在之前的 Alert 告警例子中,如果告警的规则不是很多、也不复杂,那 check() 函数中的 if 语句就不会很多,代码也不复杂,最初的第一种代码实现思路,是比较合理的选择。相反,如果告警规则很多、很复杂,if 语句、代码逻辑就会很多、很复杂,相应的代码行数也会很多,可读性、可维护性就会变差,那重构之后的代码实现思路就更加合理了。

CSDN海神之光上传的代码均可运行,亲测可用,直接替换数据即可,适合小白; 1、代码压缩包内容 主函数:main.m; 调用函数:其他m文件;无需运行 运行结果效果图; 2、代码运行版本 Matlab 2019b或2023b;若运行有误,根据提示修改;若不会,私信博主; 3、运行操作步骤 步骤一:将所有文件放到Matlab的当前文件夹中; 步骤二:双击打开main.m文件; 步骤三:点击运行,等程序运行完得到结果; 4、仿真咨询 如需其他服务,可私信博主或扫描博客文章底部QQ名片; 4.1 博客或资源的完整代码提供 4.2 期刊或参考文献复现 4.3 Matlab程序定制 4.4 科研合作 功率谱估计: 故障诊断分析: 雷达通信:雷达LFM、MIMO、成像、定位、干扰、检测、信号分析、脉冲压缩 滤波估计:SOC估计 目标定位:WSN定位、滤波跟踪、目标定位 生物电信号:肌电信号EMG、脑电信号EEG、心电信号ECG 通信系统:DOA估计、编码译码、变分模态分解、管道泄漏、滤波器、数字信号处理+传输+分析+去噪(CEEMDAN)、数字信号调制、误码率、信号估计、DTMF、信号检测识别融合、LEACH协议、信号检测、水声通信 1. EMD(经验模态分解,Empirical Mode Decomposition) 2. TVF-EMD(时变滤波的经验模态分解,Time-Varying Filtered Empirical Mode Decomposition) 3. EEMD(集成经验模态分解,Ensemble Empirical Mode Decomposition) 4. VMD(变分模态分解,Variational Mode Decomposition) 5. CEEMDAN(完全自适应噪声集合经验模态分解,Complementary Ensemble Empirical Mode Decomposition with Adaptive Noise) 6. LMD(局部均值分解,Local Mean Decomposition) 7. RLMD(鲁棒局部均值分解, Robust Local Mean Decomposition) 8. ITD(固有时间尺度分解,Intrinsic Time Decomposition) 9. SVMD(逐次变分模态分解,Sequential Variational Mode Decomposition) 10. ICEEMDAN(改进的完全自适应噪声集合经验模态分解,Improved Complementary Ensemble Empirical Mode Decomposition with Adaptive Noise) 11. FMD(特征模式分解,Feature Mode Decomposition) 12. REMD(鲁棒经验模态分解,Robust Empirical Mode Decomposition) 13. SGMD(辛几何模态分解,Spectral-Grouping-based Mode Decomposition) 14. RLMD(鲁棒局部均值分解,Robust Intrinsic Time Decomposition) 15. ESMD(极点对称模态分解, extreme-point symmetric mode decomposition) 16. CEEMD(互补集合经验模态分解,Complementary Ensemble Empirical Mode Decomposition) 17. SSA(奇异谱分析,Singular Spectrum Analysis) 18. SWD(群分解,Swarm Decomposition) 19. RPSEMD(再生相移正弦辅助经验模态分解,Regenerated Phase-shifted Sinusoids assisted Empirical Mode Decomposition) 20. EWT(经验小波变换,Empirical Wavelet Transform) 21. DWT(离散小波变换,Discraete wavelet transform) 22. TDD(时域分解,Time Domain Decomposition) 23. MODWT(最大重叠离散小波变换,Maximal Overlap Discrete Wavelet Transform) 24. MEMD(多元经验模态分解,Multivariate Empirical Mode Decomposition) 25. MVMD(多元变分模态分解,Multivariate Variational Mode Decomposition)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值