Files
knowledge-kit/Chapter6 - Design Pattern/6.4.md
2023-10-31 15:17:07 +08:00

225 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
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.
# SOLID之开闭原则
如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?
## 如何理解“对扩展开放、修改关闭”?
开闭原则的英文全称是 Open Closed Principle简写为 OCP。它的英文描述是Software entities (modules, classes, functions, etc.) should be open for extension ,
but closed for modification。
解释一下就是:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
举个场景例子,之前在做天网报警系统的时候,有一段监控告警代码。
其中 AlertRule 存储告警规则,有个 mPaaS 平台的可视化页面基于不同业务线自由设置。Notification 是告警通知类支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度Error、Warning、Info、Normal。
```
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 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() 函数,相应的单元测试都需要修改
因为从本质上来讲,上面的实现是基于修改的方式来实现的新功能,如果遵循开闭原则,该如何实现呢?
1. 将 check 函数所需要的多个参数封装为 ApiStateInfo 对象
2. 引入 handler 的概念,将 if 的具体判断逻辑分散到各个 handler 中去
```
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
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 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())
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
```
使用的地方,创建一个 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() {
instance.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);
}
}
```
重构之后的代码要实现:添加一个新的功能,每秒钟接口超时请求个数超过最大阈值就告警,该如何改动?
1. 在 ApiStatInfo 类中添加新的属性 timeoutCount。
2. 添加新的 TimeoutAlertHander 类,编写 check 方法
3. 在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。
4. 在使用 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 类合起来看作一个“模块”,那模块本身在添加新的
功能的时候,完全满足开闭原则。而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
## 如何做到“对扩展开放、修改关闭”
为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。