責任鏈模式探究

ES2049發表於2021-09-26

背景

責任鏈模式(又稱職責鏈模式,The Chain of Responsibility Pattern),作為開發設計中常用的程式碼設計模式之一,屬於行為模式中的一種,歷來被我們開發所熟悉。

責任鏈模式也是實人 SDK 使用的主要設計模式之一,從通過 start 介面獲取相關配置資訊,到 upload 介面上傳認證材料,後續通過 verify 介面提交認證獲取認證結果,可以說將整個實人業務的邏輯以鏈的方式實現,上一個業務節點的結果作為下一個業務的開始,從而串起了整個SDK的核心邏輯。我們雖然在日常開發過程中看過很多設計模式,同時也或多或少應用在工程中,但像老話說的一樣,想到不一定知道,知道不一定能做到,做出來又不代表能說出來,說的出來還不一定能寫出來。如何將自己寫過的程式碼,用到的設計模式翻譯成文字,對開發者來說也是一個很有意思的事情和小挑戰。

所以本篇旨在重新梳理一下自己印象中的設計模式,並訴諸文字,溫故而知新。

什麼是責任鏈模式

如上所述,責任鏈模式是一種理解上比較簡單的行為設計模式,它允許開發者通過處理者鏈進行順序傳送,每個鏈節點在收到請求後,具備兩種能力:

  1. 對其進行處理(消費)
  2. 將其傳遞給鏈上的下個處理者

當你想要讓一個以上的物件有機會能處理某個請求時,就可以使用責任鏈模式。通過責任鏈模式,為某個請求建立一個物件鏈,每個物件鏈依序檢查此請求,並對其進行處理,或者將它傳給鏈中的下一個物件。

image.png

從某種生活場景中理解,就像患者去醫院看病,傳統上可能會經歷從掛號到醫生問診再到抽血拍片再到醫生複診以及最終藥房拿藥的過程。

image.png

從生活經驗上可以看出,責任鏈上每個節點的產物是不同的(當然也可以相同,但相同的話就沒必要通過責任鏈去處理了,可能放在單個物件中會更合適),像連結串列結構一樣,每個節點除了需要包含指向下一個鏈節點的索引以及必要時終止傳遞的能力外,還需要具備傳遞給下一個節點的產物的能力。如何針對不同的業務場景對鏈節點的產物進行抽象,也成為了程式碼編寫中的一個問題,為什麼會成為一個問題?因為使用責任鏈的一大優勢就是我們可以動態地去新增或刪除鏈節點以達到業務能力的擴充套件,如果我們對輸出的產物定義的不夠清晰,就會導致在做鏈式擴充套件時,相關的產物程式碼會變得更加複雜導致程式碼的可讀性降低。

舉個例子,在實人 SDK 的工程中,通過建立一個物件將業務鏈中所有的過程產物都包含進了該類中,類似如下程式碼:

RealIdentityChainOutputs {
        // start 過程產物
    public StartOutput mStartOutput;
        // upload 過程產物
    public UploadOutput mUploadOutput;
        // verify 過程產物
    public VerifyOutput mVerifyOutput;
        // submit 最終結果產物
    public SubmitOutput mSubmitOutput;
    
}

這樣寫的好處是,可以通過傳遞一個物件的方式,將過程產物統一通過一個類物件的方式傳遞,就像是我在醫院拿了一個包含各種單據的檔案袋,每次走完一個流程就將其中一個單據填滿,進入下一個流程,簡單方便。但存在的問題也很明顯。

首先,會帶來程式碼的可見性風險,最開始的幾個鏈節點已經知道了後面幾個鏈節點產物的資料結構,那是否就存在前幾個節點有能力修改後面節點產物的可能?其次,如果在鏈傳遞過程中出現兩個相同的產物物件,那按照目前的產物包裝類,是很難「優雅」地去建立兩個相同資料的物件的,是建一個List還是再新建一個相同類的物件?其三,每個節點都有結束當前流轉流程的能力,也屬於鏈流轉最終產物中的一種結果,但放到上述包裝類中的話,代表著某一個產物即為最終整個鏈的產物,這和當初定義這個包裝類的初衷又是相違背的。當然,這些問題都是基於未來業務擴充套件的角度來考慮,針對實人比較簡單的業務場景,是可用的。提出太多的問題,有「過度設計」之嫌。

所以責任鏈到底解決了什麼問題?

  1. 前置檢查,減少不必要的後置程式碼邏輯
  2. 傳送者(sender)和接收者(receiver(s))的邏輯解耦,提高程式碼靈活性,這一點是最重要的
  3. 通過鏈路順序傳遞請求,也使每一個鏈節點職責明確,符合單一職責原則
  4. 通過改變鏈內的成員或調動它們的次序,允許你動態地新增或刪除,也提高了程式碼的靈活性

責任鏈模式程式碼的基本表達

我們來看一下責任鏈模式的 UML 圖。

image.png

從最基本的 UML 圖中可以看出責任鏈裡一般包含4種角色:

  1. Client 客戶端,定義鏈的規則和執行方式,根據具體業務場景動態生成鏈(所以鏈並不是固定不變的,可定製組合,並選擇鏈頭和鏈尾)
  2. Handler 處理者介面,主要用於宣告具體處理者的通用能力,一般包含抽象處理能力以及指向下一個節點的能力
  3. BaseHandler 基本處理者,這是一個可有可無的角色,可以根據業務場景將具體處理者中的一些共有邏輯放到該類當中
  4. ConcreteHandlers 具體處理者,構成了鏈中的處理節點,核心職能是處理請求,決定請求是在該節點消費掉還是沿著鏈繼續傳遞(具體處理者之間獨立且不可變)

可以看出,責任鏈模式核心的邏輯是處理和傳遞,同時具備由外部靈活定製的能力。

通過 UML 圖也可以看出責任鏈的固定的幾步實現方式:

  1. 宣告 Handler 介面定義節點處理的介面
  2. 通過建立抽象處理者基類消除具體處理者之間的重複模版程式碼
  3. 依次建立具體處理者子類及其實現方法,通過具體處理類決定當前處理類是否要消費這個請求或者沿著鏈繼續傳遞
  4. 最終體現到業務層,由 Client 物件自行組裝實現的鏈節點,實現邏輯處理和呼叫物件的解耦
// 處理者介面宣告瞭一個建立處理者鏈的方法。還宣告瞭一個執行請求的方法。
interface Handler is
    method handle()
    method setNext(h: Handler)


// 簡單元件的基礎類。
abstract class BaseHandler implements Handler is

    field canHandle: boolean

    // 如果元件能處理請求,則處理
    method handle() is
            doCommonThings
    method setNext(h: Handler)



// 原始元件應該能夠使用幫助操作的預設實現
class ConcreteHandlerA extends BaseHandler is
    // ...


// 複雜處理者可能會對預設實現進行重寫
class ConcreteHandlerB extends BaseHandler is

    method handle() is
        if (canHandle)
            // 處理者B的處理方式
        else
            super.handle()

// ...同上...
class ConcreteHandlerC extends BaseHandler is
    field wikiPageURL: string

    method handle() is
        if (canHandle)
                // 處理者C的處理方式
        else
            super.handle()


// Client
class Application is
    // 每個程式都能以不同方式對鏈進行配置。
    method cor() is
        handlerA = new ConcreteHandlerA()
        handlerB = new ConcreteHandlerB()
        handlerC = new ConcreteHandlerC()
        // ...
        handlerA.setNext(handlerB)
        handlerB.setNext(handlerC)

適用場景

通過上面的描述我們也可以看出,其實只要涉及到邏輯順序處理的,都可以使用責任鏈模式來處理。但從實際場景出發,決定是否使用該模式要考慮一下兩個因素:

  1. 場景是不是夠複雜,邏輯鏈是不是很長
  2. 是否有靈活變化的業務變化場景需求

同時還要注意使用責任鏈不可避免帶來的三個問題:

  1. 處理者的數量問題。對鏈中請求處理者的遍歷,如果處理者太多那麼遍歷必定會影響效能,特別是在一些遞迴呼叫中,所以要慎重
  2. 程式碼出現問題時,不容易觀察執行時的特徵,有礙於排查問題
  3. 需要 cover 請求即使傳遞到鏈尾端也一直沒被處理,從而導致的一些異常問題

下面藉助 Android 系統中和 ViewGroup 事件傳遞消費機制相關的例子來具體說明責任鏈使用的方式。
先看我們在螢幕上點選一次的行為在 Android 原始碼中的傳遞路徑。

image.png

可以很清晰的看到,Android 系統的事件傳遞和分發也是通過鏈的方式來實現的。如果將 ActivityViewGroupsView 三者作為具體處理者,通過他們自身的 dispatchTouchEvent() 方法對事件進行消費和傳遞,那就是一個標準的責任鏈模式。

// 虛擬碼邏輯
public boolean dispatchTouchEvent(MotionEvent ev) {

    if(onInterceptTouchEvent(ev)) {
            // onInterceptTouchEvent 方法作為是否需要在本處理者中被消費的判斷,如果為 true,則在本控制元件中消費
            this.onTouchEvent(ev);
    } else {
            // 本控制元件不被攔截,則傳遞給下一個控制元件的 dispatchTouchEvent 方法當中
            next.dispatchTouchEvent(ev);
    }

}

細心的同學也許發現了一個問題,就是當使用者的點選事件傳遞到控制元件最頂端的 View 後,如果在該 View 中 touch 事件還沒有被消費掉,那麼它會依照原來的傳遞過來的鏈路重新回到呼叫鏈最初開始的地方,即從 ViewonTouch() 或者 onTouchEvent() 重新回到 ActivityonTouchEvent() 方法中。

前文在描述責任鏈模式可能存在的問題的時候,我們也提到過,該模式有一個特別需要注意的點是,如果請求到鏈的末端還沒有被處理的話極有可能會讓程式碼出現穩定性問題。而 Android 通過重新將請求交回給最初的鏈節點方式來解決這個問題,這樣做的好處是:

  1. 請求不論走到哪一步都可控(即一定會被處理,即使可能最終是空實現)
  2. 讓和 UI 相關的功能類具備一致的行為方式(使 ActivityViewGroupView 均具備分發和消費能力)

從圖中也可以看出,以使用者輸入作為請求的起點,該請求在任何一個節點都有可能被消費掉,是一個典型的責任鏈設計。

再列舉一些日常開發過程中用到的責任鏈模式的場景,細節不表:

  • 登入模組(通過責任鏈進行各種前置賬號校驗的邏輯組合調整)
  • 賬務報銷系統(以許可權的不同來作為是否進行下一級處理的審批)
  • 郵件過濾系統(以郵件屬性,諸如重要郵件、廣告郵件、垃圾郵件、病毒郵件等郵件型別來進行過濾和攔截)

責任鏈模式與其他模式的關係

設計模式不是單指由某一個設計模式獨立存在於程式碼工程,而是多種設計模式根據不同的業務場景進行組合、變形、適配後的一個非常「豐富」的產物,那麼和責任鏈模式關係比較密切,或者說可以互相配合的設計模式有哪些呢?

通過責任鏈模式中兩種角色,傳送者和接收者,可以看出它和命令模式以及中介者模式是有一定相似性的,像命令模式在傳送者和請求者之間也是建立了單向連線,區別在於命令模式更傾向於解決引數化物件以及操作回滾等場景,當然責任鏈模式可以用命令模式來實現。

而中介者模式則將傳送者和接收者之間的直接連線改為中介物件進行連線,減少了物件間混亂的依賴關係。
設計模式之間的關係是配合、轉化的關係,其中的細節非常多,非本篇文章核心,這裡暫且不表。提到這一點也是為了讓有興趣的讀者自行探索。

總結

我們使用設計模式基本都是從程式碼擴充套件性、程式碼穩定性和程式碼可讀性等角度去考慮的。

對於責任鏈模式,它很好地解決了複雜邏輯場景下前後邏輯的耦合問題,同時對於需要靈活應對多變的業務場景,也是一種具有參考價值的解決方式。我們使用該模式時,需要特別注意對於中間鏈節點消費後丟擲後的行為以及到達鏈結尾請求沒有被處理的特殊場景。

寫在最後

最後借用《Head First Design Patterns》一書中對設計模式如何使用的表述,做一個收尾,深以為然。

  1. 為實際需要的擴充套件使用模式,不要只是為了假想的需要而使用模式
  2. 簡單才是王道,如果不用模式就能設計出更簡單的方案,那就去幹吧
  3. 模式是工具而不是規則,需要被適當地調整以符合實際的需求

參考

作者:ES2049 / 拂曉
文章可隨意轉載,但請保留此原文連結。
非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章