什麼是好的程式碼?從 SOLID 的設計原則出發(五)介面隔離原則 & 總結(Interface Segregation Principle, ISP)
介面隔離原則(Interface Segregation Principle, ISP)
“Clients should not be forced to depend on methods they do not use.”
在詳細講這原則前,可以先聊聊當初發想的動機或想解決什麼問題,這樣往後說不定遇到某個類似問題,就更有想法了,那麼這原則是源於 Robert Martin 在 90 年代初時參與一個新型印表機的開發經驗:該印表機的系統是 C++ 撰寫的,用戶使用客戶端編輯列印任務後,會發送給該印表機處理、印出、裝訂,所有機器的運作都由該系統負責處理。可以想像成一個工廠流水線,產品製作過程都是一條龍。
那隨著系統規模增加,開發團隊對於原始碼的編譯效率開始無法接受:所有的組件都會引用一個代表印表機工作的 Job 類別,每當 Job 有任何更動,都需要使所有 #include
到 Job 的組件進行編譯,最後甚至 Job 類別一個 typo 的小修改,都造成整個專案一到數個小時的編譯時間…。
因為所有功能都需要 Job 這個巨大的類別,這類別提供了「列印、掃描、裝訂、傳真…等功能」,那假設在某開發階段需要調整「列印功能」的部分,要做一個「調整頁首頁尾時間格式」的功能,這時必然會從 Job 類別找出相關變數或方法來調整,但是在「列印功能」的開發人員無法知道這些方法或變數是否被其他功能(掃描、裝訂、傳真)所使用,或者是說就算知道但他的影響範圍及成本可能很大,因此即便每次只進行了「列印功能」的變更,其他功能也有可能引此損壞,出現了巨大的測試與 debug 等維護成本。
所以以下面範例的程式碼來看,當我動了 printAction(params:)
這個方法,但我不確定其他功能的人是否也使用到它,因為其他的功能都知道這個方法,是很有可能也使用到的。
解決方法:
- 先將「具體功能類別」與 Job 類別透過介面分離,功能類別只依賴介面 API。
- 為每個功能設置專屬該功能的 Job 介面(
Protocol
),例如:Printable, Scannable, Stapleable...等
。每個 Protcol 的 API 都是專為該功能所設計、暴露的。 - 讓實際的 Job 類別實現這些介面,讓與個別功能相關的 Job 工作避免互相干擾。
這時剛剛所說的問題(在某開發階段需要調整「列印功能」的部分,要做一個「調整頁首頁尾時間格式」的功能)這件事就變得風險較低,因為我們去動「列印功能」也只是在動 Printable
,對其他功能(裝訂、掃描)就不是直接影響了。
那 ISP 因為翻譯的關係,可能讓人直觀覺得要「透過介面來隔離系統中的某些事物」。
實際上的 隔離(Segregation)
是:當有一個功能豐富的 Concrete class 要給多個調用方提供功能時,應該透過介面與抽象類 (Interface)來把要提供給「不同調用方」的「不同功能」透過「不同的介面」給「隔離」開來
;不讓多個調用方因為能夠使用與其無關的 API 而造成無謂的維護性問題。
與其把 ISP 描述成「使用方不應被迫使用對其而言無用的方法或功能」,可以解讀成「為個別的使用方設置其專屬的功能介面,來避免多個介面彼此干擾
」。
在建立組件抽象介面
時,應當先從組件使用者 (Compoent Client)
的角度考慮思考,使該抽象介面中的方法盡可能專職服務該使用者的使用場景
。
SRP 用於指導組件的設計,ISP 用於指導介面的設計
- SRP 站在組件的角度,要求組件的整體內容,應當對特定的業務內聚。
- ISP 站在介面的角度,要求介面提供的方法,應當對特定的 Client 內聚。
ISP 重點在於我們從使用者的角度拆分出了介面後,也基本解決 Fat class
可能造成的可維護性問題。
ISP 的後期闡述
與 LSP 相同,後續 Robert Martin 也對此原則做了擴展闡述:「任何層次的軟體設計,都不應該依賴其所不需要的東西(也可以說不該強迫使用者(也可能是真實的用戶、其他組件/服務...)要去知道他不需要知道的東西)」
否則,就可能帶來意料外的維護性問題
。
例如:
- 一個專門測試「用戶功能」相關的人員,不應該需要了解「支付服務」的實現細節才能做測試,他應該只需要知道用戶相關功能。
- 一個調用
UserService.getUserInfo()
的行銷服務不需要了解其會員資料表的某個欄位是什麼意義才有辦法正確的使用這 API 。 - 一個串接了我們支付功能的廠商,不應該需要知道在調用支付 API 前,還要先依照順序調用我們的 A API, B API 與 C API 才能正常使用。
小結
OCP
與 DIP
是解耦方式, LSP
要求了介面的行為穩定性,此外我們還需要一個介面設計的指導方針,就是 ISP
。
ISP
建議:為了提升系統可維護性,介面應從調用方/使用者的角度仔細設計,把要提供給不同調用方的不同功能透過不同的介面給「隔離」開來。
ISP
的擴展闡述則是:「任何層次的軟體設計,都不應該依賴其所不需要的東西
」否則就可能帶來意外的維護性問題
。
SOLID 總結
假如從第一篇看到現在,對 SOLID 有一定的了解後,再來看這張 SOLID 各原則之間的關係圖,會豁然開朗的感覺,整個系統設計的理念前提就是在 SRP, OCP
,最後要對這些設計目標的實現,就是透過 DIP, LSP, ISP
來完成。
最後再複習 SOLID 各原則
SRP
表達了軟體架構簡潔的理念前提:即系統中的一個組件應當只為一項業務服務
。SRP
消除了一個組件可能被多個不關聯業務變更而造成的可維護性
問題,試圖從根本上解決程式碼因業務本質而發生的耦合問題。
- 來自兩個不同業務的需求變更,不可能
對同一組件進行修改。OCP
闡述了設計原則的指導思想,就是需要讓系統易於擴充,同時限制其每次系統變更所影響的範圍
;可透過設計模式或用DIP
來達成。
- 任何變更都只需要修改最小範圍的類別及修改數量,例如:加解密方法只需要修改EncryptionUtils
類別, UI 展示調整只需要修改 UI 層類別、業務新增流程的話只需要 UI 與應用層調整,無需動到其他不相關的領域層。DIP
描述了組件間如何抽象與組織的指導方針
。
- 系統中有app service, domain service, worker
等組件,可以明顯看到程式碼主體為「業務流程」而沒有「資料庫、外部訪問等處理細節,或其他更低層組件使用邏輯」,所以處理該細節的組件中也不會出現業務相關流程邏輯。LSP
的限制,降低了介面實現與使用方的耦合對系統的影響
,保證了介面行為的穩定。
- 使用端不會出現為了因應介面的不同實現而應對的程式碼,例如在 Client 端(App)在使用後端 API 不會出現像if (apiVersion == 1.15.0) == { ... }
這種程式碼。ISP
控管了維護和使用一個組件所應該暴露的知識
,在實作上指導了介面的設計。
- 非常類似SRP
;做到了ISP
的系統,當有一個介面因為業務 A 而修改時,受影響的,應該只有與業務 A 相關的類別而已。
- 另一個特徵是,做到了ISP
的系統,其維護、測試、學習成本都相當的低。
參考
- https://zh.m.wikipedia.org/zh-tw/%E6%8E%A5%E5%8F%A3%E9%9A%94%E7%A6%BB%E5%8E%9F%E5%88%99
- https://igouist.github.io/post/2020/11/oo-13-interface-segregation-principle/
- https://medium.com/%40f40507777/%E4%BB%8B%E9%9D%A2%E9%9A%94%E9%9B%A2%E5%8E%9F%E5%89%87-interface-segregation-principle-isp-6854c5b3b42c
- https://medium.com/%E7%A8%8B%E5%BC%8F%E6%84%9B%E5%A5%BD%E8%80%85/%E4%BD%BF%E4%BA%BA%E7%98%8B%E7%8B%82%E7%9A%84-solid-%E5%8E%9F%E5%89%87-%E4%BB%8B%E9%9D%A2%E9%9A%94%E9%9B%A2%E5%8E%9F%E5%89%87-interface-segregation-principle-50f54473c79e
- https://youtu.be/e0UOuQ_lCUY