設計模式原則(6)開閉原則

定義

開閉原則(OCP,Open Closed Principle):作爲系統開發中最基礎的設計原則。對各大設計原則起着領袖的作用,它指導着我們設計實現穩定而靈活的系統。
原文定義:Software entities like classs,modules and functions should be open for extension but closed for
modifications;軟件實體(類、模塊和方法)應該對擴展開放,對修改關閉。

一、什麼是開閉原則?

開閉原則主要應對的是需求的變更。根據OCP,一個軟件實體應該通過擴展而不是修改原有代碼實現變更。
何爲軟件實體?其包括如下幾個部分
1、軟件項目中按一定業務粒度劃分的模塊;軟件模塊。
2、抽象和類
3、方法

在實際項目開發中往往會因爲各種原因引起軟件項目的變更,需求的變更可能是PM和開發人員都較爲頭疼的一件事情了。而合理的架構設計可以大大削減變更成本,這直接考驗着架構師對軟件開發的認知和團隊的協作能力。通過OCP,我們可以擁抱變化。

示例:(餐館售出各種菜式,假設我們的餐館主營火鍋,這裏主要關注火鍋名字和價格。HotPot類提供基本的數據模型操作,Restaurant依賴HotPot爲上層模塊提供相關服務,比如showMealPrice方法提供HotPot價格展示的功能),代碼如下:

interface IMeal {
     public String getName();
     public BigDecimal getPrice();
}
//hot pot
class HotPot implements IMeal {
     private String mealName;
     private BigDecimal mealPrice;
     HotPot() {
     }
     // set MV by construction
     HotPot(String name, BigDecimal price) {
          this.mealName = name;
          this.mealPrice = price;
     }
     @Override
     public String getName() {
          return mealName;
     }
     @Override
     public BigDecimal getPrice() {
          return mealPrice;
     }
}
class Restaurant {
     // the meal list
     private List<IMeal> mealList;
     public Restaurant() {
     }
     public Restaurant(List<IMeal> mealList) {
          this.mealList = mealList;
     }
     public void showMealPrice() {
          for (IMeal iMeal : mealList) {
              System.out.println("name:" + iMeal.getName() + " price:" + iMeal.getPrice());
          }
     }
}

高層模塊代碼如下:(mock data 代碼模擬Dao從數據庫提取數據)

public class Client {
     public static void main(String[] args) {
          // mock data to meal list
          List<IMeal> mealList = new ArrayList<IMeal>();
          mealList.add(new HotPot("h1", new BigDecimal("99.99")));
          mealList.add(new HotPot("h2", new BigDecimal("333.33")));
          mealList.add(new HotPot("h3", new BigDecimal("888.88")));
          // initial restaurant
          Restaurant restaurant = new Restaurant(mealList);
          restaurant.showMealPrice();
     }
}

以上算是一個最簡單的代碼示例。
我們可以看到在Restaurant中直接依賴IMeal抽象接口,而不是具體的類,爲後續的解耦提供了基本條件。同樣的可以看到Restaurant並沒有進一步的公共抽象設計,這會爲後續的擴展帶來一定的麻煩。
以上代碼中,進行價格展示是沒有問題的。

但社會的大環境總是會變化的,就比如現在正在發生的冠狀病毒疫情對餐飲的衝擊還是挺大的;我們的餐館打個九折吧!

那怎麼實現這種需求的變更呢?以下有三種可行但不一定適用的方法:

1、修改接口
IMeal接口新增一個getDiscountPrice方法。其破壞了接口作爲契約的穩定性,很容易導致變更風險擴散。Pass

2、修改實現類
直接修改實現類中getPrice方法。這可能是比較常用的方法了,簡單、直接,太暴力。在一個高度協調且成熟的開發團隊裏採用這種方法或許可以取得不錯的效果,但大部分的開發團隊都無法達到這種水平;這是技術和管理的高度協調。另一方面,這種方法會帶來一個弊端,假如採購人員需要查看原價進而去預估成本的時候該怎麼辦呢?畢竟直接修改展示的是打折之後的價格了。Pass

3、通過擴展實現變化
新增一個DiscountHotPot繼承IMeal接口進而重寫getPrice。Restaurant菜單展示的高層模塊中只需要通過新增的DiscountHotPot生成新的對象即可達到目標,並且在此也不會影響原有的模塊。採用此方法。

新增DiscountHotPot類代碼如下:(實際上如果有多次售價變動而增加了多個類似的類時還會導致代碼冗餘問題,此時可以使用抽象類對這些IMeal實現類進行二級封裝,即在IMeal與各實現類間增加一個抽象類封裝)

//discount
class DiscountHotPot implements IMeal {
     private String mealName;
     private BigDecimal mealPrice;
     DiscountHotPot() {
     }
     // set MV by construction
     DiscountHotPot(String name, BigDecimal price) {
          this.mealName = name;
          this.mealPrice = price;
     }
     @Override
     public String getName() {
          return mealName;
     }
     @Override
     public BigDecimal getPrice() {
          // for discount
          return mealPrice.multiply(new BigDecimal("0.9"));
     }
}

客戶端修改代碼如下:(僅改變了實例化的類,此處屬於數據持久層Dao,爲高層模塊)

public class Client {
     public static void main(String[] args) {         
          // mock data to meal list
          List<IMeal> mealList = new ArrayList<IMeal>();
          mealList.add(new DiscountHotPot("h1", new BigDecimal("99.99")));
          mealList.add(new DiscountHotPot("h2", new BigDecimal("333.33")));
          mealList.add(new DiscountHotPot("h3", new BigDecimal("888.88")));
          // initial restaurant
          Restaurant restaurant = new Restaurant(mealList);
          restaurant.showMealPrice();
     }
}

其他代碼不變。在此新增一個類的同時也導致了Client的修改(雖然修改的範圍較小)。反過來想一想,如果不是提供了IMeal接口,所有服務的提供利用具體實現類,可以看看需要修改多少代碼;是不是有點似曾相識,這不就是LSP嗎。我們知道,底層模塊的修改必然隨着高層模塊的耦合變更,否則底層模塊就是一個孤立無意義代碼段而已

何爲變更?我們可以把變更總結爲以下三種類型

1、邏輯變化
只變化一個邏輯而不涉及其他模塊,就比如需要將a+b的計算邏輯變更爲a*b,這可以通過修改原有類來完成;也就是利用上述需求變更方法2修改實現類方式。當然這種方法的前提條件是所有依賴或關聯類都按照相同的邏輯處理;牽一髮而動全身,使用場景並不多。

2、子模塊變化
通常,一個模塊的變化會對其他模塊造成影響;特別是底層模塊的變化往往直接引起高層模塊的改變。就像上述的餐館打折銷售。

3、可見視圖變化
可見視圖也就是直接和用戶交互的界面,如web界面、app界面。單純的界面變化倒是可以將變更控制在前端有限的範圍內,而某些業務耦合的變化往往涉及到後端相關業務邏輯的變更。比較極端的一種示例是:在進行前端數據展示時,客戶突然提出需要在原有報表界面上添加一個新的指標,而這個指標需要跨越多張表並經過複雜的計算才能得到。
針對業務耦合引起的變化,我們基於一個靈活的代碼設計可以更好的適應;一個靈活的設計可以實現真正的擁抱變化。就像上面的代碼示例中,我們針對菜品提供了IMeal接口,哪怕面對變化,還是可以靈活的遵循OCP進行處理。而Restaurant卻並沒有做進一步的抽象提取,如果是針對外賣平臺開發的話,那這個Restaurant的設計是不太合適的。

二、爲什麼要使用開閉原則?

很簡單的一個問題:你更願意參與陌生系統的二次開發,還是自己從頭到尾參與的系統研發?或者說在大型項目中你更願意選擇通過修改實現類實現變更還是通過擴展實現變化?(上述代碼只是最簡單的業務邏輯,可以試着去回想在複雜業務邏輯中排錯的場景)
OCP是基於前五大原則的一個抽象,是前五大原則的精神領袖;OCP對於前五大原則,就像是牛頓第一定律在力學中的地位。我們可以簡單理解爲:前五大原則是OCP的實現類

OCP爲何如此重要,現通過以下幾個方面來理解

1、開閉原則對測試的影響
業務代碼的變更往往意味着測試代碼的變更。就比如說在單元測試中,我們需要對上述新增的打折類DiscountHotPot進行測試,那我們可能只需要新增一個測試實現類即可,無需過多考慮歷史測試代碼和用例的影響;測試保證新增加的類符合業務預期即可。而如果變更基於原有實現類進行。那原有的測試類該如何處理呢?也跟着變化嗎?測試用例呢?歷史是難以修改的!

2、開閉原則可以提高複用性
這裏主要基於業務粒度的劃分。我們知道,在面向對象設計中,一定範圍內業務邏輯劃分越細緻、粒度越小,代碼複用的可能性就越大;那這裏就涉及到前五大原則中的SRP和ISP了。若是有着明晰恰當的粒度劃分,我們在進行變更時就可以更快的定位變更代碼,進而採取合適的變更策略,減少了變更的成本

3、開閉原則可以提高可維護性
這又回到了上面的問題。修改還是擴展?對於投產後的系統,維護人員往往會趨向於通過擴展而不是修改類去實現變更。畢竟你我都知道:在龐大複雜的業務代碼中,讀懂別人的代碼進而去修改是一件很痛苦的事情,還不如干脆一點自己重寫一個新的功能類呢。

4、面向對象開發的要求
我們知道萬物皆是運動的,而在面向對象的世界裏,萬物皆對象。如此一來,變化成了對象的一個特性。在開發中我們針對對象的抽象,也就是類進行變動也就成了很常見的事情。進而在面向對象的開發思想下,我們的系統想要更貼近現實的運用,就必然要做到擁抱變化。而在OCP的開發指引下,我們在系統設計之初就在一定程度上考慮到了未來可能會有的變更,進而可以留下接口,爲日後的變更提供靈活的支持。

三、如何使用開閉原則?

綜合上述可知,OCP本身其實是一個非常抽象的概念。作爲其他五條原則的指導性原則,OCP略顯縹緲虛無。總體來說它還是要基於前五大原則進行進一步的具體運用,更像是作爲前五大原則的超類或抽象接口,起着契約的作用

那如何將將這種看起來虛無縹緲的契約運用到實際的研發中呢?
以下有幾條可行的方法。

1、抽象約束
這條相信不言而喻了。作爲擴展的前提條件,抽象約束以抽象類或接口的形式將一組事物的抽象共性(接口或抽象類)進行高度提取。由於沒有具體的實現,表明着這個提取出來的共性有着許多的實現可能性。同樣的,通過這種共性的提取可以對一組可能變化的行爲進行約束,並實現對擴展開放。原則上在實現類中不允許出現共性之外的public方法,同時共性作爲一種契約而不允許輕易變動,其可以對擴展的邊界進行約束。同樣的,使用共性作爲參數類型可以獲得更好的設計靈活性,進而實現擴展兼容(運用OOD的多態),就比如上面在可見視圖變化中關於IMeal接口的設計簡述。

2、使用元數據(metadata)控制模塊行爲
何爲元數據?似乎針對不同的方向有着不同的定義,在數據開發中,個人習慣將元數據表述爲描述數據的數據。而在系統設計中可能表述爲描述環境和數據的數據,具體體現爲配置參數。配置參數的來源有很多,如文本(XML)、數據庫。記得在spring容器中使用配置文件作爲依賴注入(IOC),其實就是使用這種方式了。

3、制定項目管理
這裏越來越趨近於軟件工程思想了。在一個成熟的開發團隊中,約定俗成的章程是必備的,從每個人負責的模塊到方法變量的命名,變更的流程等等都有章可循。約定優於配置

4、封裝變化
也就是將相同的變化封裝到同一個抽象共性中,而不同的變化封裝到不同的抽象共性中;不應該有不同的變化出現在同一個抽象共性裏面。爲此我們需要在設計中做大量的工作以充分認知我們將要開發的項目,儘可能多地預判到在未來可能出現的變化,進而將這些可能出現的變化進行封裝。常見的23種設計模式正是從不同的角度對預計的變化進行封裝。

四、最佳實踐

縱觀軟件生命週期的全過程,可能最難應對的就是項目變更,不可預見的變更。恐怕再如何優秀的架構師、項目經理都難於盡數預見所有的變更吧!所幸軟件危機從“沒有銀彈”以來,我們的軟件工程技術越趨成熟。通過日積月累的實踐探索,前輩大師們給我們總結出了6大基本設計原則和23種常見設計模式封裝未來的變化,讓我們得以站在巨人的肩膀上看這個世界。

通過一下六條設計原則的學習:
1、單一職責(SRP)
2、里氏替換原則(LSP)
3、依賴倒置原則(DIP)
4、接口隔離原則(ISP)
5、迪米特法則(LoD)
6、開閉原則(OCP)

我們初步瞭解如何去設計一個穩定的(SOLID)、健壯的系統。同時我們要知道OCP作爲前五大原則的高度抽象、風向標,是最基礎的原則

在使用OCP時我們要注意以下幾個問題(不如說是遵循所有的原則我們都要注意的問題)
1、原則也僅僅是原則
原則是服務於項目開發的,而擁抱變化,實現項目穩定性、健壯性可以有很多種方法,要說軟件設計原則何止這6種呢。只是就目前而言,利用這6大基本原則就可以應對大多數的變化了。就像前面說的:原則是可以遵循的,但絕對不是嚴格執行的。我們需要根據具體的情況分析作出最合適的策略選擇

2、重視項目規章
這裏的項目規章絕對不是刻板的條條框框,而是在實踐中根據團隊、項目特點而總結出來的約定。如此首先就需要有穩定的團隊成員,進而才能建立高效的團隊協作文化。好的團隊與個人之間一定是相互成就的

3、預知變化
這是一件很難的事情,很多時候需要架構師、項目經理有着豐富的經驗和敏銳的觀察分析能力。就比如曾經發生的變化是否會在當前這個項目中發生呢?這個從未接觸過的領域模塊是否需要留下變更的接口呢?這個世界唯一的不變就是變化,架構設計優良纔能有更多的底氣和信心去擁抱變化。擁抱變化絕對不僅僅是口號,背後需要的是堅持不懈、常年累月的探索,和學習

再如何優秀的架構師、項目經理都難於盡數預見所有的變更。但我們朝着OCP這個目標前行依舊可以設計出良好的架構!最終實現擁抱變化!

發佈了132 篇原創文章 · 獲贊 40 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章