Practice the design pattern of GoF 23: SOLID principle

Posted by Atanu on Wed, 02 Mar 2022 05:13:32 +0100

Abstract: This paper will describe the interface isolation principle and Dependency Inversion Principle in SOLID principle.

This article is shared from the Huawei cloud community "practice the design pattern of GoF 23: SOLID principle (Part 2)", author: yuan Runzi.

In< Practice 23 design modes of GoF: SOLID principle (I) >In this paper, we mainly talk about the single responsibility principle, opening and closing principle and Richter replacement principle in SOLID principle. Next, we will continue to talk about the interface isolation principle and Dependency Inversion Principle in this paper.

ISP: interface isolation principle

The , interface , aggregation , Principle (ISP) is a Principle about interface design. The , interface , here , does not only refer to the narrow interface declared by interface on Java or Go, but includes the broad interface including the narrow interface, abstract class, concrete class, etc. It is defined as follows:

Client should not be forced to depend on methods it does not use.

That is, a module should not force clients to rely on interfaces they do not want to use, and the relationship between modules should be based on the smallest set of interfaces.

Next, we will introduce ISP in detail through an example.

In the above figure, Client1, Client2 and Client3 all rely on Class1, but in fact, Client1 only needs to use Class1 Func1 method, Client2 only need to use Class1 Func2, Client3 only need to use Class1 At this time, we can say that the ISP violates func3.

Violation of ISP will mainly bring the following two problems:

  1. Increase the dependence between the module and the client program. For example, in the above example, although neither Client2 nor Client3 calls func1, when Class1 modifies func1, it must notify Client1 ~ 3, because Class1 does not know whether they use func1.
  2. If the programmer who develops Client1 accidentally types func1 as func2 when writing code, it will lead to abnormal behavior of Client1. That is, Client1 is contaminated by func2.

In order to solve the above two problems, we can isolate func1, func2 and func3 through the interface:

After interface isolation, Client1 only depends on Interface1, and there is only one method of func1 on Interface1, that is, Client1 will not be polluted by func2 and func3; In addition, after Class1 modifies func1, it only needs to notify the clients that depend on Interface1, which greatly reduces the coupling between modules.

The key to the implementation of ISP is to split the large interface into small interfaces, and the key to splitting is to grasp the interface granularity. To split well, the interface designer is required to be very familiar with the business scenario and know the scenario of interface use like the back of his hand. Otherwise, if the interface is designed in isolation, it is difficult to meet the requirements of ISP.

Next, we take the distributed application system demo as an example to further introduce the implementation of ISP.

A message queue module usually contains two behaviors: production and consumer. Therefore, we have designed an abstract interface of Mq message queue, including two methods: production and consumer:

// Message queue interface
public interface Mq {
    Message consume(String topic);
    void produce(Message message);
}

// demo/src/main/java/com/yrunz/designpattern/mq/MemoryMq.java
// Currently, it provides the implementation of MemoryMq memory message queue
public class MemoryMq implements Mq {...}

There are two modules using interfaces in the current demo, namely MemoryMqInput as a consumer and AccessLogSidecar as a producer:

public class MemoryMqInput implements InputPlugin {
    private String topic;
    private Mq mq;
    ...
    @Override
    public Event input() {
        Message message = mq.consume(topic);
        Map<String, String> header = new HashMap<>();
        header.put("topic", topic);
        return Event.of(header, message.payload());
    }
    ...
}
public class AccessLogSidecar implements Socket {
    private final Mq mq;
    private final String topic
    ...
        @Override
    public void send(Packet packet) {
        if ((packet.payload() instanceof HttpReq)) {
            String log = String.format("[%s][SEND_REQ]send http request to %s",
                    packet.src(), packet.dest());
            Message message = Message.of(topic, log);
            mq.produce(message);
        }
        ...
    }
    ...
}

From the domain model, the design of Mq interface is really no problem. It should include two methods: consume and produce. However, from the perspective of the client program, it violates the ISP. For MemoryMqInput, it only needs the consume method; For AccessLogSidecar, it only needs the produce method.

One design scheme is to split the Mq interface into two sub interfaces, Consumable and Producible, so that MemoryMq can directly realize Consumable and Producible:

// demo/src/main/java/com/yrunz/designpattern/mq/Consumable.java
// Consumer interface, consuming data from message queue
public interface Consumable {
    Message consume(String topic);
}

// demo/src/main/java/com/yrunz/designpattern/mq/Producible.java
// The producer interface produces consumption data to the message queue
public interface Producible {
    void produce(Message message);
}

// Currently, it provides the implementation of MemoryMq memory message queue
public class MemoryMq implements Consumable, Producible {...}

If you think about it carefully, you will find that the above design is not in line with the domain model of message queue, because the abstraction of Mq really should exist.

A better design should be to retain the Mq abstract interface and let Mq inherit from Consumable and Producible. After such hierarchical design, it can not only meet the ISP, but also make the implementation conform to the domain model of message queue:

The specific implementation is as follows:

// demo/src/main/java/com/yrunz/designpattern/mq/Mq.java
// The message queue interface inherits Consumable and Producible, and also has two behaviors: consume and produce
public interface Mq extends Consumable, Producible {}

// Currently, it provides the implementation of MemoryMq memory message queue
public class MemoryMq implements Mq {...}

// demo/src/main/java/com/yrunz/designpattern/monitor/input/MemoryMqInput.java
public class MemoryMqInput implements InputPlugin {
    private String topic;
    // Consumers rely only on the Consumable interface
    private Consumable consumer;
    ...
    @Override
    public Event input() {
        Message message = consumer.consume(topic);
        Map<String, String> header = new HashMap<>();
        header.put("topic", topic);
        return Event.of(header, message.payload());
    }
    ...
}

// demo/src/main/java/com/yrunz/designpattern/sidecar/AccessLogSidecar.java
public class AccessLogSidecar implements Socket {
    // Producers rely only on the Producible interface
    private final Producible producer;
    private final String topic
    ...
        @Override
    public void send(Packet packet) {
        if ((packet.payload() instanceof HttpReq)) {
            String log = String.format("[%s][SEND_REQ]send http request to %s",
                    packet.src(), packet.dest());
            Message message = Message.of(topic, log);
            producer.produce(message);
        }
        ...
    }
    ...
}

Interface isolation can reduce the coupling between modules and improve the stability of the system, but excessively refining and splitting interfaces will also lead to the increase of the number of interfaces of the system, resulting in greater maintenance costs. The granularity of the interface needs to be determined according to the specific business scenario. You can refer to the principle of single responsibility to combine those interfaces that provide services for the same type of client programs.

DIP: Dependency Inversion Principle

When introducing OCP in Clean Architecture, it is mentioned that if module A is to be free from The change of module B, module B must depend on module A. This sentence seems contradictory. Module A needs to use The functions of module B. how can module B rely on module A in turn? This is The question to be answered by The Dependency Inversion Principle (DIP).

DIP is defined as follows:

  1. High-level modules should not import anything from low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Translated as:

  1. High level modules should not rely on low-level modules, and both should rely on abstraction
  2. Abstract should not rely on details, details should rely on abstraction

In the definition of DIP, there are four Keywords: high-level module, low-level module, abstraction and detail. To understand the meaning of DIP, it is very important to understand the four keywords.

(1) High level module and low level module

Generally speaking, we believe that the high-level module is a module that contains the core business logic and strategy of the application, and is the soul of the whole application; Low level modules are usually some infrastructure, such as database, Web framework, etc. They mainly exist to assist high-level modules to complete business.

(2) Abstraction and detail

In the previous section "OCP: opening and closing principles", we can know that abstraction is the common ground among many details, and abstraction is the result of constantly ignoring details.

Now let's look at the definition of DIP. It is not difficult for us to understand the second point. From the definition of abstraction, abstraction will not depend on details, otherwise it will not be abstract; Details depend on abstraction, which is often true.

The key to understanding DIP lies in point 1. According to our positive thinking, high-level modules should complete business with the help of low-level modules, which will inevitably lead to high-level modules relying on low-level modules. But in the software field, we can invert this dependency, and the key is abstraction. We can ignore the details of the low-level module, abstract a stable interface, then let the high-level module rely on the interface, and let the low-level module implement the interface, so as to realize the inversion of dependency relationship:

The reason why we should invert the dependency between high-level modules and low-level modules is mainly because the high-level modules as the core should not be affected by the changes of low-level modules. There should be only one reason for the change of high-level modules, that is, the business change requirements from software users.

Next, we introduce the implementation of DIP through the distributed application system demo.

For the service Registry, when a new service is registered, it needs to save the service information (such as service ID, service type, etc.) so that it can be returned to the client in the subsequent service discovery. Therefore, Registry needs a database to help it complete its business. As it happens, our database module implements a memory database MemoryDb, so we can implement Registry as follows:

// Service registry
public class Registry implements Service {
    ...
    // Directly dependent on MemoryDb
    private final MemoryDb db;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;

    private Registry(...) {
        ...
        // Initialize MemoryDb
        this.db = MemoryDb.instance();
        this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory);
        this.svcDiscovery = new SvcDiscovery(this.db);
    }
    ...
}

// Memory database
public class MemoryDb {
    private final Map<String, Table<?, ?>> tables;
    ...
    // Query table record
    public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        return table.query(primaryKey);
    }
    // Insert table record
    public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        table.insert(primaryKey, record);
    }
    // Update table records
    public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        table.update(primaryKey, record);
    }
    // Delete table record
    public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) {
        Table<PrimaryKey, ?> table = (Table<PrimaryKey, ?>) tableOf(tableName);
        table.delete(primaryKey);
    }
    ...
}

According to the above design, the dependency between modules is that Registry depends on MemoryDb, that is, high-level modules depend on low-level modules. This dependency is fragile. If the database storing service information needs to be changed from MemoryDb to DiskDb one day, we also have to change the code of Registry:

// Service registry
public class Registry implements Service {
    ...
    // Change to rely on DiskDb
    private final DiskDb db;
    ...
    private Registry(...) {
        ...
        // Initialize DiskDb
        this.db = DiskDb.instance();
        this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory);
        this.svcDiscovery = new SvcDiscovery(this.db);
    }
    ...
}

A better design should be to invert the dependency between Registry and MemoryDb. First, we need to abstract a stable interface Db from the detail MemoryDb:

// demo/src/main/java/com/yrunz/designpattern/db/Db.java
// DB abstract interface
public interface Db {
    <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey);
    <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record);
    <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record);
    <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey);
    ...
}

Next, let Registry rely on Db interface and MemoryDb implement Db interface to complete dependency inversion:

// demo/src/main/java/com/yrunz/designpattern/service/registry/Registry.java
// Service registry
public class Registry implements Service {
    ...
    // Only rely on Db abstract interface
    private final Db db;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;

    private Registry(..., Db db) {
        ...
        // Dependency injection Db
        this.db = db;
        this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory);
        this.svcDiscovery = new SvcDiscovery(this.db);
    }
    ...
}

// demo/src/main/java/com/yrunz/designpattern/db/MemoryDb.java
// Memory database to realize Db abstract interface
public class MemoryDb implements Db {
    private final Map<String, Table<?, ?>> tables;
    ...
    // Query table record
    @Override
    public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) {...}
    // Insert table record
    @Override
    public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) {...}
    // Update table records
    @Override
    public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) {...}
    // Delete table record
    @Override
    public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) {...}
    ...
}

// demo/src/main/java/com/yrunz/designpattern/Example.java
public class Example {
    // Complete dependency injection in main function
    public static void main(String[] args) {
        ...
        // Set memorydb Instance() is injected into the Registry
        Registry registry = Registry.of(..., MemoryDb.instance());
        registry.run();
    }
}

When high-level modules rely on abstract interfaces, implementation details (low-level modules) must be injected into high-level modules at some time and somewhere. In the above example, we choose to inject MemoryDb into the main function when creating the Registry object.

Generally, we will complete dependency injection on the main / startup function. The common injection methods are as follows:

  • Constructor injection (method used by Registry)
  • setter method injection
  • Provide the interface of dependency injection, and the client can call the interface directly
  • Inject through the framework, such as the annotation injection capability in the Spring framework

In addition, DIP is not only applicable to module / class / interface design, but also applicable at the architecture level. For example, the hierarchical architecture of DDD and the neat architecture of Uncle Bob all use DIP:

Of course, DIP does not mean that high-level modules can only rely on abstract interfaces. Its original intention should be to rely on stable interfaces / abstract classes / concrete classes. If a concrete class is stable, such as String in Java, there is no problem for high-level modules to rely on it; On the contrary, if an abstract interface is unstable and often changes, it is also against DIP for high-level modules to rely on the interface. At this time, we should consider whether the interface is abstract and reasonable.

last

This paper spent a long time discussing the core idea behind 23 design patterns - SOLID principle, which can guide us to design high cohesion and low coupling software systems. However, it is only a principle after all. How to implement it into the actual engineering project still needs to refer to the successful practical experience. These practical experiences are the design patterns we want to explore next.

The best way to learn design patterns is to practice. In the follow-up article of "practice 23 design patterns of GoF", we will introduce it in this article Distributed application system demo As a practical demonstration, this paper introduces the program structure, applicable scenarios, implementation methods, advantages and disadvantages of 23 design patterns, so that everyone can have a deeper understanding of design patterns, and can use and do not abuse design patterns.

reference resources

  1. Clean Architecture, Robert C. Martin ("Uncle Bob")
  2. Agile software development: principles, patterns and practices, Robert C. Martin ("Uncle Bob")
  3. 23 design patterns of GoF using Go , yuan Runzi
  4. Riemannian substitution principle LSP of SOLID principle , deputy chief of the people

 

Click follow to learn about Huawei's new cloud technology for the first time~

Topics: Java Go Spring Design Pattern solid