程式庫經常需要提供某些服務,可執行客戶端要求的指令,由於程式庫面對的客戶、需求不同,也就無法預測被要求執行的指令內容,此時可嘗試定義指令公開介面,讓客戶端可將指令內容封裝在介面中,分離指令的建立與執行。

更進一步地,若客戶端建立的指令彼此之間,想要任意組合,用來組合指令的組合器(Composite)必然也是一種指令,也就是零件與組合器間會具有相同的公開介面,形成遞迴式的組合模式。

從多重到單一職責的測試執行器
從開發測試程式庫的案例中,可看出分離指令的建立與執行的重要性。如果測試人員撰寫測試案例,你負責提供測試執行器(Test runner)用以執行測試人員的測試案例。

測試人員在AccountTest撰寫了testDeposit,為了執行測試,你在TestRunner加入呼叫testDeposit的程式碼,若測試人員又在AccountTest撰寫testWithdraw,你就又在TestRunner加入呼叫testWithdraw的程式碼。有了新測試案例,你就得修改TestRunner,顯然TestRunner完全受需求變化而修改。

測試案例的建立是無窮無盡的,執行測試案例的請求也就沒有盡頭。而TestRunner的問題,在於同時負責請求的建立與執行,為了解決問題,你為測試案例的執行請求,建立了共同遵守的公開介面,例如以Java的interface定義Test中具有run方法;測試人員可在Test實例的run中定義測試案例,例如在run中呼叫AccountTest的testDeposit方法,或是直接將testDeposit的內容,重新撰寫在run方法中;TestRunner修改為接受Test實例,並在被要求執行測試時,逐一取得已接受的Test實例並執行run方法。

實際上,上述修改後具有指令模式的樣貌。在《Design Patterns: Elements of Reusable Object-Oriented Software》書中,定義了指令模式:「將請求封裝為物件,如此就可將客戶端不同請求參數化、將請求排入佇列或加以記錄,並支援復原操作。」實際上「將請求封裝為物件」是此句重點,更重要的是書中後續的描述:「指令模式將要求操作與執行操作的物件分離」。

以方才的測試情境來說,測試人員要求執行測試(也就是要求操作),封裝在測試人員自行建立的Test實例中(亦即將請求封裝為指令物件),TestRunner只留下實際執行測試的職責(亦即執行操作),由於分離了測試的請求建立與執行,TestRunner不再因測試人員撰寫測試案例而需修改。

實際上,指令物件也可以有多個方法,封裝相關聯的多個請求操作,例如:定義undo方法來封裝某個指令對應的復原指令。

指令模式重點並非執行器實現方式

在《Design Patterns》書中,一開始對指令模式的定義,是從提供服務的一方來看待指令模式,所謂「可將客戶端不同請求參數化、將請求排入佇列或加以記錄」,都只是實際接受與執行指令物件的執行器可能的實現方式,而非指令模式的重點。

指令模式重點,是在觀察到同時負有指令建立與執行職責的執行器時,嘗試建立指令物件公開介面,藉以將指令建立職責,從執行器中分離出來;原本同時擔任雙重角色的執行器形式不同,重構後的執行器實現方式,就會有所不同。

例如在不少Web框架應用指令模式的方式中,就可看到將客戶端不同請求參數化的實例。

以Struts為例,可以實作Action作為指令物件來封裝請求處理,每個Action物件對應一個或多個URL,不同請求就是被參數化為不同的URL。

視窗程式庫設計者不可能知道某動作發生時,使用者想要執行的指令為何,常見處理的事件處理機制就引發了指令模式。像是JButton可讓客戶端註冊自訂的ActionListener物件,在相關事件發生時呼叫actionPerformed方法,執行其中封裝的指令內容。

而方才的測試情境中,TestRunner在測試時,則逐一取得Test實例,呼叫run方法執行測試指令。

指令模式的主要精神,在於將指令的建立與執行分離,而要分離的原因有很多種,大部份是由於事先無法預測或規範客戶端之指令內容,就如先前舉過的幾個例子;有時執行指令時所需資源與客戶端是隔離的,例如網路的物理性下,客戶端與伺服端天生就是隔離的。

一個例子是伺服端提供DAO(Data access object)物件,並允許客戶端發送指令來操作DAO,指令物件在設計上可接受DAO實作物件,客戶端建立指令內容時依賴於DAO介面,想要伺服端執行指令物件時,可將指令物件序列化後傳送至伺服端,由伺服端反序列化後,注入DAO實例並執行指令。

從上面的例子中,也可看出指令模式分離指令的建立,與執行時的目的之一:降低客戶端與服務端間溝通的複雜度。可以想像如果自訂通訊協定來解決上述問題,就得應付更多複雜的流程。

以組合模式思考組合性問題

回到方才測試的情境,如果測試人員有任意組合測試案例的需求,例如將某幾個相關測試案例組合在一起,免去個別執行測試案例的麻煩;或者是將已組合的測試案例,與另一組相關的測試案例結合,甚至是將一組測試案例與某幾個獨立的測試案例,結合為新的一組測試套件。

實際上沒有任意組合這回事,東西要能組合,必然要具有某些共同特徵。方才的需求中,可運行的測試案例組合在一起,必然也要是可運行的測試,因此可定義TestSuite來實作Test介面,並提供新增或移除Test實例的方法,當執行TestSuite的run方法時,可逐一取得管理中的Test實例並呼叫run方法。

由於TestSuite本身實作Test介面,因此TestSuite除了可接受實作Test介面的個別案例外,也可以接受實作Test的TestSuite實例,形成可遞迴的樹狀結構。

在《Design Patterns》書中,定義了組合模式:「將物件建構成可表現部份/整體階層的樹狀結構。組合模式讓客戶端能對個別物件與物件的組成物一視同仁。」之所以為樹狀結構,是因為組合模式中擔任「整體」角色的組合器,相當於樹幹角色,而擔任「部份」的個別物件相當於葉子。樹幹可以沿生出分支樹幹,而樹幹末梢可以長出葉子,若將「整體」與「部份」由上而下按照階層繪製出來,就會是一個倒過來的樹狀結構。視窗程式中元件(Component)的排列,也經常應用到組合模式,視窗程式中會有可容納元件的容器(Container),容器本身亦是一種元件,因為具有如此遞迴關係,方可應付視窗多元化的排版需求。

先前談到,東西要能組合,一定要有共同特徵,這是能讓客戶端對物件及組成物一視同仁的原因,也是套用組合模式的重點:「對這組東西,不管待會實際取得哪個,有沒有辦法用相同方式處理?像是執行測試時,無論取得的是TestSuite或Test實例,一律呼叫run方法;繪製一組視窗元件時,無論取得按鈕、文字方塊或是頁籤,一律呼叫paint方法。

以共同特徵或規律性分而治之
採用組合模式會形成可遞迴的樹狀結構,遞迴實際上是將整體問題分解為子問題的外在表現形式。就如先前談到,實際上沒有任意組合這回事,如果問題本身沒有辦法分解為子問題,亦無法採用組合模式的概念解決。

設計演算法時若出現遞迴函式,真正目的是在將函式面對的問題分而治之(Divide and conquer),以避免複雜的流程控制或變數追蹤,遞迴函式只是實現後的表面形式。

同樣的道理,面對具有層次性、可分解為子問題的需求,像是測試案例、視窗元件、影片編輯等,識別出元件間的共同特徵或規律性,以組合模式分而治之,就可大幅降低元件結構上的複雜度,遞迴性只是最後在結構上呈現出來的外在樣貌。

 

作者簡介


Advertisement

更多 iThome相關內容