摘要:本文以我們日常開發中經常碰到的一些技術/問題/場景作為切入點,示範如何運用設計模式來完成相關的實作。
本文分享自華為雲社群《實踐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,運作服務,目前模拟實作了服務注冊中心、線上商城服務叢集、服務消息中介等服務。
示例代碼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會導緻軟體的可維護性變得極差。但是,我們也不能盲目地進行子產品拆分,這樣會導緻代碼過于碎片化,同樣也會提升軟體的複雜性。比如,在前面的例子中,我們就沒有必要再對服務管理子產品進行拆分為服務注冊子產品、服務更新子產品和服務去注冊子產品,一是因為它們面向都使用者是一緻的;二是在可預見的未來它們要麼同時變化,要麼都不變。
是以,我們可以得出這樣的結論:
- 如果一個子產品面向的都是同一類使用者(變化原因一緻),那麼就沒必要進行拆分。
- 如果缺乏使用者歸類的判斷,那麼最好的拆分時機是變化發生時。
SRP是聚合和拆分的一個平衡,太過聚合會導緻牽一發動全身,拆分過細又會提升複雜性。要從使用者的視角來把握拆分的度,把面向不同使用者的功能拆分開。如果實在無法判斷/預測,那就等變化發生時再拆分,避免過度的設計。
OCP:開閉原則
開閉原則(The Open-Close Principle,OCP)中,“開”指的是對擴充開放,“閉”指的是對修改封閉,它的完整解釋為:
A software artifact should be open for extension but closed for modification.
通俗地講就是,一個軟體系統應該具備良好的可擴充性,新增功能應當通過擴充的方式實作,而不是在已有的代碼基礎上修改。
然而,從字面意思上看,OCP貌似又是自相沖突的:想要給一個子產品新增功能,但是有不能修改它。
*如何才能打破這個困境呢?*關鍵是抽象!優秀的軟體系統總是建立在良好的抽象的基礎上,抽象化可以降低軟體系統的複雜性。
*那麼什麼是抽象呢?*抽象不僅存在與軟體領域,在我們的生活中也随處可見。下面以《語言學的邀請》中的一個例子來解釋抽象的含義:
假設某農莊有一頭叫“阿花”的母牛,那麼:
1、當把它稱為“阿花”時,我們看到的是它獨一無二的一些特征:身上有很多斑點花紋、額頭上還有一個閃電形狀的傷疤。
2、當把它稱為母牛時,我們忽略了它的獨有特征,看到的是它與母牛“阿黑”,母牛“阿黃”的共同點:是一頭牛、雌性的。
3、當把它稱為家畜時,我們又忽略了它作為母牛的特征,而是看到了它和豬、雞、羊一樣的特點:是一個動物,在農莊裡圈養。
4、當把它稱為農莊财産時,我們隻關注了它和農莊上其他可售對象的共同點:可以賣錢、轉讓。
從“阿花”,到母牛,到家畜,再到農莊财産,這就是一個不斷抽象化的過程。
從上述例子中,我們可以得出這樣的結論:
- 抽象就是不斷忽略細節,找到事物間共同點的過程。
- 抽象是分層的,抽象層次越高,細節也就越少。
在回到軟體領域,我們也可以把上述的例子類比到資料庫上,資料庫的抽象層次從低至高可以是這樣的: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)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());
...
}
}