設計原則:開閉原則(OCP)

1.什麼是開閉原則

開閉原則的英文是Open Closed Principle,縮寫就是OCP。其定義如下:

軟件實體(模塊、類、方法等)應該“對擴展開放、對修改關閉”。

從定義上看,這個原則主要包含兩部分:

  • 對擴展開放:“ 這意味着模塊的行爲是可以擴展的。當應用程序的需求改變時,我們可以對其模塊進行擴展,使其具有滿足那些需求變更的新行爲。換句話說,我們可以改變模塊的功能。

  • 對修改關閉:“ 對模塊行爲進行擴展時,不必改動該模塊的源代碼或二進制代碼。模塊的二進制可執行版本,無論是可鏈接的庫、DLL或Java的.jar文件,都無需改動。

通俗解釋就是,添加一個新的功能,應該通過在已有代碼(模塊、類、方法)的基礎上進行擴展來實現,而不是修改已有代碼。

之前的一篇文章《何謂高質量代碼?》中,我們總結了高質量代碼的幾個衡量標準。

而開閉原則解決的就是代碼的擴展性問題。如果某段代碼在應對未來需求變化的時候,能夠做到“對擴展開放、對修改關閉”,那就說明這段代碼的擴展性比較好。

2.如何做到對擴展開放、對修改關閉

那麼應該怎樣寫出擴展性好的代碼呢?

在思想上我們要具備擴展意識、抽象意識、封裝意識。這些意識的培養要比一些具體的方法更爲重要,這依賴我們對面向對象的理解、對業務的掌握度,以及長期的經驗積累...... 這要求我們在寫代碼的時候後,要多花點時間往前多思考一下,未來可能有哪些需求變更,識別出代碼的易變部分與不易變部分,合理設計代碼結構,事先留好擴展點,以便在未來不需要改動代碼整體結構、做到最小代碼改動的情況下,新的代碼能夠很靈活地插入到擴展點上。

在方法上,我們主要可以通過多態、依賴注入、面向接口編程等方式來實現代碼的可擴展性。做到“對擴展開放、對修改關閉”。我們要將可變部分抽象出來以隔離變化,提供抽象化的不可變接口,給上層系統使用。當具體的實現發生變化的時候,我們只需要基於相同的抽象接口,擴展一個新的實現,替換掉老的實現即可,上游系統的代碼幾乎不需要修改。

比如,我們的項目中通常會用到一些第三方組件,消息中間件,緩存中間件......消息中間件我們可能一開始使用RabbitMQ,但是可能後來會換成Kafka,緩存中間件可能會從Memcache換成Redis。這種情況,如果我們的上層應用直接依賴這些中間件調用代碼,那麼更換的成本就會更高,這種代碼就不利於擴展。

image-20210203082100894

public class MemcacheClient {

    public boolean set(String key, String value) {
        return false;
    }

    public String get(String key) {
        return null;
    }

    public boolean remove(String key) {
        return false;
    }
}

public class OcpApplication {

    public void test() {

        // 業務代碼
        //...
        //...

        //寫緩存
        MemcacheClient client = new MemcacheClient();
        client.set("testKey", "testValue");
    }
}

如上示例,我們的上層應用OcpApplication直接依賴了MemcacheClient,如果未來有需要把Memcache換成Redis,我們就需要替換掉所有調用了MemcacheClient的上層應用方法,這嚴重違背了開閉原則。

在這種情況下,通常我們會把這種中間件的調用設計成可插拔的。我們提供一個這些中間件的抽象接口出來,讓所有上層系統都依賴這組抽象的接口編程,並且通過依賴注入的方式來調用。當我們要替換新的中間件的時候,比如將 Memcache替換成 Redis,就可以可以很方便地拔掉老的Memecache實現,插入新的Redis實現。

image-20210203080434828

/**
 * 緩存中間件的使用抽象出接口
 */
public interface ICacheClient {

    boolean set(String key, String value);

    String get(String key);

    boolean remove(String key);
}

/**
 * MemcacheClient
 */
public class MemcacheClient implements ICacheClient {

    public boolean set(String key, String value) {
        return false;
    }

    public String get(String key) {
        return null;
    }

    public boolean remove(String key) {
        return false;
    }
}

/**
 * RedisClient
 */
public class RedisClient implements ICacheClient {
    
    @Override
    public boolean set(String key, String value) {
        return false;
    }

    @Override
    public String get(String key) {
        return null;
    }

    @Override
    public boolean remove(String key) {
        return false;
    }
}

public class OcpApplication {

    /**
     * 依賴注入cacheClient
     */
    ICacheClient cacheClient;
    
    public OcpApplication(ICacheClient cacheClient){
        this.cacheClient=cacheClient;
    }

    public void test() {

        // 業務代碼
        //...
        //...

        //寫緩存
        cacheClient.set("testKey", "testValue");
    }
}

3.如何靈活運用開閉原則

開閉原則看似簡單,但我卻認爲是SOLID 中最難掌握的一條原則。其難點就在於如何在真正的項目中去靈活運動開閉原則。而且OCP同樣存在着一些陷阱,怎麼纔算滿足或違反開閉原則,修改代碼就一定意味着違反開閉原則嗎,擴展點設計的越多越好嗎......

3.1 靈活設計擴展點

對於業務系統,要想識別出盡可能多的擴展點,就要求你對業務有足夠的瞭解,能夠預見一些未來可能的變化。

對於偏技術的系統,比如,框架、組件、類庫等,就需要充分了解它的使用場景?以及今後想要擴展點功能?使用者未來會有哪些更多的訴求......

但即便我們對業務、對系統有足夠的瞭解,也不可能識別出所有的擴展點,即便可以,併爲這些地方都預留擴展點,也是沒有必要的。同樣有一條原則叫KISS原則,那就是儘量保持簡單,不要進行過度設計,實際上很多人都會陷入這樣一個誤區,我們常常爲了一些很可能不存在的擴展而絞盡腦汁!

最合理的做法就是,對於一些比較確定的、短期內可能就會擴展,或者需求改動對代碼結構影響比較大的情況,或者實現成本不高的擴展點,可以事先做些擴展性設計。但對於一些不確定未來是否要支持的需求,或者實現起來比較複雜的擴展點,我們可以等到有需求驅動的時候,再通過重構代碼的方式來支持擴展的需求。

3.2 修改代碼一定違反開閉原則嗎

開閉原則中對於修改是封閉的並非是一個絕對的概念。

1.修復缺陷所做的改動

缺陷在軟件中很常見,是不可能完全消除的。當缺陷出現時,就需要我們修復現有的代碼。軟件修復明顯傾向於實用主義而不是堅持開放封閉原則。

2.客戶端無法感知到的改動

如果一個類的改動會引起另一個類的改動,那麼這兩個類就是緊密耦合的。相反,如果一個類的修改總是獨立的,並不會引起其他類的改動,那麼這些類就是鬆散耦合的。我們要記住,任何情況下,鬆散耦合都比緊密耦合要好。如果我們對現有代碼的修改不會影響客戶端代碼,那麼也就談不上違背開放封閉原則。

3.修改還是擴展?

從開閉原則定義中,我們可以看出,開閉原則可以應用在不同粒度的代碼中,可以是模塊,也可以類,還可以是方法(及其屬性)。同樣一個代碼改動,在粗代碼粒度下,可以被認定爲“修改”,但在細代碼粒度下,又可以被認定爲“擴展”。

比如,在類這個層面添加屬性和方法相當於修改類,這個代碼改動可以被認定爲“修改”;但這個改動並沒有修改已有的屬性和方法,在方法(及其屬性)這一層面,它又可以被認定爲“擴展”。

實際上,當糾結於某個代碼改動是“修改”還是“擴展”的時候,我們就已經背離了設計原則的初衷,開閉原則的本質目的就是爲了讓我們的代碼更具有擴展性,更容易維護,如果我們可以很容易的完成修改,又不會影響到既有的代碼與單測,就可以認爲這是一個合理的改動。

3.3 擴展性與可讀性的平衡

在有些情況下,代碼的擴展性會跟可讀性相沖突。爲了更好地支持擴展性,我們對代碼進行了重構,重構之後的代碼要比之前的代碼複雜很多,理解起來也更加有難度。實際上很多時候,我們都要結合具體的場景在擴展性和可讀性之間做權衡。在某些場景下,擴展性很重要,我們就可以適當地犧牲一些可讀性;而在另一些場景下,可讀性更加重要,那我們就適當地犧牲一些擴展性。

小結

絕大多數情況下,我們的系統都不是一錘子買賣,通常隨着需求的迭代,我們需要不斷地對其進行維護與擴展。而開閉原則的思想可以很好的解決擴展性的問題,因此理解並掌握開閉原則至關重要,但這需要我們充分的理解面向對象的思想,合理的利用封裝、多態等方法以及長期大量的積累!

系列文章

設計原則:單一職責(SRP)

設計原則:開閉原則(OCP)

設計原則:裏式替換原則(LSP)

設計原則:接口隔離原則(ISP)

設計原則:依賴倒置原則(DIP)

何謂高質量代碼?

理解RESTful API

關注下方公衆號,回覆“代碼的藝術”,可免費獲取重構、設計模式、代碼整潔之道等提升代碼質量等相關學習資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章