CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:设计原则/16 | 理论二:如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

342 lines
24 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="16 | 理论二:如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/91/263ba74e016e21defd7d2d230300e191.mp3"></audio>
在上一节课中我们学习了单一职责原则。今天我们来学习SOLID中的第二个原则开闭原则。我个人觉得开闭原则是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无关紧要不同的紧急程度对应不同的发送渠道。关于API接口监控告警这部分更加详细的业务需求分析和设计我们会在后面的设计模式模块再拿出来进一步讲解这里你只要简单知道这些就够我们今天用了。
```
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 &gt; rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, &quot;...&quot;);
}
if (errorCount &gt; rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, &quot;...&quot;);
}
}
}
```
上面这段代码非常简单业务逻辑主要集中在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 &gt; rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, &quot;...&quot;);
}
if (errorCount &gt; rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, &quot;...&quot;);
}
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps &gt; rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, &quot;...&quot;);
}
}
}
```
这样的代码修改实际上存在挺多问题的。一方面我们对接口进行了修改这就意味着调用这个接口的代码都要做相应的修改。另一方面修改了check()函数,相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)。
上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?
我们先重构一下之前的Alert代码让它的扩展性更好一些。重构的内容主要包含两部分
- 第一部分是将check()函数的多个入参封装成ApiStatInfo类
- 第二部分是引入handler的概念将if判断逻辑分散在各个handler中。
具体的代码实现如下所示:
```
public class Alert {
private List&lt;AlertHandler&gt; alertHandlers = new ArrayList&lt;&gt;();
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 &gt; rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, &quot;...&quot;);
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() &gt; rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, &quot;...&quot;);
}
}
}
```
上面的代码是对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方法。那这个问题就转化为给类中添加新的属性和方法算作“修改”还是“扩展”
我们再一块回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。
实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
**我们再来分析一下改动三和改动四在ApplicationContext类的initializeBeans()方法中往alert对象中注册新的timeoutAlertHandler在使用Alert类的时候需要给check()函数的入参apiStatInfo对象设置timeoutCount的值。**
这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。为什么这么说呢?我来解释一下。
在重构之后的Alert代码中我们的核心逻辑集中在Alert类及其各个handler中当我们在添加新的告警逻辑的时候Alert类完全不需要修改而只需要扩展一个新handler类。如果我们把Alert类及各个handler类合起来看作一个“模块”那模块本身在添加新的功能的时候完全满足开闭原则。
而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
## 如何做到“对扩展开放、修改关闭”?
在刚刚的例子中我们通过引入一组handler的方式来实现支持开闭原则。如果你没有太多复杂代码的设计和开发经验你可能会有这样的疑问这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?
先给你个结论,之所以我能想到,靠的就是理论知识和实战经验,这些需要你慢慢学习和积累。对于如何做到“对扩展开放、修改关闭”,我们也有一些指导思想和具体的方法论,我们一块来看一下。
实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。
**在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。**
在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
**刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。**
我们前面讲到代码的扩展性是代码质量评判的最重要的标准之一。实际上我们整个专栏的大部分知识点都是围绕扩展性问题来讲解的。专栏中讲到的很多设计原则、设计思想、设计模式都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式大部分都是为了解决代码的扩展性问题而总结出来的都是以开闭原则为指导原则的。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。设计模式这一部分内容比较多,后面课程中我们能会详细讲到,这里就不展开了。今天我重点讲一下,如何利用多态、依赖注入、基于接口而非实现编程,来实现“对扩展开放、对修改关闭”。
实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同的角度、不同的层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”这一思想。
接下来,我就通过一个例子来解释一下,如何利用这几个设计思想或原则来实现“对扩展开放、对修改关闭”。注意,依赖注入后面会讲到,如果你对这块不了解,可以暂时先忽略这个概念,只关注多态、基于接口而非实现编程以及抽象意识。
比如我们代码中通过Kafka来发送异步消息。对于这样一个功能的开发我们要学会将其抽象成一组跟具体消息队列Kafka无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候比如将Kafka替换成RocketMQ可以很方便地拔掉老的消息队列实现插入新的消息队列实现。具体代码如下所示
```
// 这一部分体现了抽象意识
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) {
//...
}
}
```
对于如何写出扩展性好的代码、如何实现“对扩展开放、对修改关闭”这个问题,我今天只是比较笼统地总结了一下,详细的知识我们在后面的章节中慢慢学习。
## 如何在项目中灵活应用开闭原则?
前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?
如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
而且开闭原则也并不是免费的。有些情况下代码的扩展性会跟可读性相冲突。比如我们之前举的Alert告警的例子。为了更好地支持扩展性我们对代码进行了重构重构之后的代码要比之前的代码复杂很多理解起来也更加有难度。很多时候我们都需要在扩展性和可读性之间做权衡。在某些场景下代码的扩展性很重要我们就可以适当地牺牲一些代码的可读性在另一些场景下代码的可读性更加重要那我们就适当地牺牲一些代码的可扩展性。
在我们之前举的Alert告警的例子中如果告警规则并不是很多、也不复杂那check()函数中的if语句就不会很多代码逻辑也不复杂代码行数也不多那最初的第一种代码实现思路简单易读就是比较合理的选择。相反如果告警规则很多、很复杂check()函数的if语句、代码逻辑就会很多、很复杂相应的代码行数也会很多可读性、可维护性就会变差那重构之后的第二种代码实现思路就是更加合理的选择了。总之这里没有一个放之四海而皆准的参考标准全凭实际的应用场景来决定。
## 重点回顾
今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的的重点内容。
**1.如何理解“对扩展开放、对修改关闭”?**
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
**2.如何做到“对扩展开放、修改关闭”?**
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式大部分都是为了解决代码的扩展性问题而总结出来的都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有多态、依赖注入、基于接口而非实现编程以及大部分的设计模式比如装饰、策略、模板、职责链、状态
## 课堂讨论
学习设计原则,要多问个为什么。不能把设计原则当真理,而是要理解设计原则背后的思想。搞清楚这个,比单纯理解原则讲的是啥,更能让你灵活应用原则。所以,今天课堂讨论的话题是,为什么我们要“对扩展开放、对修改关闭”?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。