共计 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 超过某个事后设置的最大值时,以及当接口申请出错数大于某个最大允许值时,就会触发告警,告诉接口的相干负责人或者团队。
当初,如果咱们须要增加一个性能,当每秒钟接口超时申请个数,超过某个事后设置的最大阈值时,咱们也要触发告警发送告诉。这个时候,咱们该如何改变代码呢?次要的改变有两处:
- 第一处是批改 check() 函数的入参,增加一个新的统计数据 timeoutCount,示意超时接口申请数;
- 第二处是在 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 告警的例子。为了更好地反对扩展性,咱们对代码进行了重构,重构之后的代码要比之前的代码简单很多,了解起来也更加有难度。 很多时候,咱们都须要在扩展性和可读性之间做衡量
。在某些场景下,代码的扩展性很重要,咱们就能够适当地就义一些代码的可读性;在另一些场景下,代码的可读性更加重要,那咱们就适当地就义一些代码的可扩展性。
参考资料
- 《设计模式之美》极客工夫