likes
comments
collection
share

09 设计原则SOLID之:O(对扩展开放,修改关闭)

作者站长头像
站长
· 阅读数 7

伯特兰·梅耶(Bertrand Meyer)首次提出。这个原则表明一个软件实体(例如类、模块、函数等)应该对扩展是开放的,但对修改是关闭的。

  1. 对扩展开放(Open for Extension):

    意味着系统的设计应该允许在不修改现有代码的情况下添加新功能或行为。当需求发生变化时,我们应该能够通过添加新的代码、类或模块来扩展系统的功能。

  2. 对修改关闭(Closed for Modification):

    意味着系统的设计应该尽量避免修改已经存在和正常运行的代码。现有的代码在添加新功能时不应该被改变,这可以减少引入错误的风险,并保持系统的稳定性。

1. 能不能举个例子

在讲单一职责的时候,对于接口的修改,就违反了修改关闭原则,导致了,本来只想修改业务开关弹窗,却连隐私开关弹窗的逻辑一起修改了,这就难免会引入错误,影响系统的稳定性。

class Rectangle {
    void draw() {
        System.out.println("Drawing a rectangle");
    }
}

class Cricle {
    void draw() {
        System.out.println("Drawing a Cricle");
    }
}

class Drawing {
    void drawRectangle(Rectangle rectangle) {
        rectangle.draw();
    }
    
    void drawCrilee(Cricle cricle) {
        cricle.draw();
    }
}

如果要添加圆形,可能需要修改 Drawing 类,违反了对修改关闭的原则。

interface Shape {
    void draw();
}

class Rectangle implements Shape {
    public void draw() {
        System.out.println("Drawing a rectangle");
    }
}

class Circle implements Shape {
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

class Drawing {
    void drawShape(Shape shape) {
        shape.draw();
    }
}

在这个设计中,我们通过引入抽象的 Shape 接口,使得系统对新图形的扩展变得容易,而不需要修改已有的代码。这符合对扩展开放、对修改关闭的原则。

为什么感觉突然让我的一个代码感觉变得复杂了,新的修改是怎么来的?利用了那些特性呢?

  1. 继承(Inheritance): RectangleCircle 类都实现了 Shape 接口,这是一种简单的继承。这使得 Drawing 类可以通过统一的接口来操作不同的形状,实现了代码的重用。
  2. 多态(Polymorphism): 多态性允许使用同样的接口来操作不同的对象。在这个例子中,Drawing 类通过 Shape 接口调用 draw 方法,而不关心具体的形状类是 Rectangle 还是 Circle。这实现了运行时多态性。
  3. 抽象(Abstraction): 接口 Shape 是对形状的抽象,它定义了绘制形状的通用方法,而不涉及具体形状的细节。这种抽象使得系统更具灵活性,能够支持未来的扩展。

可以看到为了实现对扩展开放,修改关闭,对上面的代码进行了设计,主要是利用多态,额外扩展了一个Circle类,没有对已有代码进行修改,从而扩展了功能。

2. 深入理解(对扩展开放、修改关闭)?

上面通过一个例子我们简单理解了下什么是对扩展开放、修改关闭,但是实际编码中,我们应该以什么准则来判断,

“怎样的代码改动才被定义为‘扩展’?

怎样的代码改动才被定义为‘修改’?

怎么才算满足或违反‘开闭原则’?

通过上面的例子,通俗的理解是,我新加了一个绘制圆形的功能,并且不能对目前项目已有的代码进行修改,那么问题来了,怎么样才算是修改?怎么样不是修改?一点也不能动吗?

第一个例子有点简单,现在使用一个网上较为复杂的例子,解释说明下:

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 durati
        long tps = requestCount / durationOfSeconds;
        // TPS 超过某个预先设置的最大值时 发出警告
        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 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。

如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。

下面是偷懒式修改:

public class Alert {
    // ... 省略 AlertRule/Notification 属性和构造函数...
    // 改动一:添加参数 timeoutCount
    public void check(String api, long requestCount, long errorCount, long timeou
        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() 函数的多个入参封装成 参数类

第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。

怎么使用呢?

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 类添加单元测试,老的单元测试都不会失败,也不用修改。

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

上面的修改中,例子对ApiStatInfo添加了新的属性,这算是修改还是扩展呢? 对于属性来说,这种添加不算是修改,没有修改到现有的逻辑。这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。

在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

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

上面这个例子的解释我悟了,就跟第一个画正方形,画圆的例子,那是一个及其简单且优美的例子,实际的业务总是错综复杂。要做的是总体业务功效功能层面的修改关闭,扩展开放

工作中怎么“对扩展开放、修改关闭”?

在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

“在写代码的时候,我们要多花点时间往前多设计下”,这句话在项目组长哪里听到过好多次。其实他是想让我针对项目的后续发展做设计,计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用

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

下面的这个例子,跟我实际项目中提到的,firebase和pubsub数据打点的例子相似,为了减少工作量,我这里就直接搬运了。

// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class FirebaseMessageQueue implements MessageQueue { //... }
public class PubsubMessageQueue 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 msg
        //...
    }
}

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

写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点

“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。因为这不在产品经理的需求范围内,并且会增加很多工作量。

最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。

但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

这一章虽然是在讲“对扩展开放、对修改关闭”原则,但是确是对前面学习内容的一个整体的应用与输出。虽然通过整理了一些例子与工作感悟,但是对于刚刚工作的小白来说,怎么一以贯之,还是很考验理解能力和能验总结的。

转载自:https://juejin.cn/post/7308219712492027955
评论
请登录