The Open and Close Principle is probably the most difficult to understand, master, and useful principle in SOLID.
- The reason why this principle is difficult to understand is that such questions as, "What kind of code changes are defined as extensions"? What kind of code changes are defined as modifications? How can you satisfy or violate the Open and Close Principle? Does modifying code necessarily mean violating the Open and Close Principle?"
- The reason why this principle is difficult to grasp is that it is difficult to grasp such questions as, "How to do Open to Extensions, Modify to Close", how to apply the Open to Close principle flexibly in a project to avoid affecting the readability of code while pursuing extensibility?"
- This principle is most useful because scalability is one of the most important measures of code quality. Of the 23 classic design modes, most of them are designed to solve the problem of code scalability. The main design principle is open and close.
The full English name of the Open Closed Principle is Open Closed Principle (OCP). Its English description is: software entities (modules, classes, functions, etc.) should be open for extension, but closed for modification.
The following is detailed in Chinese: adding a new function should be to expand code (add modules, classes, methods, etc.) on the basis of existing code, rather than modifying existing code (modify modules, classes, methods, etc.).
How do you understand "Open to Extensions, Close to Modifications"?
We use the following examples to better understand the meaning of "open to extension, close to modification".
Initial example
This is a code for API interface monitoring alerts. Where,
- AlertRule stores alert rules and is free to set.
- Notification is an alarm notification class that supports various notification channels such as mail, SMS, WeChat, mobile phone and so on.
- NotificationEmergencyLevel indicates the degree of urgency of notifications, including SEVERE (severe), URGENCY (emergency), NORMAL (normal), TRIVIAL (insignificant), and different levels of urgency correspond to different sending channels.
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, "..."); } } }
The above code is very simple, and business logic is mainly concentrated in the check() function. When an interface's TPS exceeds a preset maximum and when the number of interface requests fails is greater than a maximum allowable value, an alert is triggered to inform the responsible person or team of the interface.
Now, if we need to add a feature, we will also trigger alerts to send notifications when the number of interface timeout requests per second exceeds a preset maximum threshold. How do we change the code at this time? There are two main changes:
- The first is to modify the entry of the check() function and add a new statistic, timeoutCount, to represent the number of timeout interface requests.
- The second is to add a new alert logic to the check() function.
The specific code changes are as follows:
public class Alert { // ...omit the AlertRule/Notification property and constructor... // Change one: Add the parameter 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, "..."); } // Change 2: Add interface timeout logic long timeoutTps = timeoutCount / durationOfSeconds; if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } } }
Such code modifications are actually problematic:
- On the one hand, we have modified the check() function parameter, which means that the code calling this function has to be modified accordingly.
- On the other hand, the check() function has been modified, and the corresponding unit tests need to be modified.
Improvement example
The above code changes are based on modifications to implement new functionality. If we follow the open-close principle, that is, "open to extension, close to modification". How do you achieve the same functionality by "extending"?
Let's refactor the previous Alert code to make it more scalable. The reconstructed content mainly consists of two parts:
- The first part encapsulates the multiple inputs of the check() function into an ApiStatInfo class.
- The second part introduces the concept of handler and separates if judgment logic among handlers.
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 {//Omit constructor/getter/setter method 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, "..."); } } }
The code above is a refactoring of Alert. Let's see how Alert will be used after the refactoring. Specific usage code is also written here. The ApplicationContext is a singleton class responsible for Alert creation, assembly (alertRule and notification dependent injection), initialization (adding handlers).
public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.Omit parameter.*/); //Omit some initialization code notification = new Notification(/*.Omit parameter.*/); //Omit some initialization code alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); } public Alert getAlert() { return alert; } // Hungry Han Style Single Case 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(); // ... omit the code that sets the apiStatInfo data value ApplicationContext.getInstance().getAlert().check(apiStatInfo); } }
Based on the refactored code, if you add the new function mentioned above, the number of interface timeout requests per second exceeds a certain maximum threshold, then how can we change the code? There are four main changes.
- The first change is to add a new attribute timeoutCount to the ApiStatInfo class.
- The second change is to add a new TimeoutAlertHander class.
- The third change is to register a new timeoutAlertHandler in the alert object in the initializeBeans() method of the ApplicationContext class.
- The fourth change is that when using the Alert class, you need to set the timeoutCount value on the apiStatInfo object to which the check() function participates.
public class Alert { // Code unchanged...} public class ApiStatInfo {//Omit constructor/getter/setter method private String api; private long requestCount; private long errorCount; private long durationOfSeconds; private long timeoutCount; // Change 1: Add a new field } public abstract class AlertHandler { //Code unchanged...} public class TpsAlertHandler extends AlertHandler {//Code unchanged...} public class ErrorAlertHandler extends AlertHandler {//Code unchanged...} // Change 2: Add a new handler public class TimeoutAlertHandler extends AlertHandler {//Omit Code...} public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.Omit parameter.*/); //Omit some initialization code notification = new Notification(/*.Omit parameter.*/); //Omit some initialization code alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); // Change 3: Register handler alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification)); } //...omit any other unchanged code... } public class Demo { public static void main(String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo(); // ... omit the set field code for apiStatInfo apiStatInfo.setTimeoutCount(289); // Change 4: Set tiemoutCount value ApplicationContext.getInstance().getAlert().check(apiStatInfo); }
The refactored code is more flexible and extensible. If we want to add a new alert logic, we just need to create a new handler class based on an extension, and we don't need to change the logic of the original check() function. Furthermore, we only need to add unit tests for the new handler class, and the old unit tests will not fail or need to be modified.
Does modifying the code mean that it violates the open-close principle?
Looking at the code after the above refactoring, you may also have questions: When adding a new alarm logic, although change two (adding a new handler class) is based on extension rather than modification, change one, three, and four seems to be done not on extension but on modification. Does that change one, three, and four violate the open-close principle?
Analysis change one: Add a new attribute timeoutCount to the ApiStatInfo class.
In fact, we have not only added attributes to the ApiStatInfo class, but also corresponding getter/setter methods. The question then turns into: Is adding new attributes and methods to a class a "modification" or an "extension"?
Let's go back to the definition of the open-close principle: software entities (modules, classes, methods, etc.) should be "open to extensions, closed to modifications". By definition, we can see that the open-close principle can be applied to code of different granularities, from modules to classes to methods (and their attributes). The same code change is considered "modification" at coarse code granularity and "extension" at fine code granularity. For example, change one, adding attributes and methods is equivalent to modifying a class, and at the class level, this code change can be considered a "modification"; However, this code change does not modify existing properties and methods, and at the level of methods (and their properties), it can also be considered an "extension".
In fact, there's no need to tangle about whether a code change is a "modification" or an "extension", or whether it violates the "open and close principle". We go back to the original design of this principle: as long as it does not break the normal operation of the original code or the original unit tests, we can say that it is a qualified code change.
Analyzing Change Three and Change Four
Both of these changes are made within the method. Regardless of the level (modules, classes, methods), they are not "extensions", but "modifications" that are authentic. However, some modifications are inevitable and acceptable. Why do you say that? Let me explain.
In the reconstructed Alert code, our core logic is focused on the Alert class and its handlers. When we add new alarm logic, the Alert class does not need to be modified at all, it just needs to extend a new handler class. If we consider the Alert class and the handler classes together as a "module", the module itself fully satisfies the open and close principle when adding new functionality.
Moreover, we need to recognize that adding a new function, it is impossible for any module, class, method code to be "modified", which is impossible. Classes need to be created, assembled, and initialized to build runnable programs, and this part of the code will inevitably be modified. What we have to do is try to make the modifications more focused, fewer, and more advanced, and try to make the most core and complex part of the logic code meet the open and close principle.
How to "open to extension, modify to close"?
In the example just given, we support the open-close principle by introducing a set of handler s. If you don't have much experience designing and developing complex code, you might have the question: How could I not think of such a code design idea? How did you think of it?
This mainly depends on theoretical knowledge and practical experience, which needs to be learned and accumulated slowly. However, there are some guidelines and specific methodologies for how to achieve "open to expansion, modify to close".
guiding ideology
- We should always have the awareness of expansion, abstraction and encapsulation. These "subconscious" may be more important than any development technique.
- As you write your code, take a little more time to think about what changes may be required in the future, how to design your code structure, and leave your extension points in advance.
- After identifying the variable and immutable parts of the code, we encapsulate them, isolate the changes, provide abstract immutable interfaces, and make them available to the upper system. When the specific implementation changes, we only need to extend a new implementation based on the same abstract interface, replace the old one, and the upstream system code hardly needs to be modified.
Specific Methodology
Among the many design principles, ideas, and patterns, the most common ways to improve code extensibility are:
- polymorphic
- Dependent Injection
- Programming based on interfaces rather than implementation
- And most design patterns (e.g., decoration, strategy, template, responsibility chain, status, etc.)
In fact, polymorphism, injection dependency, interface-based rather than programming implementation, and the abstract consciousness mentioned above all refer to the same design idea, but only from different perspectives and levels. This also reflects the idea that "many design principles, ideas and patterns are common".
Example
An example is given to illustrate how these design ideas or principles can be used to achieve "open to extension, close to modification".
For example, our code sends asynchronous messages through Kafka. For the development of such a feature, we need to learn to abstract it into a set of asynchronous message interfaces that are independent of the specific message queue (Kafka). All upper systems rely on this abstract set of interface programming and are invoked by injection-dependent means. When we are replacing a new message queue, such as replacing Kafka with RocketMQ, it is easy to unplug the old message queue implementation and insert a new one. The code is as follows:
// This part reflects Abstract consciousness 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; // Programming based on interfaces rather than implementation public Demo(MessageQueue msgQueue) { // Dependent Injection this.msgQueue = msgQueue; } // msgFormatter: Polymorphic, Dependent Injection public void sendNotification(Notification notification, MessageFormatter msgFormatter) { //... } }
How to apply the Open and Close principle flexibly in a project?
As mentioned earlier, the key to writing code that supports Open to Extensions and Close to Modifications is to reserve extension points. The question is how can all possible extensions be identified?
- If you are developing a business-oriented system, such as a financial system, e-commerce system, logistics system, and so on, to identify as many expansion points as possible, you need to have a good understanding of your business and know what business needs you may support now and in the future.
- If you're developing business-independent, generic, low-level systems such as frameworks, components, and class libraries, you need to understand questions like, "How will they be used? What features do you plan to add in the future? What more functional requirements will users have in the future?"
However, there is a good saying,'The only thing that remains unchanged is the change itself'. Even if we have a good understanding of the business and the system, it is impossible to identify all the extension points. Even if you can identify all the extension points and reserve them for these places, the cost of doing so is unacceptable. We don't have to pay in advance for distant, not necessarily occurring needs, and we're over-engineered.
The most reasonable approach is:
- For some more deterministic, short-term extensibility scenarios, where changes in requirements have a greater impact on the code structure, or when implementing low-cost extensions, we can do some extensibility design beforehand when writing code.
- However, for some requirements that are uncertain about whether they will be supported in the future or for more complex extensions to be implemented, we can wait until there is a demand-driven need to support the extensions by refactoring the code.
Moreover, the open and close principle is not free. In some cases, the extensibility of the code conflicts with readability. For example, the Alert warning example we mentioned earlier. To better support scalability, we refactored the code, which is much more complex and difficult to understand than the previous code. Many times, we need to balance scalability with readability. In some scenarios, code extensibility is important, and we can appropriately sacrifice some code readability; In other scenarios, code readability is more important, and we are appropriately sacrificing some code scalability.
Reference material
- Beauty of Design Mode Geek Time