设计原则02:开闭原则(OCP)

开闭原则(Open Closed Principle),英文描述为:Software entities(modules, classes, functions, etc.) should be open for extension, but closed for modification. 翻译过来就是对扩展开放、对修改关闭。

这条原则是SOLID中最难理解同时也是最有用的一条原则。

说难理解是因为“如何做到对扩展开放、对修改关闭?如何在项目中灵活的应用开闭原则?怎么才算满足或违反开闭原则?修改代码就一定意味着违反开闭原则吗?”这些问题都比较难理解。

而之所以这条原则最有用,是因为,扩展性是代码质量最重要的衡量标准之一。在23种经典的设计模式之中,大部分设计模式是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

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

为了更好的理解这个原则,我们举一个例子。这是一段API接口监控告警的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

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, "...");
    }
  }
}

其中,AlertRule是存储告警规则,可以自由设置;Notification是通知类,支持邮件、短信、微信、手机等各种告警渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。

这段代码的逻辑非常简单,业务逻辑主要集中在check()函数中,当接口的TPS超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时就会触发告警。现在我们要增加一个功能,当每秒钟接口超时请求个数超过预先设定的最大值,也要进行告警,那么我们该如何改动呢?

一般来说,我们会在方法的入参中增加timeoutCount,然后再在方法中加入对应的判断逻辑。但这样改动存在很多问题,一方面我们对接口进行了修改就意味着调用这个接口的代码都要修改,另一方面修改了check()函数,对应的单元测试都要修改。

上面的改动方法是基于“修改”的方式,如果我们遵循开闭原则,通过扩展的方式,来实现新的功能呢?

主要改动点如下:

  • check()函数的入参封装成ApiStatInfo类
  • 引入handler的概念,将if判断逻辑分散在各个handler中

具体代码改动后如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

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的重构了,那么重构之后的代码如何使用呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

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

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

如果基于以上的代码,当我们新增上面的新功能点时,只需要去创建新的handler即可,不需要改动原本的check()函数,而且原本的单元测试也不会受到影响。

具体的改动点如下,代码略:

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

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

上面的四点改动中,貌似只有第二点是基于扩展而非修改的,那么一三四违背开闭原则么?

首先第一点,在 ApiStatInfo 类中添加新的属性 timeoutCount。 我们增加了新的属性,新的getter/setter方法。从类的层面来看确实修改了代码,但是从方法/属性这一层面相当于是增加了新的属性,又可以被认定为“扩展”。所以,只要它没有破坏原有的代码正常运行,没有破坏原有的单元测试,我们就可以说这是一个合格的代码改动。

然后再分析一下改动三和改动四。

这两点无论怎么说都可以算是对代码的“修改”,但是我们需要知道的是,增加新功能不可能任何模块、类、方法的代码都不修改,我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

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

上面那种handler的写法,你可能会想:这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?

实际上,这靠的是理论知识和实战经验,先掌握理论知识,然后在实战中慢慢积攒经验即可。

指导思想

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

写代码的时候要做好充足的思考,事先留好扩展点,识别出代码的可变部分和不可变部分,当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可。

方法论

代码的扩展性是评判代码质量最重要的标准之一。常用来提高扩展性的方法有:多态、依赖注入、基于接口而非实现编程、大部分的设计模式等等。而且,很多设计原则、思想、模式都是相通的。

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

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

软件开发中“唯一不变的就是变化本身”。我们很难识别出所有的扩展点,对于短期的、确定的扩展,我们可以实现做扩展性设计,对于长期的或者实现成本很高的扩展点,我们可以等到有需求驱动的时候进行重构。

另外开闭原则有时候会和可读性相冲突,我们也需要做一些平衡。

updatedupdated2023-06-032023-06-03
加载评论