Java專案中常用的的五大設計原則

Kevin.ZhangCG發表於2021-10-22

今天我們一起來聊聊關於設計原則相關的知識點。

SOLID五大原則是什麼

SRP 單一責任原則

單一責任原則,從名字上我們就能比較好的去理解它。這項原則主張一個物件只專注於單個方面的邏輯,強調了職責的專一性。

舉個例子:

學生管理系統中,我們需要提交一些學生的基本資料,那麼學生資訊相關的程式都交給了StudentService負責,如果我們要實現一個儲存教師基本資料的功能就應該新建一個TeacherService去處理,而不應該寫在StudentService當中。

OCP開放封閉原則

這項原則從我個人的角度去理解,它更加強調的是對於擴充套件的開放性,例如當我們需要調整某些實現邏輯的時候,儘量不要直接改動到原有的實現點。

但是這裡面有幾個點容易被人們誤解:

第一點

開放封閉原則雖然強調的是不要隨意改動代原先程式碼到邏輯結構,但是並沒有要求一定不能對程式碼進行改動!

第二點

同樣是程式碼改動,如果我們可以從功能,模組的角度去看,實際上程式碼的改動更多地可以被認作為是一種“擴充套件”。

關於如何做到開放封閉原則,下文我會專門用一個案例來進行介紹。

LSP里氏替換原則

里氏替換原則強調的是不能破壞一個原有類設計的原始設計體系。強調了子類可以對父類程式進行繼承。但是有幾個點需要注意下:

如果父類定義的規則最好是最基礎,必須遵守的法則。如果子類繼承了父類之後,在某個方法的實現上違背了初衷,那麼這樣的設計就是違背了里氏替換法則。

例如:

父類的設計是希望實現商品庫存扣減的功能,但是子類的實現卻是實現了庫存+1的功能,這就很明顯是牛頭不對馬嘴了。

子類不要違背父類對於入參,出參,異常方面的約定。例如:父類對於異常的丟擲指定的是 NullPointException ,但是子類卻在實現的時候宣告瞭會出 illegalArgumentException,那麼此時就需要注意到設計已經違背了LSP原則。

同樣,具體的案例我在下文會列舉出來和大家進行程式碼分享。

ISP介面隔離原則

理解“介面隔離原則”的重點是理解其中的“介面”二字。

這裡有三種不同的理解。如果把“介面”理解為一組介面集合,可以是某個微服務的介面,也可以是某個類庫的介面等。

如果部分介面只被部分呼叫者使用,我們就需要將這部分介面隔離出來,單獨給這部分呼叫者使用,而不強迫其他呼叫者也依賴這部分不會被用到的介面。

DIP依賴倒置原則

比較經典的例子,例如說Spring框架的IOC控制反轉,將bean的管理交給了Spring容器去託管。依賴注入則是指不通過明確的new物件的方式來在類中建立類,而是提前將類建立好,然後通過建構函式,setter函式等方式將對應的類注入到所需使用的物件當中。

DIP的英文解釋大致為:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

解釋過來就是,高層次的模組不應該依賴低層次的模組,不同的模組之間應該通過介面來互相訪問,而並非直接訪問到對方的具體實現。

清楚了這麼多理論知識之後,接下來我們通過一些程式碼實戰案例來進行更加深入的瞭解吧。

單一責任原則案例

我們來看這麼一個類,簡單的一個使用者資訊類中,包含了一個叫做home的欄位,這個欄位主要用於記錄使用者所居住的位置。

public class UserInfo {

    private String username;
    
    private short age;
    
    private short height;
    
    private String phone;
    
    private String home;
    
}

慢慢地隨著業務的發展,這個實體類中的home欄位開始進行了擴充套件,UserINfo類變成了以下模式:

public class UserInfo {
    private String username;
    private short age;
    private short height;
    private String phone;
    private String home;
    /**
     * 省份
     */
    private String province;
    /**
     * 城市
     */
    private String city;
    /**
     * 地區
     */
    private String region;
    /**
     * 街道
     */
    private String street;
}

此時對於這個實體類的設計就會有了新的觀點:

這個類中關於居住部分的欄位開始漸漸增加,應該將住址部分抽象出來成一個Address欄位,拆分後變成如下所示:

public class UserInfo {

    private String username;
    private short age;
    private short height;
    private String phone;
    private String home;
    /**地址資訊**/
    private Address address;
    
}

這樣的拆分可以確保UserInfo物件的職責單一,類似的擴充套件還可以蔓延到後續的email,tel相關屬性。

舉這個例子只是想簡單說明,我們在對一些類進行設計的時候,其實就已經使用到了單一責任原則。另外還有可能在以下場景中也有運用到該原則:

類中的屬性欄位特別多,一個bean中充斥了幾十個屬性。此時也可以嘗試使用單一責任原則,將不同屬性的欄位歸納為一個bean進行收攏。

一個大物件,例如XXXManager或者XXXContext這種名詞定義的物件中,可能引入了一大堆的外部依賴,此時可以按照依賴的類別來進行拆分。

業務程式碼塊中,我們定義了一個UserService類,然後這個類裡面寫了一坨的使用者密碼,手機號,身份證號解密加密相關的私有函式,這時候可以不妨嘗試將這些私有方法統統抽象成為一個獨立的Util當中,從而減少UserService中的程式碼量。

所以最終你會發現,單一責任原則還是一個比較需要依靠主觀意識去拿捏的一項技巧。隨著我們實踐開發經驗的逐漸提升,自然就會明白什麼樣的程式碼該進行良好的抽象與優化了。

開放封閉原則案例

關於這條原則我個人感覺要想較好地理解它,需要有具體的實戰案例程式碼,所以接下來我打算用一個自己曾經在工作中遇到的實際場景和你分享:

我做的一款社交小程式應用當中,當一個使用者註冊完資訊之後,需要通知到系統下游,主要是修改某些後臺資料,分配對應的員工去跟進這個使用者。

所以大體的程式碼設計可能如下所示:

public class RegisterHandler {
    public void postProcessorAfterRegister(long userId){
        //通知員工
        notifyWorker(userId);
    }
    
    private void notifyWorker(long userId){
        //通知部分的邏輯
    }
}
public interface IRegisterHandler {
    /**
     * 使用者註冊之後處理函式
     *
     * @param userId 使用者渠道ID
     */
    void postProcessorAfterRegister(long userId);
}

但是註冊的渠道型別有許多種,例如公眾號,小程式二維碼傳播,小程式的分享連結,其他App渠道等等。所以程式碼結構需要做部分調整:

首先需要修改一開始設計的介面模型:

public interface IRegisterHandler {
    /**
     * 使用者註冊之後處理函式
     *
     * @param userId 使用者ID
     * @param sourceId 註冊渠道ID
     */
    void postProcessorAfterRegister(long userId,int sourceId);
}

然後還需要修改實際的實現規則:

public class RegisterHandlerImpl implements IRegisterHandler {
    @Override
    public void postProcessorAfterRegister(long userId, int sourceId) {
        //通知員工
        if (sourceId == 1) {
            //doSth
        } else if (sourceId == 2) {
            //doSth
        } else if (sourceId == 3) {
            //doSth
        } else {
            //doSth
        }
        notifyWorker(userId, sourceId);
    }

    private void notifyWorker(long userId, int sourceId) {
        //通知部分的邏輯
    }
}

這樣的程式碼擴充套件就會對原先定義好的結構造成破壞,也就不滿足我們所認識的開放封閉原則了。(雖然我在上文中有提及過對於開放封閉原則來說,並不是強制要求不對程式碼進行修改,但是現在的這種擴充套件模式已經對內部結構造成了較大的傷害。)

所以我們可以換一種設計思路去實現。

首先我們需要將註冊的傳入引數定義為一個物件型別,這樣在後續新增引數的時候只需調整物件內部的欄位即可,不會對原有介面的設計造成影響:

public class RegisterInputParam {

    private long userId;

    private int source;

    public long getUserId() {
        return userId;
    }

    public void setUserId(long userId) {
        this.userId = userId;
    }

    public int getSource() {
        return source;
    }

    public void setSource(int source) {
        this.source = source;
    }
}

接著可以將註冊邏輯拆解為註冊處理器和使用註冊處理器的service模組:

public interface IRegisterService {
    /**
     * 使用者註冊之後處理函式
     *
     * @param registerInputParam 使用者註冊之後的傳入引數
     */
    void postProcessorAfterRegister(RegisterInputParam registerInputParam);
}

註冊處理器內部才是真正的核心部分:

public abstract class AbstractRegisterHandler {
    /**
     * 獲取註冊渠道ID
     *
     * @return
     */
    public abstract int getSource();

    /**
     * 註冊之後的核心通知模組程式
     *
     * @param registerInputParam
     * @return
     */
    public abstract boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam);

}

具體的實現交給了各個Handler元件:

公眾號註冊渠道的後置處理器

public class GZHRegisterHandler  extends AbstractRegisterHandler {

    @Override
    public int getSource() {
        return RegisterConstants.RegisterEnum.GZH_CHANNEL.getCode();
    }

    @Override
    public boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam) {
        System.out.println("公眾號處理邏輯");
        return true;
    }
}

app註冊渠道的後置處理器

public class AppRegisterHandler extends AbstractRegisterHandler {

    @Override
    public int getSource() {
        return RegisterConstants.RegisterEnum.APP_CHANNEL.getCode();
    }

    @Override
    public boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam) {
        System.out.println("app處理邏輯");
        return true;
    }
}

不同的註冊渠道號通過一個列舉來進行管理:

public class RegisterConstants {

    public enum RegisterEnum{

        GZH_CHANNEL(0,"公眾號渠道"),
        APP_CHANNEL(1,"app渠道");

        RegisterEnum(int code, String desc) {
            this.code = code;
            this.desc = desc;
        }

        int code;
        String desc;

        public int getCode() {
            return code;
        }
    }
}

接下來,對於註冊的後置處理服務介面進行實現:

public class RegisterServiceImpl implements IRegisterService {

    private static List registerHandlerList = new ArrayList<>();

    static {
        registerHandlerList.add(new GZHRegisterHandler());
        registerHandlerList.add(new AppRegisterHandler());
    }

    @Override
    public void postProcessorAfterRegister(RegisterInputParam registerInputParam) {
        for (AbstractRegisterHandler abstractRegisterHandler : registerHandlerList) {
            if(abstractRegisterHandler.getSource()==registerInputParam.getSource()){
                abstractRegisterHandler.doPostProcessorAfterRegister(registerInputParam);
                return;
            }
        }
        throw new RuntimeException("未知註冊渠道號");
    }

}

最後通過簡單的一段測試程式:

public class TestDesignPrinciple {
    public static void main(String[] args) {
        RegisterInputParam registerInputParam = new RegisterInputParam();
        registerInputParam.setUserId(10012);
        registerInputParam.setSource(0);

        IRegisterService registerService = new RegisterServiceImpl();
        registerService.postProcessorAfterRegister(registerInputParam);

        RegisterInputParam registerInputParam2 = new RegisterInputParam();
        registerInputParam2.setUserId(10013);
        registerInputParam2.setSource(1);
        registerService.postProcessorAfterRegister(registerInputParam2);

        System.out.println("=======");

    }
}

這樣的設計和起初最先前的設計相比有幾處不同的完善點:

新增不同註冊渠道的時候,只需要關心註冊渠道的source引數。

同時對於後續業務的擴充,新增不同的註冊渠道的時候,RegisterServiceImpl只需要新增新編寫的註冊處理器類即可。

再回過頭來看,這樣的一段程式碼設計是否滿足了開放封閉原則呢?

每次新增不同的註冊型別處理邏輯之後,程式中都只需要新增一種Handler處理器,這種處理器對於原先的業務程式碼並沒有過多的修改,從整體設計的角度來看,並沒有對原有的程式碼結構造成影響,而且靈活度相比之前有所提高。這也正好對應了,對擴充套件開放,對修改關閉。

如果你對設計模式有一定了解的話,可能還會發現大多數常用的設計模式都在遵守這一項原則,例如模版模式,策略模式,責任鏈模式等等。

里氏替換原則

我認為,里氏替換原則更多是體現在了父子類繼承方面,強調的是子類在繼承了父類物件的時候不應該破壞這個父類物件的設計初衷。

舉個例子來說:

我們定義了一個提款的服務:

public interface DrawMoneyService {
    /**
     * 提款函式
     *
     * @param drawMoneyInputParam
     */
    void drawMoney(DrawMoneyInputParam drawMoneyInputParam);
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class DrawMoneyInputParam {

    private int money;
}

對應的是一個抽象實現父類:

public abstract class AbstractDrawMoneyServiceImpl implements DrawMoneyService{

    /**
     * 設計初衷,需要對提現金額進行引數校驗
     * 
     * @param drawMoneyInputParam
     */
    @Override
    public abstract void drawMoney(DrawMoneyInputParam drawMoneyInputParam);
}

正常的子類繼承對應父類都應該是對入參進行一個校驗判斷,如果金額數值小於0,自然就不允許提現了。

public class AppDrawMoneyServiceImpl extends AbstractDrawMoneyServiceImpl{

    @Override
    public void drawMoney(DrawMoneyInputParam drawMoneyInputParam) {
        if(drawMoneyInputParam.getMoney()>0){
            //執行提款程式
        }
        System.out.println("app提款業務");
    }
}

但是如果某個實現的子類當中違背了這一設計原則,例如下邊這種:

public class GZHDrawMoneyServiceImpl implements DrawMoneyService {
    @Override
    public void drawMoney(DrawMoneyInputParam drawMoneyInputParam) {
        if(drawMoneyInputParam.getMoney()<0){
            //執行提款程式
        }
        System.out.println("公眾號提款業務");
    }
}

那麼這種情況下,子類的實現就違背了最初父類設計的初衷,此時就違背了里氏替換原則的思想。此時就容易給閱讀程式碼的人感覺,不同的子類雖然都繼承了同一個父類,但是在轉賬的引數校驗邏輯上完全是東一套,西一套,沒有特定的規矩,邏輯比較亂。

所以較好的做法是在父類中就將需要滿足的基本邏輯定義好,保證子類在進行擴充套件的時候不會輕易造成修改。

另外說說多型和里氏替換原則兩個名詞:

從案例程式碼來看,你會發現似乎 多型 和 里氏替換 長得很相似。但是我個人認為這是兩個不同領域的東西,前者是程式碼特有的屬性,後者則是一種設計思想,正因為類有了多型的這種特性,人們才會重視在程式碼設計過程中需要遵守里氏替換原則。這一項原則在設計的過程中保證了程式碼設計的正確性,它更像是一種思路在指導著開發者如何設計出更加好維護和理解的程式。

介面隔離原則

關於介面隔離原則這部分,我們可以通過一個具體的實戰案例來學習。

在和第三方服務進行對接的時候,通常我們需要接入一些金鑰之類的相關資訊,例如和支付寶的支付介面對接,和微信支付介面做對接,和銀聯支付做對接等等。

那麼我們可以將這些不同場景下關於支付相關的資訊的儲存放在一個Config相關的物件中,如下所示:

public interface BasePayConfig {
}

然後對每類支付配置都有對應的一個實現方式:

@Data
@ToString
@EqualsAndHashCode
@Builder
public class BankPayConfig implements BasePayConfig {
    
    private String secretKey;
    
    private String appId;
    
    private String randomNumber;
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class AliPayConfig implements BasePayConfig {

    private String secretKey;

    private String appId;

    private String randomNumber;
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class WXPayConfig implements BasePayConfig {

    private String secretKey;

    private String appId;

    private String randomNumber;
}

然後呢,實際場景中我們需要將這些配置資訊給展示到一個後臺管理系統的某個模組當中,所以後續我便在已有的BasePayConfig介面中定義了一個專門展示支付配置的函式:

public interface BasePayConfig {

    /**
     * 展示配置
     */
    Map<String,Object> showConfig();
}

展示配置之後,需要在各個子類中去對不同的資訊進行組裝,最後返回一個Map的格式給到呼叫方。

但是隨著業務的變動,某天需要對微信支付的配置資訊實現可以替換更新的功能,但是額外的支付寶支付,銀聯支付不允許對外暴露這一許可權。那麼此時就需要對程式碼進行調整了。

調整思路一:

直接在BasePayConfig介面中進行擴充套件,程式碼案例如下:

public interface BasePayConfig {

    /**
     * 展示配置
     */
    Map<String,Object> showConfig(int code);
    
    /**
     * 更新配置資訊
     * 
     * @return
     */
    Map<String,Object> updateConfig();
}

然後各個子類依舊是實現這些介面,並且即使不需要實現更新功能的支付寶配置類,銀聯配置類都必須強制實現。從這樣的設計角度來思考就會發現,對於程式碼實現方面不是太友好,介面內部定義的函式粒度還可以再分細一些。

調整思路二:

將讀取配置和更新配置分成兩個介面,需要實現更新配置功能的類才需要去實現該介面。程式碼如下所示:

支付配置展示

public interface BasePayConfigViewer {
    /**
     * 展示配置
     */
    Map<String,Object> showConfig(int code);
}

支付配置更新

public interface BasePayConfigUpdater {

    /**
     * 更新配置資訊
     *
     * @return
     */
    Map<String,Object> updateConfig();
}

這樣的設計能夠保證,不同的介面專門負責不同的領域,只有當實現類確實需要使用該功能的時候才去實現該介面。寫到這裡的時候,你可以不妨再回過頭去理解下我在文章上半部分中提及的介面隔離原則,相信你會有新的體會。

或許你也會有所疑惑,介面隔離原則好像和單一責任原則有些類似呀,都是各自專一地負責自己所管理的部分。但是我個人認為,介面隔離原則關注的是介面,而單一責任原則關注的目標可以是物件,介面,類,所涉及的領域更加廣闊一些。

依賴反轉原則

在介紹依賴反轉原則之前,我們先來理解一個相似的名詞,控制反轉。

單純的從Java程式來進行理解:

例如我們定義個BeanObject物件:

public interface BeanObject {
    void run();
}

然後再定義相關的實現類,如訊息傳送:

public class MessageNotify implements BeanObject{

    @Override
    public void run() {
        System.out.println("訊息傳送");
    }
}

最後是一個Context上下文環境:

public class BeanContext {

    private static List<BeanObject> beanObjectList = new ArrayList<>();

    static {
        beanObjectList.add(new MessageNotify());
    }

    public static void main(String[] args) {
        beanObjectList.get(0).run();
    }
}

從程式碼來看,可以發現對於MessageNotify的呼叫均是通過一個BeanContext元件呼叫來實現的,而並不是直接通過new MessageNotify的方式去顯示呼叫。通過封裝一個基礎骨架容器BeanContext來管控每個BeanObject的run方法執行,這樣就將該函式的呼叫權轉交給了BeanContext物件管理。

控制反轉現在我們再來理解 控制反轉 這個名詞,“控制”主要是指對程式執行流程的控制,例如bean的呼叫方式。“反轉”則是指程式呼叫許可權的轉變,例如從bean的呼叫方轉變為了基礎容器。

依賴注入再來聊下依賴注入這個名詞。

依賴注入強調的是將依賴屬性不要通過顯式的new方式來建立注入,而是將其交給了基礎框架去管理。這方面的代表框架除了我們熟悉的Spring之外,其實還有很多,例如Pico Contanier等。

最後再來品味下官方對於依賴反轉的介紹:

High-level modules shouldn’t depend on low-level modules.  Both modules should depend on abstractions. In addition,  abstractions shouldn’t depend on details. Details depend on  abstractions.

高層模組(high-level modules)不要依賴低層模組(low-level)。高層模組和低層模組應該通過抽象(abstractions)來互相依賴。除此之外,抽象(abstractions)不要依賴具體實現細節(details),具體實現細節(details)依賴抽象(abstractions)。

依賴反轉原則也叫作依賴倒置原則。這條原則跟控制反轉有點類似,主要用來指導框架層面的設計。高層模組不依賴低層模組,它們共同依賴同一個抽象。抽象不要依賴具體實現細節,具體實現細節依賴抽象。

最後,希望這篇文章能夠對你有所啟發。

相關文章