指令物件的建立與組合


iThome 網站首載:指令物件的建立與組合

程式庫經常需要提供某些服務,可執行客戶端要求的指令(Command),由於程式庫面對的客戶不同、需求不同,也就無法事先預測被要求執行的指令內容,此時可嘗試定義指令公開介面,讓客戶端可以將指令內容封裝在介面之中,分離指令的建立與執行。更進一步地,若客戶端建立的指令彼此之間想要任意組合,用來組合指令的組合器(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),以避免複雜的流程控制或變數追蹤,遞迴函式只是實現後的表面形式。同樣的道理,面對具有層次性、可分解為子問題的需求,像是測試案例、視窗元件、影片編輯等,識別出元件間的共同特徵或規律性,以組合模式分而治之,就可大幅降低元件結構上的複雜度,遞迴性只是最後在結構上呈現出來的外在樣貌。