實踐GoF的23的設計模式:SOLID原則(下)

語言: CN / TW / HK
摘要:本文將講述SOLID原則中的介面隔離原則和依賴倒置原則。

​本文分享自華為雲社群《實踐GoF的23的設計模式:SOLID原則(下)》,作者:元閏子。

在《實踐GoF的23種設計模式:SOLID原則(上)》中,主要講了SOLID原則中的單一職責原則、開閉原則、里氏替換原則,接下來在本文中將繼續講述介面隔離原則和依賴倒置原則。

ISP:介面隔離原則

介面隔離原則(The Interface Segregation Principle,ISP)是關於介面設計的一項原則,這裡的“介面”並不單指Java或Go上使用interface宣告的狹義介面,而是包含了狹義介面、抽象類、具象類等在內的廣義介面。它的定義如下:

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

也即,一個模組不應該強迫客戶程式依賴它們不想使用的介面,模組間的關係應該建立在最小的介面集上。

下面,我們通過一個例子來詳細介紹ISP。

上圖中,Client1、Client2、Client3都依賴了Class1,但實際上,Client1只需使用Class1.func1方法,Client2只需使用Class1.func2,Client3只需使用Class1.func3,那麼這時候我們就可以說該設計違反了ISP。

違反ISP主要會帶來如下2個問題:

  1. 增加模組與客戶端程式的依賴,比如在上述例子中,雖然Client2和Client3都沒有呼叫func1,但是當Class1修改func1還是必須通知Client1~3,因為Class1並不知道它們是否使用了func1。
  2. 產生介面汙染,假設開發Client1的程式設計師,在寫程式碼時不小心把func1打成了func2,那麼就會帶來Client1的行為異常。也即Client1被func2給汙染了。

為了解決上述2個問題,我們可以把func1、func2、func3通過介面隔離開:

介面隔離之後,Client1只依賴了Interface1,而Interface1上只有func1一個方法,也即Client1不會受到func2和func3的汙染;另外,當Class1修改func1之後,它只需通知依賴了Interface1的客戶端即可,大大降低了模組間耦合。

實現ISP的關鍵是將大介面拆分成小介面,而拆分的關鍵就是介面粒度的把握。想要拆分得好,就要求介面設計人員對業務場景非常熟悉,對介面使用的場景瞭如指掌。否則孤立地設計介面,很難滿足ISP。

下面,我們以分散式應用系統demo為例,來進一步介紹ISP的實現。

一個訊息佇列模組通常包含生產(produce)和消費(consumer)兩種行為,因此我們設計了Mq訊息佇列抽象介面,包含produce和consume兩個方法:

// 訊息佇列介面
public interface Mq {
    Message consume(String topic);
    void produce(Message message);
}

// demo/src/main/java/com/yrunz/designpattern/mq/MemoryMq.java
// 當前提供MemoryMq記憶體訊息佇列的實現
public class MemoryMq implements Mq {...}

當前demo中使用介面的模組有2個,分別是作為消費者的MemoryMqInput和作為生產者的AccessLogSidecar:

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);
        }
        ...
    }
    ...
}

從領域模型上看,Mq介面的設計確實沒有問題,它就應該包含consume和produce兩個方法。但是從客戶端程式的角度上看,它卻違反了ISP,對MemoryMqInput來說,它只需要consume方法;對AccessLogSidecar來說,它只需要produce方法。

一種設計方案是把Mq介面拆分成2個子介面Consumable和Producible,讓MemoryMq直接實現Consumable和Producible:

// demo/src/main/java/com/yrunz/designpattern/mq/Consumable.java
// 消費者介面,從訊息佇列中消費資料
public interface Consumable {
    Message consume(String topic);
}

// demo/src/main/java/com/yrunz/designpattern/mq/Producible.java
// 生產者介面,向訊息佇列生產消費資料
public interface Producible {
    void produce(Message message);
}

// 當前提供MemoryMq記憶體訊息佇列的實現
public class MemoryMq implements Consumable, Producible {...}

仔細思考一下,就會發現上面的設計不太符合訊息佇列的領域模型,因為Mq的這個抽象確實應該存在的。

更好的設計應該是保留Mq抽象介面,讓Mq繼承自Consumable和Producible,這樣的分層設計之後,既能滿足ISP,又能讓實現符合訊息佇列的領域模型:

具體實現如下:

// demo/src/main/java/com/yrunz/designpattern/mq/Mq.java
// 訊息佇列介面,繼承了Consumable和Producible,同時又consume和produce兩種行為
public interface Mq extends Consumable, Producible {}

// 當前提供MemoryMq記憶體訊息佇列的實現
public class MemoryMq implements Mq {...}

// demo/src/main/java/com/yrunz/designpattern/monitor/input/MemoryMqInput.java
public class MemoryMqInput implements InputPlugin {
    private String topic;
    // 消費者只依賴Consumable介面
    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 {
    // 生產者只依賴Producible介面
    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);
        }
        ...
    }
    ...
}

介面隔離可以減少模組間耦合,提升系統穩定性,但是過度地細化和拆分介面,也會導致系統的介面數量的上漲,從而產生更大的維護成本。介面的粒度需要根據具體的業務場景來定,可以參考單一職責原則,將那些為同一類客戶端程式提供服務的介面合併在一起

DIP:依賴倒置原則

《Clean Architecture》中介紹OCP時有提過:如果要模組A免於模組B變化的影響,那麼就要模組B依賴於模組A。這句話貌似是矛盾的,模組A需要使用模組B的功能,怎麼會讓模組B反過來依賴模組A呢?這就是依賴倒置原則(The Dependency Inversion Principle,DIP)所要解答的問題。

DIP的定義如下:

  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.

翻譯過來,就是:

  1. 高層模組不應該依賴低層模組,兩者都應該依賴抽象
  2. 抽象不應該依賴細節,細節應該依賴抽象

在DIP的定義裡,出現了高層模組低層模組抽象細節等4個關鍵字,要弄清楚DIP的含義,理解者4個關鍵字至關重要。

(1)高層模組和低層模組

一般地,我們認為高層模組是包含了應用程式核心業務邏輯、策略的模組,是整個應用程式的靈魂所在;低層模組通常是一些基礎設施,比如資料庫、Web框架等,它們主要為了輔助高層模組完成業務而存在。

(2)抽象和細節

在前文“OCP:開閉原則”一節中,我們可以知道,抽象就是眾多細節中的共同點,抽象就是不斷忽略細節的出來的。

現在再來看DIP的定義,對於第2點我們不難理解,從抽象的定義來看,抽象是不會依賴細節的,否則那就不是抽象了;而細節依賴抽象往往都是成立的。

理解DIP的關鍵在於第1點,按照我們正向的思維,高層模組要藉助低層模組來完成業務,這必然會導致高層模組依賴低層模組。但是在軟體領域裡,我們可以把這個依賴關係倒置過來,這其中的關鍵就是抽象。我們可以忽略掉低層模組的細節,抽象出一個穩定的介面,然後讓高層模組依賴該介面,同時讓低層模組實現該介面,從而實現了依賴關係的倒置:

之所以要把高層模組和底層模組的依賴關係倒置過來,主要是因為作為核心的高層模組不應該受到低層模組變化的影響。高層模組的變化原因應當只能有一個,那就是來自軟體使用者的業務變更需求

下面,我們通過分散式應用系統demo來介紹DIP的實現。

對於服務註冊中心Registry來說,當有新的服務註冊上來時,它需要把服務資訊(如服務ID、服務型別等)儲存下來,以便在後續的服務發現中能夠返回給客戶端。因此,Registry需要一個數據庫來輔助它完成業務。剛好,我們的資料庫模組實現了一個記憶體資料庫MemoryDb,於是我們可以這麼實現Registry:

// 服務註冊中心
public class Registry implements Service {
    ...
    // 直接依賴MemoryDb
    private final MemoryDb db;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;

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

// 記憶體資料庫
public class MemoryDb {
    private final Map<String, Table<?, ?>> tables;
    ...
    // 查詢表記錄
    public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        return table.query(primaryKey);
    }
    // 插入表記錄
    public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        table.insert(primaryKey, record);
    }
    // 更新表記錄
    public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) {
        Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName);
        table.update(primaryKey, record);
    }
    // 刪除表記錄
    public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) {
        Table<PrimaryKey, ?> table = (Table<PrimaryKey, ?>) tableOf(tableName);
        table.delete(primaryKey);
    }
    ...
}

按照上面的設計,模組間的依賴關係是Registry依賴於MemoryDb,也即高層模組依賴於低層模組。這種依賴關係是脆弱的,如果哪天需要把儲存服務資訊的資料庫從MemoryDb改成DiskDb,那麼我們也得改Registry的程式碼:

// 服務註冊中心
public class Registry implements Service {
    ...
    // 改成依賴DiskDb
    private final DiskDb db;
    ...
    private Registry(...) {
        ...
        // 初始化DiskDb
        this.db = DiskDb.instance();
        this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory);
        this.svcDiscovery = new SvcDiscovery(this.db);
    }
    ...
}

更好的設計應該是把Registry和MemoryDb的依賴關係倒置過來,首先我們需要從細節MemoryDb抽象出一個穩定的介面Db:

// demo/src/main/java/com/yrunz/designpattern/db/Db.java
// DB抽象介面
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);
    ...
}

接著,我們讓Registry依賴Db介面,而MemoryDb實現Db介面,以此來完成依賴倒置:

// demo/src/main/java/com/yrunz/designpattern/service/registry/Registry.java
// 服務註冊中心
public class Registry implements Service {
    ...
    // 只依賴於Db抽象介面
    private final Db db;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;

    private Registry(..., Db db) {
        ...
        // 依賴注入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
// 記憶體資料庫,實現Db抽象介面
public class MemoryDb implements Db {
    private final Map<String, Table<?, ?>> tables;
    ...
    // 查詢表記錄
    @Override
    public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) {...}
    // 插入表記錄
    @Override
    public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) {...}
    // 更新表記錄
    @Override
    public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) {...}
    // 刪除表記錄
    @Override
    public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) {...}
    ...
}

// demo/src/main/java/com/yrunz/designpattern/Example.java
public class Example {
    // 在main函式中完成依賴注入
    public static void main(String[] args) {
        ...
        // 將MemoryDb.instance()注入到Registry上
        Registry registry = Registry.of(..., MemoryDb.instance());
        registry.run();
    }
}

當高層模組依賴抽象介面時,總得在某個時候,某個地方把實現細節(低層模組)注入到高層模組上。在上述例子中,我們選擇在main函式上,在建立Registry物件時,把MemoryDb注入進去。

一般地,我們都會在main/啟動函式上完成依賴注入,常見的注入的方式有以下幾種:

  • 建構函式注入(Registry所使用的方法)
  • setter方法注入
  • 提供依賴注入的介面,客戶端直呼叫該介面即可
  • 通過框架進行注入,比如Spring框架中的註解注入能力

另外,DIP不僅僅適用於模組/類/介面設計,在架構層面也同樣適用,比如DDD的分層架構和Uncle Bob的整潔架構,都是運用了DIP:

當然,DIP並不是說高層模組是隻能依賴抽象介面,它的本意應該是依賴穩定的介面/抽象類/具象類。如果一個具象類是穩定的,比如Java中的String,那麼高層模組依賴它也沒有問題;相反,如果一個抽象介面是不穩定的,經常變化,那麼高層模組依賴該介面也是違反DIP的,這時候應該思考下介面是否抽象合理。

最後

本文花了很長的篇幅討論了23種設計模式背後的核心思想 —— SOLID原則,它能指導我們設計出高內聚、低耦合的軟體系統。但是它畢竟只是原則,如何落地到實際的工程專案上,還是需要參考成功的實踐經驗。而這些實踐經驗正是接下來我們要探討的設計模式

學習設計模式最好的方法就是實踐,在《實踐GoF的23種設計模式》後續的文章裡,我們將以本文介紹的分散式應用系統demo作為實踐示範,介紹23種設計模式的程式結構、適用場景、實現方法、優缺點等,讓大家對設計模式有個更深入的理解,能夠用對不濫用設計模式。

參考

  1. Clean Architecture, Robert C. Martin (“Uncle Bob”)
  2. 敏捷軟體開發:原則、模式與實踐, Robert C. Martin (“Uncle Bob”)
  3. 使用Go實現GoF的23種設計模式, 元閏子
  4. SOLID原則精解之里氏替換原則LSP, 人民副首席碼仔

 

點選關注,第一時間瞭解華為雲新鮮技術~