Skip to content
快速预览

开闭原则(OCP)

✍️ chunyi.wang 🕒 2023-07-26 10:00:50(10 months ago) 🔗 G.设计模式

开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。中文意思:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。是面向对象设计原则的一种,开闭原则也是面向对象的可复用设计的基石。

通俗话理解添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等) 这个程度可以理解为(当增加了新功能,原代码的功能可以在不修改测试代码的情况下继续测试,新的功能只要新增测试用例就可以)

开闭原则问题由来

在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。

扩展和修改

开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动

在粗代码粒度下,被认定为修改

在细代码粒度下,又可以被认定为扩展,例如,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为修改但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为扩展

也可以理解为修改还是扩展 取决于 不破坏原有代码,对原有代码的侵入最低,可以说是基于扩展而不是修改或者说 只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试

具体来说,开放封闭原则要求我们在设计软件时,应该尽量使用抽象化和多态等技术,将变化的部分抽象出来,形成稳定的抽象层,而将不变的部分留给具体的实现。这样,当需要添加新功能时,只需要添加新的实现类,而不需要修改原有的代码,从而实现了对修改关闭,对扩展开放的目标。

开放封闭原则是面向对象设计中的一个重要原则,它可以提高代码的可维护性、可扩展性和可重用性,降低代码的复杂度和维护成本。

案例代码

一个案例一段 API 接口监控告警的代码业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队

java
public class Alert {
	private AlertRule rule; // 存储告警规则
	private Notification notification;

	// 收集规则
	public Alert(AlertRule rule, Notification notification) {
		this.rule = rule;
		this.notification = notification;
	}

	/**
	 * 
	 * @param api               接口
	 * @param requestCount      请求数量
	 * @param errorCount        错误数量
	 * @param durationOfSeconds 持续时间
	 */
	public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {

		// 计算 服务器每秒处理的事务数
		long tps = requestCount / durationOfSeconds;

		// 处理的事务数tps 大于 传入规则 收集
		if (tps > rule.getMatchedRule(api).getMaxTps()) {
			// NotificationEmergencyLevel
			// 表示通知的紧急程度其中SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要)
			notification.notify(NotificationEmergencyLevel.URGENCY, "...");
		}

		// 报错条数 大于 传入规则 收集
		if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
			notification.notify(NotificationEmergencyLevel.SEVERE, "...");
		}
	}
}

现在需要,添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知,这里我们就需要手动修改 check 方法,这种做法最简单也最危险,你并不知道会影响那部分代码

改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑

java
public class Alert {
  // ...省略AlertRule/Notification属性和构造函数...
  
  // 改动一:添加参数timeoutCount
  public void check(String api, long requestCount, long errorCount, long timeoutCount, 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.SEVERE, "...");
    }
    // 改动二:添加接口超时处理逻辑
    long timeoutTps = timeoutCount / durationOfSeconds;
    if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
      notification.notify(NotificationEmergencyLevel.URGENCY, "...");
    }
  }
}

并且这种改动带来了问题,我们增加了方法的入参,对应之前的位置都需要进行修改,如果做了单测那么之前的单测也需要重写。

这类的操作简单粗暴的修改,带入开闭原则,也就是 对扩展开放、对修改关闭 。看了并不是明智的,采用另一种形式 扩展开放

对上面代码要解决的两个问题

第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;

第二部分在原有的 Alert 类中,check() 方法中包含了多个 if 判断逻辑,这些逻辑判断都是针对不同的告警规则的,这样会导致代码的可读性和可维护性变差。为了解决这个问题,可以引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中

具体来说,我们可以定义一个 AlertHandler 抽象类,然后让 TpsAlertHandler 和 ErrorAlertHandler 类分别继承自 AlertHandler 类,并且实现自己的 check() 方法。这样,当需要添加新的告警规则时,只需要添加新的 AlertHandler 子类,并且实现自己的 check() 方法即可,不需要修改原有的代码。这样就实现了对修改关闭,对扩展开放的目标。

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

// 可以定义一个 AlertHandler 抽象类
public abstract class AlertHandler {
  protected AlertRule rule;
  protected Notification notification;
  public AlertHandler(AlertRule rule, Notification notification) {
    this.rule = rule;
    this.notification = notification;
  }
  public abstract void check(ApiStatInfo apiStatInfo);
}

// 继承自 AlertHandler 类,并且实现自己的 check() 方法
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, "...");
    }
  }
}

// 继承自 AlertHandler 类,并且实现自己的 check() 方法
public class ErrorAlertHandler extends AlertHandler {
  public ErrorAlertHandler(AlertRule rule, Notification notification){
    super(rule, notification);
  }

  @Override
  public void check(ApiStatInfo apiStatInfo) {
    if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
      notification.notify(NotificationEmergencyLevel.SEVERE, "...");
    }
  }
}

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 handler : alertHandlers) {
      handler.check(apiStatInfo);
    }
  }
}

下面开始使用,重构后的代码nApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。

它对扩展开放,对修改关闭。具体来说,当我们需要增加一种新的警报类型时,只需要编写一个新的 AlertHandler 类型的警报处理器,并将其添加到 Alert 对象中即可,不需要修改 Alert 类的源代码。这样就避免了修改 Alert 类的源代码,从而避免了对现有代码的影响。

另外,ApplicationContext 类中的 initializeBeans 方法负责初始化 AlertRule、Notification 和 Alert 对象,并将 TpsAlertHandler 和 ErrorAlertHandler 警报处理器添加到 Alert 对象中。这样,Demo 类中的 main 方法只需要调用 ApplicationContext.getInstance().getAlert().check(apiStatInfo) 方法即可,无需关心 AlertRule、Notification 和 Alert 对象的初始化过程。这样做符合单一职责原则和依赖倒置原则,使得代码更加清晰、简洁、易于维护。

java
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。

第二处改动是:添加新的 TimeoutAlertHander 类。

第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。

第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

java
public class Alert { // 代码未改动... }
public class ApiStatInfo {//省略constructor/getter/setter方法
  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 class Demo {
  public static void main(String[] args) {
    ApiStatInfo apiStatInfo = new ApiStatInfo();
    // ...省略apiStatInfo的set字段代码
    apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值
    ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}

我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。

改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”,这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动

改动三和改动四,这两处改动都是在方法内部进行的,不能算是“扩展”,而是地地道道的“修改”。然而,在重构之后的 Alert 代码中,核心逻辑集中在 Alert 类及其各个 handler 中,当需要添加新的警告逻辑时,Alert 类完全不需要修改,只需要扩展一个新的 handler 类。因此,如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,那么模块本身在添加新的功能时,完全满足开闭原则。

案例二

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

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

public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}

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

在实践中实现途径

  1. 编程行为 -抽象化和多态:通过抽象化和多态等技术,将变化的部分抽象出来,形成稳定的抽象层,而将不变的部分留给具体的实现。这样,当需要添加新功能时,只需要添加新的实现类,而不需要修改原有的代码,从而实现了对修改关闭,对扩展开放的目标。

  2. 编程行为 -接口和抽象类:通过使用接口或抽象类,我们可以定义一个固定的行为或规范。然后,具体的实现可以随着需求的变化而变化,或者可以添加新的实现,而不需要改变基于接口或抽象类的代码。

  3. 编程行为 -依赖注入(Dependency Injection): 依赖注入是一种软件设计模式,它实现了控制反转(IoC)并鼓励代码的松耦合。依赖注入允许客户端消除硬编码的依赖并通过外部方式进行提供,有助于使代码对扩展开放,对修改关闭。

  4. 编程行为 -配置文件和反射机制:通过配置文件和反射机制等技术,将程序的配置信息和具体实现分离开来,从而实现了对修改关闭,对扩展开放的目标。

  5. 设计模式 - 模板方法模式:通过模板方法模式等设计模式,将稳定的算法框架和变化的部分分离开来,从而实现了对修改关闭,对扩展开放的目标。

  6. 设计模式 - 策略模式(Strategy Pattern): 策略模式定义了一系列的算法,并将每一个算法封装起来,使它们可以互相替换。策略模式让算法可独立于使用它的客户而变化。在策略模式中,我们可以通过创建新的策略类来添加新的算法,而不需要修改原有的代码。

  7. 设计模式 - 装饰器模式(Decorator Pattern): 装饰器模式可以在不改变原有对象的基础上,动态地给对象添加新的功能。当需要新增功能时,我们只需要添加一个新的装饰类,而不需要修改原有的代码。

  8. 设计模式 - 观察者模式(Observer Pattern): 观察者模式定义了对象之间的一对多依赖,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。我们可以通过添加新的观察者类来增加新的依赖者,而不需要修改原有的代码。

  9. 设计模式 - 工厂模式(Factory Pattern)和抽象工厂模式(Abstract Factory Pattern): 工厂模式和抽象工厂模式提供了一个接口,用于创建对象,但是让子类决定实例化哪一个类。这样我们可以在不修改现有代码的前提下,添加新的类。

总结

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

开闭原则是判断一段代码是否易扩展的“金标准”,如果一段代码能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。为了写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识,多花点时间往前多思考一下,留好扩展点,以便在未来需求变更的时候,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)都是常用来提高代码扩展性的方法。

文章给的建议

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

开发人员应该仅对程序中呈现出现频繁变化的那些部分做出抽象,然而对于应用程序中的每个部分都刻意地进行抽象同样不是一个好主意,拒绝不成熟的抽象和抽象本身一样重要。开闭原则,可以保证以前代码的正确性,因为没有修改以前代码,所以可以保证开发人员专注于将设计放在新扩展的代码上。

为了写出支持“对扩展开放、对修改关闭”的代码,我们需要预留扩展点,但是不可能识别出所有可能的扩展点,因此需要在编写代码的时候,对一些比较确定的、短期内可能就会扩展、或者需求改动对代码结构影响比较大的情况、或者实现成本不高的扩展点进行扩展性设计。对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。同时,我们也需要在扩展性和可读性之间做出权衡,根据实际的应用场景来决定。

参考

理论二:如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?

开闭原则OCP

Released under the MIT License.