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

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

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

前言

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

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

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

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

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

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

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

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

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

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

示例程式碼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。

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

具體實現如下:

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

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

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

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

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

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

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

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

 

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

相關文章