关于设计原则:开闭原则OCP的理解与灵活应用

36次阅读

共计 9508 个字符,预计需要花费 24 分钟才能阅读完成。

开闭准则可能是 SOLID 中 最难了解 最难把握 ,同时也是 最有用 的一条准则。

  • 之所以说这条准则难了解,那是因为,“怎么的代码改变才被定义为‘扩大’?怎么的代码改变才被定义为‘批改’?怎么才算满足或违反‘开闭准则’?批改代码就肯定意味着违反‘开闭准则’吗?”等等这些问题,都比拟难了解。
  • 之所以说这条准则难把握,那是因为,“如何做到‘对扩大凋谢、批改敞开’?如何在我的项目中灵便地利用‘开闭准则’,以防止在谋求扩展性的同时影响到代码的可读性?”等等这些问题,都比拟难把握。
  • 之所以说这条准则最有用,那是因为,扩展性是代码品质最重要的衡量标准之一 。在 23 种经典设计模式中, 大部分设计模式都是为了解决代码的扩展性问题而存在的,次要听从的设计准则就是开闭准则

开闭准则的英文全称是 Open Closed Principle(OCP)。它的英文形容是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。

用中文具体表述下就是:增加一个新的性能应该是,在已有代码根底上扩大代码(新增模块、类、办法等),而非批改已有代码(批改模块、类、办法等)

如何了解“对扩大凋谢、对批改敞开”?

咱们通过上面的例子来更好的了解“对扩大凋谢、对批改敞开”的含意。

初始示例

这是一段 API 接口监控告警的代码。其中,

  • AlertRule 存储告警规定,能够自在设置。
  • Notification 是告警告诉类,反对邮件、短信、微信、手机等多种告诉渠道。
  • NotificationEmergencyLevel 示意告诉的紧急水平,包含 SEVERE(重大)、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.SEVERE, "...");
    }
  }
}

下面这段代码非常简单,业务逻辑次要集中在 check() 函数中。当接口的 TPS 超过某个事后设置的最大值时,以及当接口申请出错数大于某个最大允许值时,就会触发告警,告诉接口的相干负责人或者团队。

当初,如果咱们须要增加一个性能,当每秒钟接口超时申请个数,超过某个事后设置的最大阈值时,咱们也要触发告警发送告诉。这个时候,咱们该如何改变代码呢?次要的改变有两处:

  1. 第一处是批改 check() 函数的入参,增加一个新的统计数据 timeoutCount,示意超时接口申请数;
  2. 第二处是在 check() 函数中增加新的告警逻辑。

具体的代码改变如下所示:

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() 函数入参进行了批改,这就意味着调用这个函数的代码都要做相应的批改。
  • 另一方面,批改了 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 handler : alertHandlers) {handler.check(apiStatInfo);
    }
  }
}

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

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);
}

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) {if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, "...");
    }
  }
}

下面的代码是对 Alert 的重构,咱们再来看下,重构之后的 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。
  • 第二处改变是:增加新的 TimeoutAlertHander 类。
  • 第三处改变是:在 ApplicationContext 类的 initializeBeans() 办法中,往 alert 对象中注册新的 timeoutAlertHandler。
  • 第四处改变是:在应用 Alert 类的时候,须要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
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 类增加单元测试,老的单元测试都不会失败,也不必批改。

批改代码就意味着违反开闭准则吗?

看了下面重构之后的代码,你可能还会有疑难:在增加新的告警逻辑的时候,只管改变二(增加新的 handler 类)是基于扩大而非批改的形式来实现的,但改变一、三、四貌似不是基于扩大而是基于批改的形式来实现的,那改变一、三、四不就违反了开闭准则吗?

剖析改变一:往 ApiStatInfo 类中增加新的属性 timeoutCount。

实际上,咱们不仅往 ApiStatInfo 类中增加了属性,还增加了对应的 getter/setter 办法。那这个问题就转化为:给类中增加新的属性和办法,算作“批改”还是“扩大”?

咱们再一块回顾一下开闭准则的定义:软件实体(模块、类、办法等)应该“对扩大凋谢、对批改敞开”。从定义中,咱们能够看出,开闭准则能够利用在不同粒度的代码中 ,能够是模块,也能够类,还能够是办法(及其属性)。 同样一个代码改变,在粗代码粒度下,被认定为“批改”,在细代码粒度下,又能够被认定为“扩大”。比方,改变一,增加属性和办法相当于批改类,在类这个层面,这个代码改变能够被认定为“批改”;但这个代码改变并没有批改已有的属性和办法,在办法(及其属性)这一层面,它又能够被认定为“扩大”。

实际上,咱们也没必要纠结某个代码改变是“批改”还是“扩大”,更没必要太纠结它是否违反“开闭准则”。咱们回到这条准则的设计初衷:只有它没有毁坏原有的代码的失常运行,没有毁坏原有的单元测试,咱们就能够说,这是一个合格的代码改变。

剖析改变三和改变四

这两处改变都是在办法外部进行的,不论从哪个层面(模块、类、办法)来讲,都不能算是“扩大”,而是地地道道的“批改”。不过,有些批改是在劫难逃的,是能够被承受的。为什么这么说呢?我来解释一下。

在重构之后的 Alert 代码中,咱们的 外围逻辑集中在 Alert 类及其各个 handler 中 ,当咱们在增加新的告警逻辑的时候,Alert 类齐全不须要批改,而只须要扩大一个新 handler 类。 如果咱们把 Alert 类及各个 handler 类合起来看作一个“模块”,那模块自身在增加新的性能的时候,齐全满足开闭准则

而且,咱们要意识到,增加一个新性能,不可能任何模块、类、办法的代码都不“批改”,这个是做不到的。类须要创立、组装、并且做一些初始化操作,能力构建成可运行的的程序,这部分代码的批改是在劫难逃的。咱们要做的是 尽量让批改操作更集中、更少、更下层,尽量让最外围、最简单的那局部逻辑代码满足开闭准则

如何做到“对扩大凋谢、批改敞开”?

在刚刚的例子中,咱们通过 引入一组 handler 的形式 来实现反对开闭准则。如果你没有太多简单代码的设计和开发教训,你可能会有这样的疑难:这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?

这次要依附的是理论知识和实战经验,这些须要缓缓学习和积攒。然而对于如何做到“对扩大凋谢、批改敞开”,也有一些指导思想和具体的方法论能够参考。

指导思想

  • 要时刻具备扩大意识、形象意识、封装意识 。这些“潜意识”可能 比任何开发技巧都重要
  • 在写代码的时候后,咱们要多花点工夫 往前多思考一下 ,这段代码将来可能有哪些需要变更、如何设计代码构造, 当时留好扩大点.
  • 在辨认出代码可变局部和不可变局部之后,咱们要 将可变局部封装起来,隔离变动,提供抽象化的不可变接口,给下层零碎应用。当具体的实现发生变化的时候,咱们只须要基于雷同的形象接口,扩大一个新的实现,替换掉老的实现即可,上游零碎的代码简直不须要批改。

具体方法论

在泛滥的设计准则、思维、模式中,最罕用来进步代码扩展性的办法有:

  • 多态
  • 依赖注入
  • 基于接口而非实现编程
  • 以及大部分的设计模式(比方,装璜、策略、模板、职责链、状态等)

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

示例

通过一个例子来解释一下,如何利用这几个设计思维或准则来实现“对扩大凋谢、对批改敞开”。

比方,咱们代码中 通过 Kafka 来发送异步音讯 。对于这样一个性能的开发,咱们要学会将其 形象成一组跟具体音讯队列(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 ProtoBufMessageFormatter  implements MessageFormatter {//...}

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

如何在我的项目中灵便利用开闭准则?

后面咱们提到,写出反对“对扩大凋谢、对批改敞开”的代码的要害是预留扩大点。那问题是 如何能力辨认出所有可能的扩大点呢?

  • 如果你开发的是一个 业务导向的零碎,比方金融零碎、电商零碎、物流零碎等,要想辨认出尽可能多的扩大点,就要对业务有足够的理解,可能晓得当下以及将来可能要反对的业务需要。
  • 如果你开发的是跟 业务无关的、通用的、偏底层的零碎,比方,框架、组件、类库,你须要理解“它们会被如何应用?今后你打算增加哪些性能?使用者将来会有哪些更多的性能需要?”等问题。

不过,有一句话说得好,“惟一不变的只有变动自身”。即使咱们对业务、对系统有足够的理解,那也不可能辨认出所有的扩大点,即使你能辨认出所有的扩大点,为这些中央都预留扩大点,这样做的老本也是不可承受的。咱们没必要为一些边远的、不肯定产生的需要去提前买单,做适度设计。

最正当的做法是:

  • 对于一些比拟确定的、短期内可能就会扩大,或者需要改变对代码构造影响比拟大的状况,或者实现老本不高的扩大点,在编写代码的时候之后,咱们就能够当时做些扩展性设计。
  • 但对于一些不确定将来是否要反对的需要,或者实现起来比较复杂的扩大点,咱们能够等到有需要驱动的时候,再通过重构代码的形式来反对扩大的需要。

而且,开闭准则也并不是收费的。有些状况下,代码的扩展性会跟可读性相冲突 。比方,咱们之前举的 Alert 告警的例子。为了更好地反对扩展性,咱们对代码进行了重构,重构之后的代码要比之前的代码简单很多,了解起来也更加有难度。 很多时候,咱们都须要在扩展性和可读性之间做衡量。在某些场景下,代码的扩展性很重要,咱们就能够适当地就义一些代码的可读性;在另一些场景下,代码的可读性更加重要,那咱们就适当地就义一些代码的可扩展性。

参考资料

  • 《设计模式之美》极客工夫

正文完
 0