策略模式初探

北冥有隻魚發表於2022-06-19

引入

其實介紹設計模式本身並不難,難的在於其該如何在具體的場景中合適的使用,上週在介紹了Shiro之後,我原本打算趁熱打鐵介紹一下責任鏈模式,但是責任鏈模式本身並不複雜,我們接觸的也不少,比如攔截器鏈,過濾器鏈,每個攔截器和過濾都有機會處理請求。

責任鏈模式

但在怎樣的場景中可以引入責任鏈模式呢, 我其實也在查對應的資料,在B站上看了不少該模式的視訊,但是底下的評論倒不是一邊到的完全叫好,底下的評論倒是說這是過度設計,不如策略模式,所以我們還是要認清楚一件事情,設計模式可以幫助我們解決一些問題,就像做飯的醬油,合適的使用能夠讓我們的菜變的更美味,但是如果你用不好,醬油放多了或者有些菜不該放醬油,做的菜就會不好吃。這一點我們可以在Shiro(Java領域的安全框架)對Java的加密庫設計的評價,可以一窺二三。

The JDK/JCE’s Cipher and Message Digest (Hash) classes are abstract classes and quite confusing, requiring you to use obtuse factory methods with type-unsafe string arguments to acquire instances you want to use.

JDK的密碼和MD5相關的類是非常抽象而且讓人非常困惑的,要求開發者使用型別不安全的String引數中從簡單工廠模式中獲取對應的加密例項,這是非常愚蠢的。

PS: 我覺得Shiro吐槽的不無道理,下面是JDK中使用MD5演算法和Shiro中使用MD5演算法的對比,大家可以體會下,也許就會明白為什麼Shiro的設計者吐槽JDK在加密庫的設計是愚蠢的。

// JDK 中的
private static void testMD5JDK() {
        try {
            String code = "hello world";
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] targetBytes = md.digest(code.getBytes());
            System.out.println(targetBytes);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
}
// 這是Shiro的,我個人覺得Shiro的設計更好
private static void testMD5Shiro() {
    String hex = new Md5Hash("hello world").toHex();
    System.out.println(hex.getBytes());
}

所以我寫設計模式的時候基本上會花很大的篇幅介紹這種模式的理念、以及應用場景,我還將UML圖逐了出去,我不認為UML圖有助於對設計模式的理解,因為看懂UML圖本身又需要一定的學習成本, 普通的圖一樣能表達設計模式的理念,並且不需要更多的預知概念。

為了介紹策略模式,我們要引入這樣一個場景(每種設計模式都有自己的場景),假設你有一堆程式碼if else, 每個判斷對應的又是一個執行方法,這些方法目前已經很龐大了, 像下面這樣:

public class StrategyDemo {   
    public void strategy(){
        if (條件A){
            doA();
        }else if (條件B){
            doB();
        }else if (條件C){
            doC();
        }else if (條件D){
            doD();
        }else if (條件E){
            doE();
        }
    }
}

這裡我們又假定滿足條件的方法處理的業務已經不少了, 這個StrategyDemo的檔案已經很可觀了,而且新的需求還在持續湧入,也就是說還會不斷的加入else if, 那該怎麼降低StrategyDemo這個類的複雜度呢,一個檔案太大也不利於後面人的維護,增加其他開發者的閱讀成本,我們可以將其類比到微服務架構上, 業務在演進的過程中,程式碼量不斷的上升,服務需要面對的請求量也在不斷上升,為了加快開發速度、增強服務的請求能力,我們將單體服務拆分成了微服務:

架構演進

我們這裡簡單介紹一下Module,JDK 9之前,JDK原生給我們提供的管理程式碼的單元是包,包裡面放類,我們藉助maven的父子工程來實現模組化概念:

專案結構圖

本質上也是一種拆分,我們的天性就是喜歡簡單的東西,那如何降低StrategyDemo這個類的複雜度呢,將這個類的體積減小一點呢,策略模式給出的答案是更進一步的單一職責,每個類分攤一種方法,這樣就將複雜度分攤到幾個類身上,那具體怎麼做呢?將判斷和行為定義抽取到父類中,哪個子類滿足對應的條件,由哪個子類來執行。一個比較經典的例子就是對接各種簡訊平臺,不同的簡訊平臺有不同的行為,上面的if else顯然是不利於我們擴充套件的,這正是策略模式大顯身手的場景:

public abstract class AbstractSmsStrategy {

    /**
     * 不同簡訊平臺的傳送簡訊的方式不一樣
     * 有的給sdk,有的用HTTP,所以這裡用抽象方法
     * @return
     */
    public abstract boolean sendSmsStrategy(String mobile,String content);

    /**
     * support 相當於if,哪個子類滿足該條件,哪個子類傳送簡訊
     * @return
     */
    public abstract boolean support(String name);
}

public class AliSmsStrategy extends AbstractSmsStrategy{

    /**
     * 對應的方法
     * @return
     */
    @Override
    public boolean sendSmsStrategy(String mobile,String content) {
        System.out.println("阿里傳送簡訊平臺成功.....");
        return false;
    }

     @Override
    public boolean support(String name) {
        return "ali".equals(name);
    }
}

public class TencentSmsStrategy extends AbstractSmsStrategy{
    @Override
    public boolean sendSmsStrategy(String mobile, String content) {
        System.out.println("騰訊傳送簡訊平臺成功.....");
        return false;
    }

    @Override
    public boolean support(String name) {
        return "tencent".equals(name);
    }
}
public class SmsStrategyContext {

    public AbstractSmsStrategy abstractSmsStrategy;

    public SmsStrategyContext(AbstractSmsStrategy abstractSmsStrategy) {
        this.abstractSmsStrategy = abstractSmsStrategy;
    }

    public  boolean sendSms(String mobile,String content){
        boolean result = abstractSmsStrategy.sendSmsStrategy(mobile, content);
        return result;
    }
}
public class StrategyDemo {
    public static void main(String[] args) {
        AbstractSmsStrategy abstractSmsStrategy = new AliSmsStrategy();
        SmsStrategyContext smsStrategyContext = new SmsStrategyContext(abstractSmsStrategy);
        smsStrategyContext.sendSms("","");
    }
}

大多數講策略模式其實都是這麼講的,定義具體的抽象策略由子類來實現, 然後再new 出一個具體的策略,給上下文,由上下文來執行具體的發簡訊策略。

策略模式

那我為啥要通過一個上下文來調對應的方法, 我直接用抽象的基類調對應的策略方法不行嗎?像下面這樣:

public class StrategyDemo {
    public static void main(String[] args) {
        AbstractSmsStrategy abstractSmsStrategy = new AliSmsStrategy();
        abstractSmsStrategy.sendSms("","");
    }
}

那是因為這個上下文在當前的場景下目前還是比較多餘的, 上下文在這個模式中就是遮蔽人和具體策略的互動, 如果你對接過簡訊平臺,你會發現簡訊平臺,簡訊平臺通常還要指定地址、賬號、密碼。那麼我們可以在上下文中載入一些配置或者做一些預處理,儲存簡訊的傳送日誌,上下文存在的意義就是儘可能的遮蔽載入策略的細節。我們可以將上下文進行改造,大家可以在這個例子中體會到上下文存在的意義。

public class SmsStrategyContext {

    public AbstractSmsStrategy abstractSmsStrategy;

    public SmsStrategyContext(AbstractSmsStrategy abstractSmsStrategy) {
        this.abstractSmsStrategy = abstractSmsStrategy;
    }

    /**
     * 其實對應的操作還有很多。
     * 發簡訊的時候,我們一般是將模板儲存在資料庫中.
     * 業務方在呼叫的時候,我們從資料庫中取出對應的模板。
     * 需要傳送連線的時候,還需要將長連結處理成短連結。
     * 你看上下文的作用體現出來了吧。
     * 通常傳送簡訊還有非同步處理,所以我們這裡還可以再做一個執行緒池,
     * 這樣看上下文的作用是不是體現出來了。
     * @param mobile
     * @param content
     * @return
     */
    public  boolean sendSms(String mobile,String content){
        //生成簡訊日誌,這裡假裝有一個簡訊日誌表.
        MessageLog messageLog = new MessageLog();
        boolean result = abstractSmsStrategy.sendSmsStrategy(mobile, content);
        // 這裡假裝執行對應的入庫操作
        return result;
    }
}

注意這個上下文不必一定要嚴格按照設計模式中一定是要注入對應的策略, 然後來呼叫,我們要理解,設定上下文的目的是儘可能的遮蔽執行對應策略的細節,讓呼叫方僅提供手機號和模板ID,就可以實現簡訊的傳送。 模板方式其實還可以做進一步的變型,在這個例子中我們還要顯式的new 出來對應的策略,那不能不能new啊,我給你一個識別符號,你來根據識別符號來載入對應的策略不行嗎? 讓呼叫方少乾點事,有效的解耦合。那其實上面的策略模式可以改造成下面這樣:

public class SmsStrategyContextPlus {

    private static final List<AbstractSmsStrategy> SMS_STRATEGY_LIST = new ArrayList<>();

    /**
     * 載入所有的簡訊策略
     */
    static {
        AbstractSmsStrategy aliSmsStrategy = new AliSmsStrategy();
        AbstractSmsStrategy tencentSmsStrategy = new TencentSmsStrategy();
        SMS_STRATEGY_LIST.add(aliSmsStrategy);
        SMS_STRATEGY_LIST.add(tencentSmsStrategy);
    }
    /**
     * 略去產生日誌, 長鏈轉短鏈。
     * 執行緒池等操作
     * @param mobile
     * @param templateKey
     * @param platform 哪個平臺,如果怕呼叫方寫錯,其實這裡可以做成列舉值
       * platform 也可以從配置中載入,在前臺選中啟用哪個簡訊平臺。
       * 這裡其實可以做的很靈活。
     * @return
     */
    public boolean sendSms(String mobile,String templateKey,String platform){
        boolean sendResult = false;
        for (AbstractSmsStrategy abstractSmsStrategy : SMS_STRATEGY_LIST) {
            if (abstractSmsStrategy.support(platform)){
                sendResult = abstractSmsStrategy.sendSmsStrategy(mobile,templateKey);
            }
        }
        return sendResult;
    }
}

在Spring中其實我們可以這麼用:

@Component
public class SmsStrategyContextSpringDemo {
    @Autowired
    private List<AbstractSmsStrategy> strategyList ;

    public boolean sendSms(String mobile,String templateKey,String platform){
        boolean sendResult = false;
        for (AbstractSmsStrategy abstractSmsStrategy : strategyList) {
            if (abstractSmsStrategy.support(platform)){
                sendResult = abstractSmsStrategy.sendSmsStrategy(mobile,templateKey);
            }
        }
        return sendResult;
    }
}

注意這個上下文的作用,對呼叫方遮蔽掉執行對應的策略的細節,不必太過拘泥其實現,我個人的看法是隻要是對呼叫方遮蔽了執行對應策略的細節,就是一個Context。

在shiro中的使用

上面講的策略模式其實在Java領域裡面的安全框架Shiro中也有所體現,Shiro幫我們寫好了登入程式碼,所謂的登入就是拿前臺提交的登入資訊和資料來源中的使用者資訊進行比對,資料來源其實有很多種, JDBC、LDAP等等。Shiro 抽象出了Realm介面來統一的從資料來源中獲取資訊,Shiro支援多資料來源,那Shiro是怎麼根據對應的資料來源來載入使用者資訊的呢?其實也是通過策略方式來分發的, AuthenticatingRealm 有兩個抽象方法:

  • doGetAuthenticationInfo 交給對應的realm來實現獲取登入資訊
  • supports 是否由當前realm來獲取資料來源

support

獲取登入資訊

總結一下

我們通常希望類小一點,這樣閱讀起來就快一點,假如你有在一個類裡面放了很多判斷,這個類已經很龐大了,不妨嘗試用策略模式來進行分發,來降低程式碼的複雜度,這樣維護起來也容易,或者就是預判,像是系統裡面接入簡訊平臺一樣,簡訊平臺商有很多,即使你的系統已經支援了知名的簡訊服務商了,但是還不夠,客戶希望用他自己的簡訊服務商,避免每接入一個簡訊服務商都要加一個判斷,我們可以採取策略方式,接入一個簡訊服務商,繼承父類,實現具體的發簡訊操作即可,通用操作像生成簡訊日誌、非同步傳送我們放在上下文裡面進行操作,也不用編寫重複程式碼,要注意體會上下文的思想,上下文存在的意義在於降低客戶端呼叫的難度,遮蔽載入對應策略的細節,簡單點儘量簡單點。某種程度上,我理解設計模式是一種預判或者降低程式碼複雜度的一種方式,在策略模式中就是分發程式碼,將滿足條件的行為移動到對應的子類中,儘可能的減少改動,策略模式的判斷,當前if else很多了,即使不會再增加判斷,在單一檔案中擁擠太多程式碼,也讓讀懂程式碼的人頭疼,為了降低複雜度,我將這些程式碼按照單一職責進行分發。預判的是當前是隻有一個if else,但是隨著業務的發展,會接入越來越多的簡訊平臺,所以這裡用上下文做通用操作,接入一個簡訊平臺實現對應的方法,減少程式碼的改動量,讓程式碼的可維護性和可閱讀性更高。

參考資料

相關文章