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

摘要:本文以我們日常開發中經常碰到的一些技術/問題/場景作為切入點,示範如何運用設計模式來完成相關的實現。

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

前言

從1995年GoF提出23種設計模式到現在,25年過去了,設計模式依舊是軟件領域的熱門話題。設計模式通常被定義為:

設計模式(Design Pattern)是一套被反覆使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結,使用設計模式是為了可重用代碼、讓代碼更容易被他人理解並且保證代碼可靠性。

從定義上看,設計模式其實是一種經驗的總結,是針對特定問題的簡潔而優雅的解決方案。既然是經驗總結,那麼學習設計模式最直接的好處就在於可以站在巨人的肩膀上解決軟件開發過程中的一些特定問題。

學習設計模式的最高境界是吃透它們本質思想,可以做到即使已經忘掉某個設計模式的名稱和結構,也能在解決特定問題時信手拈來。設計模式背後的本質思想,就是我們熟知的SOLID原則。如果把設計模式類比為武俠世界裏的武功招式,那麼SOLID原則就是內功內力。通常來說,先把內功練好,再來學習招式,會達到事半功倍的效果。因此,在介紹設計模式之前,很有必要先介紹一下SOLID原則。

本文首先會介紹本系列文章中用到的示例代碼demo的整體結構,然後開始逐一介紹SOLID原則,也即單一職責原則、開閉原則、里氏替換原則、接口隔離原則和依賴倒置原則。

一個簡單的分佈式應用系統

本系列示例代碼demo獲取地址://github.com/ruanrunxue/Practice-Design-Pattern–Java-Implementation

示例代碼demo工程實現了一個簡單的分佈式應用系統(單機版),該系統主要由以下幾個模塊組成:

  • 網絡 Network,網絡功能模塊,模擬實現了報文轉發、socket通信、http通信等功能。
  • 數據庫 Db,數據庫功能模塊,模擬實現了表、事務、dsl等功能。
  • 消息隊列 Mq,消息隊列模塊,模擬實現了基於topic的生產者/消費者的消息隊列。
  • 監控系統 Monitor,監控系統模塊,模擬實現了服務日誌的收集、分析、存儲等功能。
  • 邊車 Sidecar,邊車模塊,模擬對網絡報文進行攔截,實現access log上報、消息流控等功能。
  • 服務 Service,運行服務,當前模擬實現了服務註冊中心、在線商城服務集群、服務消息中介等服務。

示例代碼demo工程的主要目錄結構如下:

├── db                # 數據庫模塊,定義Db、Table、TableVisitor等抽象接口 【@單例模式】
│   ├── cache         # 數據庫緩存代理,為Db新增緩存功能 【@代理模式】
│   ├── console       # 數據庫控制台實現,支持dsl語句查詢和結果顯示 【@適配器模式】
│   ├── dsl           # 實現數據庫dsl語句查詢能力,當前只支持select語句查詢 【@解釋器模式】
│   ├── exception     # 數據庫模塊相關異常定義
│   ├── iterator      # 遍歷表迭代器,包含按序遍歷和隨機遍歷 【@迭代器模式】
│   └── transaction   # 實現數據庫的事務功能,包括執行、提交、回滾等 【@命令模式】【@備忘錄模式】
├── monitor        # 監控系統模塊,採用插件式的架構風格,當前實現access log日誌etl功能
│   ├── config     # 監控系統插件配置模塊  【@抽象工廠模式】【@組合模式】
│   │   ├── json   # 實現基於json格式文件的配置加載功能
│   │   └── yaml   # 實現基於yaml格式文件的配置加載功能
│   ├── entity     # 監控系統實體對象定義
│   ├── exception  # 監控系統相關異常
│   ├── filter     # Filter插件的實現定義  【@責任鏈模式】
│   ├── input      # Input插件的實現定義   【@策略模式】
│   ├── output     # Output插件的實現定義
│   ├── pipeline   # Pipeline插件的實現定義,一個pipeline表示一個ETL處理流程 【@橋接模式】
│   ├── plugin     # 插件抽象接口定義
│   └── schema     # 監控系統相關的數據表定義 
├── mq          # 消息隊列模塊
├── network        # 網絡模塊,模擬網絡通信,定義了socket、packet等通用類型/接口  【@觀察者模式】
│   └── http       # 模擬實現了http通信等服務端、客戶端能力
├── service           # 服務模塊,定義了服務的基本接口
│   ├── mediator      # 服務消息中介,作為服務通信的中轉方,實現了服務發現,消息轉發的能力 【@中介者模式】
│   ├── registry      # 服務註冊中心,提供服務註冊、去註冊、更新、 發現、訂閱、去訂閱、通知等功能
│   │   ├── entity    # 服務註冊/發現相關的實體定義 【@原型模式】【@建造者模式】
│   │   └── schema    # 服務註冊中心相關的數據表定義 【@訪問者模式】【@享元模式】
│   └── shopping      # 模擬在線商城服務群的定義,包含訂單服務、庫存服務、支付服務、發貨服務 【@外觀模式】
└── sidecar        # 邊車模塊,對socket進行攔截,提供http access log、流控功能 【@裝飾者模式】【@工廠模式】
    └── flowctrl   # 流控模塊,基於消息速率進行隨機流控 【@模板方法模式】【@狀態模式】

SRP:單一職責原則

單一職責原則(The Single Responsibility Principle,SRP)應該是SOLID原則中,最容易被理解的一個,但同時也是最容易被誤解的一個。很多人會把「將大函數重構成一個個職責單一的小函數」這一重構手法等價為SRP,這是不對的,小函數固然體現了職責單一,但這並不是SRP。

SRP傳播最廣的定義應該是Uncle Bob給出的:

A module should have one, and only one, reason to change.

也即,一個模塊應該有且只有一個導致其變化的原因。

這個解釋里有2個需要理解的地方:

(1)如何定義一個模塊

我們通常會把一個源文件定義為最小粒度的模塊。

(2)如何找到這個原因

一個軟件的變化往往是為了滿足某個用戶的需求,那麼這個用戶就是導致變化的原因。但是,一個模塊的用戶/客戶端程序往往不只一個,比如Java中的ArrayList類,它可能會被成千上萬的程序使用,但我們不能說ArrayList職責不單一。因此,我們應該把「一個用戶」改為「一類角色」,比如ArrayList的客戶端程序都可以歸類為「需要鏈表/數組功能」的角色。

於是,Uncle Bob給出了SRP的另一個解釋:

A module should be responsible to one, and only one, actor.

有了這個解釋,我們就可以理解函數職責單一併不等同於SRP,比如在一個模塊有A和B兩個函數,它們都是職責單一的,但是函數A的使用者是A類用戶,函數B的使用者是B類用戶,而且A類用戶和B類用戶變化的原因都是不一樣的,那麼這個模塊就不滿足SRP了。

下面,以我們的分佈式應用系統demo為例進一步探討。對於Registry類(服務註冊中心)來說,它對外提供的基本能力有服務註冊、更新、去註冊和發現功能,那麼,我們可以這麼實現:

// demo/src/main/java/com/yrunz/designpattern/service/Registry.java
public class Registry implements Service {
    private final HttpServer httpServer;
    private final Db db;
    ...
    @Override
    public void run() {
        httpServer.put("/api/v1/service-profile", this::register)
                .post("/api/v1/service-profile", this::update)
                .delete("/api/v1/service-profile", this::deregister)
                .get("/api/v1/service-profile", this::discovery)
                .start();
    }
    // 服務註冊
    private HttpResp register(HttpReq req) {
      ...
    }
    // 服務更新
    private HttpResp update(HttpReq req) {
      ...
    }
    // 服務去註冊
    private HttpResp deregister(HttpReq req) {
      ...
    }
    // 服務發現
    private HttpResp discovery(HttpReq req) {
      ...
    }
}

上述實現中,Registry包含了register、update、deregister、discovery等4個主要方法,正好對應了Registry對外提供的能力,看起來已經是職責單一了。

但是在仔細思考一下就會發現,服務註冊、更新和去註冊是給專門給服務提供者使用的功能,而服務發現則是專門給服務消費者使用的功能。服務提供者和服務消費者是兩類不同的角色,它們產生變化的時間和方向都可能不同。比如:

當前服務發現功能是這麼實現的:Registry從滿足查詢條件的所有ServiceProfile中挑選一個返回給服務消費者(也即Registry自己做了負載均衡)。

假設現在服務消費者提出新的需求:Registry把所有滿足查詢條件的ServiceProfile都返回,由服務消費者自己來做負載均衡。

為了實現這樣的功能,我們就要修改Registry的代碼。按理,服務註冊、更新、去註冊等功能並不應該受到影響,但因為它們和服務發現功能都在同一個模塊(Registry)里,於是被迫也受到影響了,比如可能會代碼衝突。

因此,更好的設計是將register、update、deregister內聚到一個服務管理模塊SvcManagement,discovery則放到另一個服務發現模塊SvcDiscovery,服務註冊中心Registry再組合SvcManagement和SvcDiscovery。

具體實現如下:

// demo/src/main/java/com/yrunz/designpattern/service/SvcManagement.java
class SvcManagement {
    private final Db db;
    ...
    // 服務註冊
    HttpResp register(HttpReq req) {
      ...
    }
    // 服務更新
    HttpResp update(HttpReq req) {
      ...
    }
    // 服務去註冊
    HttpResp deregister(HttpReq req) {
      ...
    }
}

// demo/src/main/java/com/yrunz/designpattern/service/SvcDiscovery.java
class SvcDiscovery {
    private final Db db;
    ...
    // 服務發現
    HttpResp discovery(HttpReq req) {
      ...
    }
}

// demo/src/main/java/com/yrunz/designpattern/service/Registry.java
public class Registry implements Service {
    private final HttpServer httpServer;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;
    ...
    @Override
    public void run() {
        // 使用子模塊的方法完成具體業務
        httpServer.put("/api/v1/service-profile", svcManagement::register)
                .post("/api/v1/service-profile", svcManagement::update)
                .delete("/api/v1/service-profile", svcManagement::deregister)
                .get("/api/v1/service-profile", svcDiscovery::discovery)
                .start();
    }
}

除了重複的代碼編譯,違反SRP還會帶來以下2個常見的問題:

1、代碼衝突。程序員A修改了模塊的A功能,而程序員B在不知情的情況下也在修改該模塊的B功能(因為A功能和B功能面向不同的用戶,完全可能由2位不同的程序員來維護),當他們同時提交修改時,代碼衝突就會發生(修改了同一個源文件)。

2、A功能的修改影響了B功能。如果A功能和B功能都使用了模塊里的一個公共函數C,現在A功能有新的需求需要修改函數C,那麼如果修改人沒有考慮到B功能,那麼B功能的原有邏輯就會受到影響。

由此可見,違反SRP會導致軟件的可維護性變得極差。但是,我們也不能盲目地進行模塊拆分,這樣會導致代碼過於碎片化,同樣也會提升軟件的複雜性。比如,在前面的例子中,我們就沒有必要再對服務管理模塊進行拆分為服務註冊模塊、服務更新模塊和服務去註冊模塊,一是因為它們面向都用戶是一致的;二是在可預見的未來它們要麼同時變化,要麼都不變。

因此,我們可以得出這樣的結論:

  1. 如果一個模塊面向的都是同一類用戶(變化原因一致),那麼就沒必要進行拆分。
  2. 如果缺乏用戶歸類的判斷,那麼最好的拆分時機是變化發生時。

SRP是聚合和拆分的一個平衡,太過聚合會導致牽一髮動全身,拆分過細又會提升複雜性。要從用戶的視角來把握拆分的度,把面向不同用戶的功能拆分開。如果實在無法判斷/預測,那就等變化發生時再拆分,避免過度的設計。

OCP:開閉原則

開閉原則(The Open-Close Principle,OCP)中,「開」指的是對擴展開放,「閉」指的是對修改封閉,它的完整解釋為:

A software artifact should be open for extension but closed for modification.

通俗地講就是,一個軟件系統應該具備良好的可擴展性,新增功能應當通過擴展的方式實現,而不是在已有的代碼基礎上修改。

然而,從字面意思上看,OCP貌似又是自相矛盾的:想要給一個模塊新增功能,但是有不能修改它。

*如何才能打破這個困境呢?*關鍵是抽象!優秀的軟件系統總是建立在良好的抽象的基礎上,抽象化可以降低軟件系統的複雜性。

*那麼什麼是抽象呢?*抽象不僅存在與軟件領域,在我們的生活中也隨處可見。下面以《語言學的邀請》中的一個例子來解釋抽象的含義:

假設某農莊有一頭叫「阿花」的母牛,那麼:

1、當把它稱為「阿花」時,我們看到的是它獨一無二的一些特徵:身上有很多斑點花紋、額頭上還有一個閃電形狀的傷疤。

2、當把它稱為母牛時,我們忽略了它的獨有特徵,看到的是它與母牛「阿黑」,母牛「阿黃」的共同點:是一頭牛、雌性的。

3、當把它稱為家畜時,我們又忽略了它作為母牛的特徵,而是看到了它和豬、雞、羊一樣的特點:是一個動物,在農莊里圈養。

4、當把它稱為農莊財產時,我們只關注了它和農莊上其他可售對象的共同點:可以賣錢、轉讓。

從「阿花」,到母牛,到家畜,再到農莊財產,這就是一個不斷抽象化的過程。

從上述例子中,我們可以得出這樣的結論:

  1. 抽象就是不斷忽略細節,找到事物間共同點的過程。
  2. 抽象是分層的,抽象層次越高,細節也就越少。

在回到軟件領域,我們也可以把上述的例子類比到數據庫上,數據庫的抽象層次從低至高可以是這樣的:MySQL 8.0版本 -> MySQL -> 關係型數據庫 -> 數據庫。現在假設有一個需求,需要業務模塊將業務數據保存到數據庫上,那麼就有以下幾種設計方案:

  • 方案一:把業務模塊設計為直接依賴MySQL 8.0版本。因為版本總是經常變化的,如果哪天MySQL升級了版本,那麼我們就得修改業務模塊進行適配,所以方案一違反了OCP。
  • 方案二:把業務模塊設計為依賴MySQL。相比於方案一,方案二消除了MySQL版本升級帶來的影響。現在考慮另一種場景,如果因為某些原因公司禁止使用MySQL,必須切換到PostgreSQL,這時我們還是得修改業務模塊進行數據庫的切換適配。因此,在這種場景下,方案二也違反了OCP。
  • 方案三:把業務模塊設計為依賴關係型數據庫。到了這個方案,我們基本消除了關係型數據庫切換的影響,可以隨時在MySQL、PostgreSQL、Oracle等關係型數據庫上進行切換,而無須修改業務模塊。但是,熟悉業務的你預測未來隨着用戶量的迅速上漲,關係型數據庫很有可能無法滿足高並發寫的業務場景,於是就有了下面的最終方案。
  • 方案四:把業務模塊設計為依賴數據庫。這樣,不管以後使用MySQL還是PostgreSQL,關係型數據庫還是非關係型數據庫,業務模塊都不需要再改動。到這裡,我們基本可以認為業務模塊是穩定的,不會受到底層數據庫變化帶來的影響,滿足了OCP。

我們可以發現,上述方案的演進過程,就是我們不斷對業務依賴的數據庫模塊進行抽象的過程,最終設計出穩定的、服務OCP的軟件。

那麼,在編程語言中,我們用什麼來表示「數據庫」這一抽象呢?是接口

數據庫最常見的幾個操作就是CRUD,因此我們可以設計這麼一個Db接口來表示「數據庫」:

public interface Db {
    Record query(String tableName, Condition cond);
    void insert(String tableName, Record record);
    void update(String tableName, Record record);
    void delete(String tableName, Record record);
}

這樣,業務模塊和數據庫模塊之間的依賴關係就變成如下圖所示:

滿足OCP的另一個關鍵點就是分離變化,只有先把變化點識別分離出來,我們才能對它進行抽象化。下面以我們的分佈式應用系統demo為例,解釋如何實現變化點的分離和抽象。

在demo中,監控系統主要負責對服務的access log進行ETL操作,也即涉及如下3個操作:1)從消息隊列中獲取日誌數據;2)對數據進行加工;3)將加工後的數據存儲在數據庫上。

我們把整一個日誌數據的處理流程稱為pipeline,那麼我們可以這麼實現:

public class Pipeline implements Plugin {
    private Mq mq;
    private Db db;
    ...
    public void run() {
        while (!isClose.get()) {
            // 1、從消息隊列中獲取數據
            Message msg = mq.consume("monitor.topic");
            String accessLog = msg.payload();

            // 2、對數據進行清理操作,轉換為json字符串對格式
            ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance);
            logJson.put("content", accessLog);
            String data = logJson.asText();

            // 3、存儲到數據庫上
            db.insert("logs_table", logId, data);
        }
    }
    ...
}

現在考慮新上線一個服務,但是這個服務不支持對接消息隊列了,只支持socket傳輸數據,於是我們得在Pipeline上新增一個InputType來判斷是否適用socket輸入源:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            String accessLog;
            // 使用消息隊列為消息來源
            if (inputType == InputType.MQ) {
                Message msg = mq.consume("monitor.topic");
                accessLog = msg.payload();
            }  else {
                // 使用socket為消息來源
                Packet packet = socket.receive();
                accessLog = packet.payload().toString();
            }
           ...
        }
    }
}

過一段時間,有需求需要給access log打上一個時間戳,方便後續的日誌分析,於是我們需要修改Pipeline的數據加工邏輯:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            ...
            // 對數據進行清理操作,轉換為json字符串對格式
            ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance);
            logJson.put("content", accessLog);
            // 新增一個時間戳字段
            logJson.put("timestamp", Instant.now().getEpochSecond());
            String data = logJson.asText();
           ...
        }
    }
}

很快,又有一個需求,需要將加工後的數據存儲到ES上,方便後續的日誌檢索,於是我們再次修改了Pipeline的數據存儲邏輯:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            ...
            // 存儲到ES上
            if (outputType == OutputType.DB) {
                db.insert("logs_table", logId, data);
            } else {
            // 存儲到ES上
                es.store(logId, data)
            }
        }
    }
}

在上述的pipeline例子中,每次新增需求都需要修改Pipeline模塊,明顯違反了OCP。下面,我們來對它進行優化,使它滿足OCP。

第一步是分離變化點,根據pipeline的業務處理邏輯,我們可以發現3個獨立的變化點,數據的獲取、加工和存儲。第二步,我們對這3個變化點進行抽象,設計出以下3個抽象接口:

// demo/src/main/java/com/yrunz/designpattern/monitor/input/InputPlugin.java
// 數據獲取抽象接口
public interface InputPlugin extends Plugin {
    Event input();
    void setContext(Config.Context context);
}

// demo/src/main/java/com/yrunz/designpattern/monitor/filter/FilterPlugin.java
// 數據加工抽象接口
public interface FilterPlugin extends Plugin {
    Event filter(Event event);
}

// demo/src/main/java/com/yrunz/designpattern/monitor/output/OutputPlugin.java
// 數據存儲抽象接口
public interface OutputPlugin extends Plugin {
    void output(Event event);
    void setContext(Config.Context context);
}

最後,Pipeline的實現如下,只依賴於InputPlugin、FilterPlugin和OutputPlugin三個抽象接口。後續再有需求變更,只需擴展對應的接口即可,Pipeline無須再變更:

// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/Pipeline.java
// ETL流程定義
public class Pipeline implements Plugin {
    final InputPlugin input;
    final FilterPlugin filter;
    final OutputPlugin output;
    final AtomicBoolean isClose;

    public Pipeline(InputPlugin input, FilterPlugin filter, OutputPlugin output) {
        this.input = input;
        this.filter = filter;
        this.output = output;
        this.isClose = new AtomicBoolean(false);
    }

    // 運行pipeline
    public void run() {
        while (!isClose.get()) {
            Event event = input.input();
            event = filter.filter(event);
            output.output(event);
        }
    }
    ...
}

OCP是軟件設計的終極目標,我們都希望能設計出可以新增功能卻不用動老代碼的軟件。但是100%的對修改封閉肯定是做不到的,另外,遵循OCP的代價也是巨大的。它需要軟件設計人員能夠根據具體的業務場景識別出那些最有可能變化的點,然後分離出去,抽象成穩定的接口。這要求設計人員必須具備豐富的實戰經驗,以及非常熟悉該領域的業務場景。否則,盲目地分離變化點、過度地抽象,都會導致軟件系統變得更加複雜。

LSP:里氏替換原則

上一節介紹中,OCP的一個關鍵點就是抽象,而如何判斷一個抽象是否合理,這是里氏替換原則(The Liskov Substitution Principle,LSP)需要回答的問題。

LSP的最初定義如下:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

簡單地講就是,子類型必須能夠替換掉它們的基類型,也即基類中的所有性質,在子類中仍能成立。一個簡單的例子:假設有一個函數f,它的入參類型是基類B。同時,基類B有一個派生類D,如果把D的實例傳遞給函數f,那麼函數f的行為功能應該是不變的。

由此可以看出,違反LSP的後果很嚴重,會導致程序出現不在預期之內的行為錯誤。下面,我們看一個經典反面例子,矩形與正方形。

假設現在有矩形Rectangle,可以通過setWidth方法設置寬度,setLength方法設置長度,area方法得到矩形面積:

// 矩形定義
public class Rectangle {
    private int width; // 寬度
    private int length; // 長度
    // 設置寬度
    public void setWidth(int width) {
        this.width = width;
    }
    // 設置長度
    public void setLength(int length) {
        this.length = length;
    }
    // 返回矩形面積
    public int area() {
        return width * length;
    }
}

另外,有一個客戶端程序Cient,它的方法f以Rectangle作為入參,邏輯為校驗矩形的邏輯:

// 客戶端程序
public class Client {
    // 校驗矩形面積為長*寬
    public void f(Rectangle rectangle) {
        rectangle.setWidth(5);
        rectangle.setLength(4);
        if (rectangle.area() != 20) {
            throw new RuntimeException("rectangle's area is invalid");
        }
        System.out.println("rectangle's area is valid");
    }
}
// 運行程序
public static void main(String[] args) {
      Rectangle rectangle = new Rectangle();
      Client client = new Client();
      client.f(rectangle);
 }
// 運行結果:
// rectangle's area is valid

現在,我們打算新增一種新的類型,正方形Square。因為從數學上看,正方形也是矩形的一種,因此我們讓Square繼承了Rectangle。另外,正方形要求長寬一致,因此Square重寫了setWidth和setLength方法:

// 正方形,長寬相等
public class Square extends Rectangle {
    // 設置寬度
    public void setWidth(int width) {
        this.width = width;
        // 長寬相等,因此同時設置長度
        this.length = width;
    }
    // 設置長度
    public void setLength(int length) {
        this.length = length;
        // 長寬相等,因此同時設置長度
        this.width = length;
    }
}

下面,我們把Square實例化後作為入參傳入Cient.f上:

public static void main(String[] args) {
    Square square = new Square();
    Client client = new Client();
    client.f(square);
}
// 運行結果:
// Exception in thread "main" java.lang.RuntimeException: rectangle's area is invalid
//     at com.yrunz.designpattern.service.mediator.Client.f(Client.java:8)
//     at com.yrunz.designpattern.service.mediator.Client.main(Client.java:16)

我們發現Cient.f的行為發生了變化,子類型Square並不能替代基類型Rectangle,違反了LSP。

出現上面的這種違反LSP的設計,主要原因還是我們孤立地進行模型設計,沒有從客戶端程序的角度來審視該設計是否正確。我們孤立地認為在數學上成立的關係(正方形 IS-A 矩形),在程序中也一定成立,而忽略了客戶端程序的使用方法(先設置寬度為5,長度為4,然後校驗面積為20)。

這個例子告訴我們:一個模型的正確性或有效性,只能通過客戶端程序來體現。

下面,我們總結一下在繼承體系(IS-A)下,要想設計出符合LSP的模型所需要遵循的一些約束:

  1. 基類應該設計為一個抽象類(不能直接實例化,只能被繼承)。
  2. 子類應該實現基類的抽象接口,而不是重寫基類已經實現的具體方法。
  3. 子類可以新增功能,但不能改變基類的功能。
  4. 子類不能新增約束,包括拋出基類沒有聲明的異常。

前面的矩形和正方形的例子中,幾乎把這些約束都打破了,從而導致了程序的異常行為:1)Square的基類Rectangle不是一個抽象類,打破約束1;2)Square重寫了基類的setWidth和setLength方法,打破約束2;3)Square新增了Rectangle沒有的約束,長寬相等,打破約束4。

除了繼承之外,另一個實現抽象的機制是接口。如果我們是面向接口的設計,那麼上述的約束1~3其實已經滿足了:1)接口本身不具備實例化能力,滿足約束1;2)接口沒有具體的實現方法(Java中接口的default方法比較例外,本文先不考慮),也就不會被重寫,滿足約束2;3)接口本身只定義了行為契約,並沒有實際的功能,因此也不會被改變,滿足約束3。

因此,使用接口替代繼承來實現多態和抽象,能夠減少很多不經意的錯誤。但是面向接口設計仍然需要遵循約束4,下面我們以分佈式應用系統demo為例,介紹一個比較隱晦地打破約束4,從而違反了LSP的實現。

還是以監控系統為例,為例實現ETL流程的靈活配置,我們需要通過配置文件定義pipeline的流程功能(數據從哪獲取、需要經過哪些加工、加工後存儲到哪裡)。當前需要支持json和yaml兩種配置文件格式,以yaml配置為例,配置內容是這樣的:

# src/main/resources/pipelines/pipeline_0.yaml
name: pipeline_0 # pipeline名稱
type: single_thread # pipeline類型
input: # input插件定義(數據從哪裡來)
  name: input_0 # input插件名稱
  type: memory_mq # input插件類型
  context: # input插件的初始化上下文
    topic: access_log.topic
filter: # filter插件定義(需要經過哪些加工)
  - name: filter_0 # 加工流程filter_0定義,類型為log_to_json
    type: log_to_json
  - name: filter_1 # 加工流程filter_1定義,類型為add_timestamp
    type: add_timestamp
  - name: filter_2 # 加工流程filter_2定義,類型為json_to_monitor_event
    type: json_to_monitor_event
output: # output插件定義(加工後存儲到哪裡)
  name: output_0 # output插件名稱
  type: memory_db # output插件類型
  context: # output插件的初始化上下文
    tableName: monitor_event_0

首先我們定義一個Config接口來表示「配置」這一抽象:

// demo/src/main/java/com/yrunz/designpattern/monitor/config/Config.java
public interface Config {
    // 從json字符串中加載配置
    void load(String conf);
}

另外,上述配置中的input、filter、output子項,可以認為是InputPlugin、FilterPlugin、OutputPlugin插件的配置項,由Pipeline插件的配置項組合在一起,因此我們定義了如下幾個Config的抽象類:

// demo/src/main/java/com/yrunz/designpattern/monitor/config/InputConfig.java
public abstract class InputConfig implements Config {
    protected String name;
    protected InputType type;
    protected Context ctx;
    // 子類實現具體加載邏輯,支持yaml和json的加載方式
    @Override
    public abstract void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/FilterConfig.java
public abstract class FilterConfig implements Config {
    protected List<Item> items;
    // 子類實現具體加載邏輯,支持yaml和json的加載方式
    @Override
    public abstract void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/OutputConfig.java
public abstract class OutputConfig implements Config {
    protected String name;
    protected OutputType type;
    protected Context ctx;
    // 子類實現具體加載邏輯,支持yaml和json的加載方式
    @Override
    abstract public void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/PipelineConfig.java
public abstract class PipelineConfig implements Config {
    protected String name;
    protected PipelineType type;
    protected final InputConfig inputConfig;
    protected final FilterConfig filterConfig;
    protected final OutputConfig outputConfig;
    // 子類實現具體加載邏輯,支持yaml和json的加載方式
    @Override
    public abstract void load(String conf);
}

最後再實現具體的基於json和yaml的子類:

// json方式加載Config子類目錄:src/main/java/com/yrunz/designpattern/monitor/config/json
public class JsonInputConfig extends InputConfig  {...}
public class JsonFilterConfig extends FilterConfig  {...}
public class JsonOutputConfig extends OutputConfig  {...}
public class JsonPipelineConfig extends PipelineConfig  {...}
// yaml方式加載Config子類目錄:src/main/java/com/yrunz/designpattern/monitor/config/yaml
public class YamlInputConfig extends InputConfig  {...}
public class YamlFilterConfig extends FilterConfig  {...}
public class YamlOutputConfig extends OutputConfig  {...}
public class YamlPipelineConfig extends PipelineConfig  {...}

因為涉及到從配置到對象的實例化過程,自然會想到使用***工廠模式***來創建對象。另外因為Pipeline、InputPlugin、FilterPlugin和OutputPlugin都實現了Plugin接口,我們也很容易想到定義一個PluginFactory接口來表示「插件工廠」這一抽象,具體的插件工廠再實現該接口:

// 插件工廠接口,根據配置實例化插件
public interface PluginFactory {
    Plugin create(Config config);
}
// input插件工廠
public class InputPluginFactory implements PluginFactory {
    ...
    @Override
    public InputPlugin create(Config config) {
        InputConfig conf = (InputConfig) config;
        try {
            Class<?> inputClass = Class.forName(conf.type().classPath());
            InputPlugin input = (InputPlugin) inputClass.getConstructor().newInstance();
            input.setContext(conf.context());
            return input;
        } ...
    }
}
// filter插件工廠
public class FilterPluginFactory implements PluginFactory {
    ...
    @Override
    public FilterPlugin create(Config config) {
        FilterConfig conf = (FilterConfig) config;
        FilterChain filterChain = FilterChain.empty();
        String name = "";
        try {
            for (FilterConfig.Item item : conf.items()) {
                name = item.name();
                Class<?> filterClass = Class.forName(item.type().classPath());
                FilterPlugin filter = (FilterPlugin) filterClass.getConstructor().newInstance();
                filterChain.add(filter);
            }
        } ...
    }
}
// output插件工廠
public class OutputPluginFactory implements PluginFactory {
    ...
    @Override
    public OutputPlugin create(Config config) {
        OutputConfig conf = (OutputConfig) config;
        try {
            Class<?> outputClass = Class.forName(conf.type().classPath());
            OutputPlugin output = (OutputPlugin) outputClass.getConstructor().newInstance();
            output.setContext(conf.context());
            return output;
        } ...
    }
}
// pipeline插件工廠
public class PipelineFactory implements PluginFactory {
    ...
    @Override
    public Pipeline create(Config config) {
        PipelineConfig conf = (PipelineConfig) config;
        InputPlugin input = InputPluginFactory.newInstance().create(conf.input());
        FilterPlugin filter = FilterPluginFactory.newInstance().create(conf.filter());
        OutputPlugin output = OutputPluginFactory.newInstance().create(conf.output());
        ...
    }
}

最後,通過PipelineFactory來實創建Pipline對象:

Config config = YamlPipelineConfig.of(YamlInputConfig.empty(), YamlFilterConfig.empty(), YamlOutputConfig.empty());
config.load(Files.readAllBytes("pipeline_0.yaml"));
Pipeline pipeline = PipelineFactory.newInstance().create(config);
assertNotNull(pipeline);
// 運行結果:
Pass

到目前為止,上述的設計看起來是合理的,運行也沒有問題。

但是,細心的讀者可能會發現,每個插件工廠子類的create方法的第一行代碼都是一個轉型語句,比如PipelineFactory的是PipelineConfig conf = (PipelineConfig) config;。所以,上一段代碼能夠正常運行的前提是:傳入PipelineFactory.create方法的入參必須是PipelineConfig 。如果客戶端程序傳入InputConfig的實例,PipelineFactory.create方法將會拋出轉型失敗的異常。

上述這個例子就是一個違反LSP的典型場景,雖然在約定好的前提下,程序可以運行正確,但是如果有客戶端不小心破壞了這個約定,就會帶來程序行為異常(我們永遠無法預知客戶端的所有行為)。

要糾正這個問題也很簡單,就是去掉PluginFactory這一層抽象,讓PipelineFactory.create等工廠方法的入參聲明為具體的配置類,比如PipelineFactory可以這麼實現:

// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/PipelineFactory.java
// pipeline插件工廠,不在實現PluginFactory接口
public class PipelineFactory {
    ...
    // 工廠方法入參為PipelineConfig實現類,消除轉型
    public Pipeline create(PipelineConfig config) {
        InputPlugin input = InputPluginFactory.newInstance().create(config.input());
        FilterPlugin filter = FilterPluginFactory.newInstance().create(config.filter());
        OutputPlugin output = OutputPluginFactory.newInstance().create(config.output());
        ...
    }
}

從上述幾個例子中,我們可以看出遵循LSP的重要性,而設計出符合LSP的軟件的要點就是,根據該軟件的使用者行為作出的合理假設,以此來審視它是否具備有效性和正確性。

 

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