SOLID:SOLID 原则并非单纯的1个原则,而是由5个设计原则组成,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,SOLID 由5个设计原则的头一个字母组成。
开闭原则是SOLID中最难理解、最难掌握,同时也是最有用的一条原则。
之所以这条原则难理解,那是因为需要判定以下问题:
-
怎样的代码改动才被定义为 “扩展”?
-
怎样的代码改动才被定义为 “修改”?
-
怎样才算满足或违反开闭原则?
-
修改代码就一定意味着违反开闭原则吗?
之所以这条原则难掌握,那是因为需要理解以下问题:
-
如何做到“对扩展开放、对修改关闭”?
-
如何在项目中灵活应用开闭原则,以避免在追求扩展性的同时影响到代码的可读性?
之所以这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在23种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。
如何理解 ”对扩展开放、修改关闭”?
开闭原则(Open Close Principle,OCP),它的英文描述是:
software entities(modules, classes, functions, etc.) should be open for extension, but closed for modification
软件实体(模块、类、方法等)应该对扩展开放、对修改关闭
用通俗点的话讲就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
为了能更好的理解这个原则,举一个例子解释一下。这是一段API接口监控告警的代码。
AlertRule
存储告警规则,可以自由设置。Notification
是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel
表示通知的紧急程度,包括 SERVER(严重)
,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.SERVER, "...");
}
}
}
上面的代码业务逻辑主要集中在 check()
方法,当接口的 tps 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。
现在如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。你可能会这么修改代码:
public class Alert {
...省略AlertRule/Notification代码
// 改动一:方法添加一个参数timeoutCount
public void check(String api,
long requestCount,
long errorCount,
long timeoutCount,
long durationOfSeconds) {
...与上面相同
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
上面的代码改动是基于”修改“的方式来实现新功能的。如果我们遵循开闭原则即”对扩展开放,对修改关闭“,该怎么通过”扩展”的方式,来实现同样的功能?
我们重构一下之前的 Alert 代码,让它的扩展性更好一些:
-
将
check()
的多个参数封装成ApiStateInfo
-
引入
handler
的概念,将if
判断逻辑分散在各个handler
中
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();
public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}
public void check(ApiStateInfo apiStateInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStateInfo);
}
}
}
public class ApiStateInfo {
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(ApiStateInfo apiStateInfo);
}
public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStateInfo apiStateInfo) {
long tps = apiStateInfo.getRequestCount() / apiStateInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStateInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStateInfo apiStateInfo) {
if (apiStateInfo.getErrorCount() > rule.getMatchedRule(apiStateInfo.getApi().getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SERVER, "...");
}
}
}
上面对 Alert 进行了重构,重构后怎么使用 Alert 呢?
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
initializeBeans();
}
public static ApplicationContext getInstance() {
return instance;
}
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;
}
}
public class Demo {
public static void main(String[] args) {
ApiStateInfo apiStateInfo = new ApiStateInfo();
// 省略apiStateInfo设置数据值
ApplicationContext.getInstance().getAlert().check(apiStateInfo);
}
}
基于上面重构后的代码,怎么添加一个接口请求超时的处理呢?
public class Alert { // 代码未改动... }
public class ApiStateInfo {
...
private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHaneler { // 代码未改动... }
public class TpsAlertHandler extends AlertHandler { // 代码未改动... }
public class ErrorAlertHandler extends AlertHandler { // 代码未改动... }
// 改动二:添加新的handler
public class TimeoutAlertHandler extends AlertHandler { // 省略代码... }
public class ApplicationContext {
...
public void initializeBeans() {
...
// 改动三:注册handler
alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
}
...
}
public class Demo {
public static void main(String[] args) {
ApiStateInfo apiStateInfo = new ApiStateInfo();
// 省略apiStateInfo设置数据值
// 改动四:设置timeoutCount值
apiStateInfo.setTimeoutCount(289);
ApplicationContext.getIntance().getAlert().check(apiStateInfo);
}
}
重构后的代码更加灵活和易扩展。如果我们想要添加新的告警逻辑,只需要基于扩展的方式创建新的 handler
即可,不需要改动原来的 check()
的逻辑。
修改代码就意味着违背开闭原则吗?
在上面的案例中,我们对 ApiStateInfo 添加了新的字段,ApplicationContext
添加了新的 handler
,在使用 apiStateInfo 设置 timeoutCount 值,是否认为是属于修改代码,违反了开闭原则?
ApiStateInfo添加新字段timeoutCount
我们再回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该对扩展开放、对修改关闭。
从定义中可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以是类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为 “修改”,在细代码粒度下,又可以被认定为 “扩展”。
在粗代码粒度下,ApiStateInfo 被添加了属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为 “修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为 “扩展”。
实际上我们没必要纠结某个代码改动是 “修改” 还是 “扩展”,更没必要太纠结它是否违反开闭原则。只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
ApplicationContext添加新的handler,ApiStateInfo设置timeoutCount
这两个改动不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。
我们的核心逻辑集中在 Alert
和各个 handler
中,当我们在添加新的告警逻辑的时候,Alert
类完全不需要修改,而只需要扩展一个新的 handler
类。如果我们把 Alert
和各个 handler
合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。
我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
开闭原则并不是说完全杜绝修改,而是以最小修改代码的代价来完成新功能的开发。
如何做到 “对扩展开放、修改关闭”?
在刚刚的例子中,通过引入一组 handler
的方式来实现支持开闭原则。你可能会有这样的疑问:这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?
之所以能想到,靠的就是理论知识和实战经验,这些需要你慢慢学习和积累。对于如何做到“对扩展开放、修改关闭”,也有一些指导思想和具体的方法论。
实际上,开闭原则讲的就是代码的扩展性问题,如果某段代码在应对未来需求变化的时候,能够做到 “对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以问“对扩展开放、对修改关闭”粗略地等同于在问题,如何才能写出扩展性好的代码。
“对扩展开放、修改关闭” 的顶层指导思想
在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些 “潜意识” 可能比任何开发技巧都重要。
在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到 “对扩展开放、对修改关闭”。
还有,在 识别出代码可变部分和不可变部分 之后,我们要 将可变部分封装起来,隔离变化,提供抽象化地不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。例如例子中的 handler
就是如此。
“对扩展开放、修改关闭” 的具体方法论
刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。
很多的设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的,特别是23中经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。
实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同的角度、不同的层面来阐述而已。
比如,我们代码中通过 Kafka
来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka
替换成 RocketMQ
,可以很方便地拔掉老地消息队列实现。
// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMessageQueue implements MessageQueue { //... }
public interface MessageFormatter { //... }
public class JsonMessageFormatter implements MessageFormatter { //... }
public class ProtocolBufferMessageFormatter implements MessageFormatter { //... }
public class Demo {
private MessageQueue messageQueue; // 基于接口而非实现编程
public Demo(MessageQueue messageQueue) { // 依赖注入
this.messageQueue = messageQueue;
}
// MessageFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter formatter) { //... }
}
如何在项目中灵活应用开闭原则?
写出支持 “对扩展开放、对修改关闭” 的代码关键是预留扩展点。问题是如何才能识别出所有可能的扩展点呢?
无论你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,还是跟业务无关的、通用的、偏底层的系统,比如框架、组件、类库等,我们需要了解它们会被如何使用?今后打算添加哪些功能?使用者未来会有哪些更多的功能需求 等问题。
但也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的,我们没有必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
开闭原则也并不是免费的,有些情况下,代码的扩展性会跟可读性相冲突。比如举的 Alert
告警的例子,为了支持扩展性对代码进行了重构,重构之后的代码比重构前理解起来难度要大一些。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。
Alert
告警的例子中,如果告警规则并不是很多、也不复杂,check()
中的 if
语句就不会很多,代码逻辑也不复杂,代码行数也不多,那最初的第一种代码实现思路简单易懂,就是比较合理的选择;相反的告警规则很多在原有代码上修改起来很复杂,可读性、可维护性变差,那重构之后的第二种代码实现思路就是更加合理的选择了。
总结
1、如何理解 “对扩展开放、对修改关闭”?
在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
2、修改代码就意味着违背开闭原则吗?
不是。开闭原则并不是完全杜绝修改,而是以最小修改代码的代价完成新功能的开发。
要对代码区分不同的粒度,同样一个代码改动,在粗代码粒度下,本认定为 “修改”,在细代码粒度下,又可以被认定为 “扩展”。只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,就是适合的代码改动,不违反开闭原则。
添加一个新功能,不可能任何模块、类、方法的代码都不 “修改”,这个是做不到的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
3、如何做到 “对扩展开放、修改关闭”?
-
识别出代码可变和不可变的部分,将可变部分封装起来,隔离变化,提供抽象化地不可变接口,给上层系统使用
-
多态、依赖注入、基于接口而非实现编程
4、如何在项目中灵活应用开闭原则?
灵活应用开闭原则,即写出 “对扩展开放、对修改关闭” 的代码,关键是预留扩展点。
如何预留扩展点?在了解项目和业务的基础上,了解项目模块会被如何使用?今后打算添加哪些功能?未来会有哪些更多的功能需求等。
没必要对项目模块做过度设计,最合理的做法是,对短期内比较明确可能会扩展或有需求改动的地方,可以做一些扩展设计;对于不明确的未来是否需要支持的需求或实现起来比较复杂的扩展点,可以等有需求驱动时再重构代码支持。