什麼是好的程式碼?從 SOLID 的設計原則出發(二)開放閉合原則 (The Open/Closed Principle, OCP)

ChengYang
13 min readJul 4, 2022

--

宜蘭太平山

開放閉合原則 (The Open/Closed Principle, OCP)

“Software entitles (classes, modules, functions, etc.) should be open for extension but close for modification.”

我們都知道軟體系統從第一天被建立開始,往後會持續一直迭代,有無數功能變更。

但如果每次的功能變更或延伸都造成系統內一連串相依組件的調整,那很明顯這個系統的設計可能很失敗。

所以一個良好的系統設計,其行為應該能在「不需要修改原始碼的前提下就可以輕易擴充」。舉一個實際的例子來理解,想像你有一台相機,它有拍照的功能,某天你想要搞一點新花招,你買了玻璃紙,把它裝在鏡頭前面,結果你的相機就多了濾鏡功能,在新增了這個濾鏡功能過程中,你完全沒有動到它原本的拍照的功能。

一開始 OCP 是很有爭議的一條,我們想想看剛剛說的「不需要修改原始碼的前提下就可以輕易擴充」,這乍聽之下會以為我們工程師都會通靈嗎?需求就是要新增或修改某個功能,怎麼可能不修改原始碼就可以輕易擴充。

其實從90年到17年之間,許多社群討論 OCP ,最終大家的解讀是「系統要妥善預設複雜度的發生點,並在該處建立合適的擴展點 (extension point);以便往後要新增/修改行為時,能夠透過擴展點接受變更 (open for predicted extension),而原有主流程或使用該擴展點的 client 本身不需要修改 (closed for client modification)」。

來看一個實際軟體上的例子,我們可能有一個 UserPayManager ,他主要負責透過用戶等級來決定要付多少錢的功能。

目前看上去還滿單純的,除了「超級 VIP 會員」的邏輯多一點點。

那麼往後需求會一直調整,可能會增加會員等級,可能每個會員等級又有各自的業務邏輯,例如專屬會員滿多少金額可以打幾折、超級 VIP 會員累積消費滿多少可以打幾折,VIP 會員某些條件下又可以有多少優惠…,相信很多人都可以預測未來會發生這些事情。

那我們就透過「開放閉合原則 OCP 」來改良一下。

我們做到了以下幾個關鍵的事情:

  • 系統要能妥善預測複雜度的發生點:
    -> 計算應付價格處,這決定是這個業務邏輯最核心也會往後最複雜的地方。
  • 並在該處建立合適的擴展點
    -> 建立 UserPayProtocol Protocol,不在直接計算而採用 策略模式 (strategy pattern),將計算邏輯置於不同 UserPayProtocol 實現。
  • 以便往後要新增/修改行為時,能夠透過擴展點接受變更:
    -> 當需要增加會員等級邏輯時,增加對應的 UserPayProtocol 即可。
  • 原有主流程或使用該擴展點的 client 本身不需要修改:
    -> 原流程確實不用在修改。

假設在 GoF 的「設計模式」來解讀「開放閉合原則」的話,以下幾點是很好的體現:

  • 策略模式(Strategy pattern):
    擴展點與對 client 隱藏的部分是業務流程中可能因新需求或新場景而增減的演算法
    策略模式解釋:
    指對象有某個行爲,但是在不同的場景中,該行爲有不同的實現算法。比如每個人都要「交個人所得稅」,但是「在美國交個人所得稅」和「在中華民國交個人所得稅」就有不同的算稅方法。
    所以在我們的例子中,每個會員等級都需要計算商品金額,只是不同等級的會員有不同的算法。
  • 模板方法(Template method pattern):
    擴展點與對 client 隱藏的部分為演算法可以改變的步驟。
    模板方法解釋:
    就是定義一個操作中的演算法的骨架,而將一些步驟延遲到 subclass 中,使得 subclass 可以不改變一個演算法的結構,就可以重新定義該演算法的某些特定步驟。
    我們簡單看一個例子:

在這個例子中,我們有 Milk 這個 class ,我們可以透過它做出原味牛奶,但今天我們想做不同口味的牛奶,怎麼辦?很簡單,例如今天想做 BannerMilkChocolateMilk ,我們只需要 override shouldAddCondiments 以及 addCondiments() 即可,這時應該會發現我們最核心製作牛奶的的過程沒有被修改到,只有調整 subclass 並 override 而已,但我們就可以調出不同口味的牛奶了,核心程式碼 (class Milk) 就沒有被修改到了。

  • 外觀模式(Facade pattern):
    擴展點與對 client 隱藏的部分為內部系統原本應與外部串接的複雜 APIs。
    外觀模式解釋:
    它為子系統中的一組介面提供一個統一的高層介面,使得子系統更容易使用。
    簡單來說就是 client 端想使用時,都只能透過某個介面 (Facade),只有它知道各個子系統的功能,client 在使用時就有簡單的介面,而且也減少了 client 與子系統的耦合。
    可以想像有一台筆電,上面有一個顆開機鍵,這就是你知道的介面(Facade),但其實這顆開機鍵按下去後的實作細節很複雜,要先讓電池啟動開始供電,然後啟動 CPU…一系列複雜的事情,但你都不用去理會這些實作細節。
  • 工廠方法 (Factory Method pattern):
    擴展點與對 client 隱藏的部分為具體的物件類型與創建流程。
    工廠方法解釋:
    工廠方法模式的實質是「定義一個建立物件的介面,但讓實現這個介面的類來決定實例化哪個類。工廠方法讓類別的實例化推遲到子類中進行。」
    所以假設建立一個物件需要複雜的過程,或此物件包含在一個複合物件中。那很明顯這物件建立可能會導致大量的重複代碼,可能會需要複合物件存取不到的資訊,也可能提供不了足夠級別的抽象,還可能並不是複合物件概念的一部分。所以「工廠方法模式通過定義一個單獨的建立物件的方法來解決這些問題」。由子類實現這個方法來建立具體類型的物件。
  • 構造器 (Builder Pattern):
    擴展點與對 client 隱藏的部分為物件的創建複雜流程。
    構造器解釋:
    它可以將複雜對象的建造過程抽象出來(抽象類別),使這個抽象過程的不同實現方法可以構造出不同表現(屬性)的對象。
    想像今天要蓋一間房子,有些房子要五個門,有些房子要游泳池,或者有些事要木屋,有些要鋼筋水泥,有各種需求,假設用最糟糕的方式就是,在建立時用一堆參數來處理各式各樣需求,例如 House(doors: 2, swimmingPool: 2, rooms: _, windows: ...),但就會造成一堆參數可能用不到,所以這時可以用 Bulder Pattern 來解決,把這些各種需求變成一個 function,例如可能有 buildSwimmingPool(2), buildDoor(5) ,這就可以建造出2個游泳池5個門的房子。

其他的體現:

  • 規則引擎(Rule Engines)
    簡單來說就是我們常在程式中使用 control flow (if else, switch case) 來實做一些業務邏輯,像是我們前面提到的例子,不同的會員等級會有不同計算商品金額的方式, 但是這些業務邏輯會越來越複雜,而且可能往後還有一堆指標數據要判斷,例如以銀行系統來說,要借錢出去可能要評估(年齡、職業、性別、家庭、地區、資產、收入、信用、健康…),但這些東西要用 control flow 來完成可能就有點困難,像這樣的「規則引擎」的應用被稱為專家系統,專家系統的推斷可以用演算法或是人工智慧的方式實現。
    而且「規則引擎」提供更靈活的參數調整方式,因為假如我們都是用寫程式的 control flow 來處理,那不懂程式的人(例如營運的人)要調整就非常困難,都需要工程師來調整,那使用「規則引擎」可以更靈活的調整方法,因為可能是用讀入特定格式的檔案(csv 或 xls 或 json)來實現規則的設定,並且這些格式同時是人機可讀的,讓營運人員更簡易的自行調控。所以一個商業用系統的長遠來看,用規則引擎來實現應該是可以有更低的長期維運成本。
  • 業務流程配置化
    抽象業務流程,將業務流程的流轉看做是一個流水生產線。包含三種核心概念,分別是:「原材料」、「通道」、「加工」,原材料在事先配置好的通道中流轉,經過多處加工最後得出預期的產品。
    「原材料」可看成是原始數據,「通道」看成是數據關聯,「加工」看成是一個一個的服務。原數據通過數據關聯連接對應的服務,其中服務包含三要素:輸入(I)、輸出(O)、操作(A),一旦原始數據符合數據關聯要求,就可順利通過I流入,對應的A將會依據定義好的邏輯對原始數據進行處理,最終數據從O流入。
    整套流程可通過多套數據關聯鏈接起來,原始數據經過一步一步處理,最終將會被加工成預計需要的結果。
    最簡化的說法就是整個流程化設計的原則是:組件組裝,將業務流轉過程中涉及的核心模塊拆分成組件,流程可配置化的過程就是對整個服務流程組件進行生產和組裝的過程。
  • 插件化
    要理解插件化,就先理解「插件」的概念,與「組件」很像,但插件更細,如果說每一個組件是工程中的某一個模組,那每一個插件就像是業務模組中的子功能。
    那麼插件還有一個特性,就是「可拔插」,而且引用時或刪除對現有業務是應該不會有影響,有的話也是很輕微。
    插件的拆分上要滿足「單一職責」,通過各個不同的插件,來提供和完成不同的功能。
    所以「插件化」就是透過不同「插件」組織業務模組。

那在 2017 年出版的 Clean ArchitectureRobert Martin OCP 做了一次更完整更宏觀的解釋:

OCP 是我們進行軟體架構設計的「主導原則」,其目標是「讓系統易於擴充,同時限制其每次被修改所影響的範圍」。

實現方法是通過將系統劃分為一系列組件,並且將這些組件間的依賴關係按層次結構進行組織,使得高階組件不會因低階組件被修改而受影響。

範例:實現一個展示財務數據的 Web 頁面

需求A:假設我們有一個財務數據系統,顯示在 Web 上,它是可以滾動瀏覽,而且其中負值會以紅色呈現。

幾個月後,收到新的需求B,同樣的這些數據要產出報表,該報表要能用列印機列印,且其報表需合理分頁,每頁都要有頁首頁尾,並負值使用括號表示。

那問題來了,我們在做這個需求B時,「如何對舊程式碼的修改量降到最低,甚至為0呢?」

  1. 先把系統劃分成一系列組件:
    1. 一定會有負責基礎設施的組件,例如:DAO, FtpClient 等。
    2. 一定會有負責業務需求 use case 流程的組件。
    3. 一定會有負責終端輸出或輸入前負責格式化工作的組件 。
    4. 可能會有負責 use case 中與業務流程較無關但與其他技術相關 的組件(例如:加解密、雲服務的使用等)。
  2. 把組件按層次架構進行組織:
    1. 因 Financial analysis (金融分析) 組件是在整個業務中比較穩定的部分,此處很適合使用依賴反轉原則 之技巧使得其他組件的程式碼依賴均指向它。
    2.我們可以判斷出未來可能也有其他展示需求,所以這些不穩定的組件,例如 Presenter (展示)的組件,就應該依賴穩定的組件。
    3.所以簡單釐清一下關係,最穩定的金融分析業務邏輯的組件在最上層,中間可能有一個 Controller 介面管理組件,最後使用依賴反轉 將展示層(最容易變動的組件)放在下層,未來真的又有新的展示部分的功能調整,也都只需要新增或調整展示層組件,並且實現 Controller 要求的介面即可,最後就可以使得核心金融分析組件不需修改。

小結

  • 軟體的可維護性的另外一個問題來源:組件/程式碼的耦合,造成即便系統的微小變動也可能需要對原有系統進行連鎖性、大幅度調整
  • OCP 的建議:「組件的設計必須讓系統易於擴充,同時限制其每次被修改所影響的範圍。」實現的方式是:將系統劃分為一系列的組件,並將組件間依賴關係按層次結構進行組織,使得其組件不會因被依賴組件的修改而受影響
  • 2017年前的詮釋規模可能比較小,注重於如何透過擴展點來封裝程式碼中複雜度可能發散的地方,比較沒有套用在整個系統設計上。
  • OCP 是進行軟體架構設計時的主導原則。

我們回想一下,上一篇聊的 SRP (單一職責原則) ,假如從 SRP 來看,會覺得 SRP 是一個理念前提,假如沒有實現好這理念,後面怎麼做都會做不好,因為假設沒有實現好 SRP ,那我們在用 OCP 原則所做的這些組件不就有可能一堆耦合,一堆業務邏輯可能都混在一起,這些組件就沒有做好單一職責,所以沒有做好 SRP ,後面都沒有用了,必須把這個 SRP 核心做好,才可以接著做 OCP 。

所以先做 SRP (單一原則) ,再來做 OCP (開放閉合原則), OCP 是依賴 SRP,「這些是系統設計的整體理念前提與總體指導方針」

--

--