JavaScript設計模式之組合模式
- 2019 年 11 月 7 日
- 筆記
一個公司,可能分為很多個事業部,然後事業部又分為不同的部門。每個部門可能又分為不同的方向,每個方向又由不同的項目組組成。在程式設計中,也有一些和「事物是由相似的子事物構成」類似的思想。組合模式就是用小的子對象來構建更大的對象,而這些小的子對象本身也許是由更小的「孫對象」構成的。
回顧在《命令模式》中講到的宏命令實現影片上傳流程管理。
const uploadVideo = { upload() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('upload'); resolve('finished') }, 3000); }); }, decode() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('decode'); resolve('finished') }, 3000); }); }, approve() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('approve'); resolve('finished') }, 3000); }); }, publish() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('publish'); resolve('finished'); }, 3000); }); } } const makeCommand = (receiver, action) => { return { excute: receiver[action] }; } const setCommand = (command) => command.excute(); const uploadCommand = makeCommand(uploadVideo, 'upload'); const decodeCommand = makeCommand(uploadVideo, 'decode'); const approveCommand = makeCommand(uploadVideo, 'approve'); const publishCommand = makeCommand(uploadVideo, 'publish'); const commandList = [uploadCommand ,decodeCommand,approveCommand,publishCommand]; // 宏命令 class MacroCommand { constructor(cmdList){ this.cmdList=cmdList; } // 壓棧 add(cmd){ this.cmdList.push(cmd); } // 出棧 del(){ this.cmdList.pop(); } // 運行宏命令 async excute(i){ if(i<this.cmdList.length){ let ret=await setCommand(this.cmdList[i]); if(ret=='finished'){ return this.excute(i+1); } } return true; } } const mc=new MacroCommand(commandList); mc.excute(0)
宏命令都包括了一組子命令隊列cmdList和自己的excute執行方法。mc表現的很像一個命令,甚至也可以作為命令使用。在結構上,稱之為組合對象,諸如上傳、轉碼審核等,都是它的葉對象。在mc.excute()方法中,並不會實際執行子命令。它只負責遍歷迭代。而把真正執行的事情委託給了 makeCommand
,讓它去"代理"自己執行子命令的excute方法。本身並不負責任何業務邏輯。
組合模式的優勢
首先,組合模式層次清晰地表述了命令之間的樹形結構關係,以電飯煲煮飯為例:

組合模式提供了一種遍歷樹形結構的方案,通過調用組合對象的execute方法,程式會遞歸調用組合對象下面的葉對象的execute方法,所以我們的只要點擊一個按鈕只需要一次操作,便能依次完成多件事情。組合模式可以非常方便地描述對象部分整體層次結構。
其次,編程者可以充分利用對象多態的優點。一視同仁地處理不同的宏命令。而不需去關心業務上的東西。
"自然選擇,前進四!" —— 章北海
《三體》中海軍出身的章北海,在冬眠幾個世紀後,能夠以海軍術語指導最新技術星際飛船"自然選擇號"行動,並不是一件多麼難以想像的事情——只要有組合命令。
請求在樹的傳遞過程
在一個組合模式的命令體系中,請求總是遞歸進行的。從頂層節點開始遍歷。

客戶只要請求頂層的組合對象(比如"前進四"),請求就會沿著樹的左叉遍歷傳遞。
為了說明這個問題,我們繼續來複雜化影片上傳的問題,假如影片的審核需要做更多的區分,包括"運營方(operator)審核"和"廣電總局(SARFT)審核"。那麼審核(approve)既是一個葉對象,也是一個宏命令。
// 審核流程 const approve={ operator() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('operator approved'); resolve('finished') }, 1000); }); }, sarft(){ return new Promise((resolve, reject) => { setTimeout(() => { console.log('SARFT approved'); resolve('finished') }, 2000); }); } } // 普通上傳 const uploadVideo = { upload() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('upload'); resolve('finished') }, 1000); }); }, decode() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('decode'); resolve('finished') }, 1000); }); }, approve, publish() { return new Promise((resolve, reject) => { setTimeout(() => { console.log('publish'); resolve('finished'); }, 1000); }); } } const makeCommand = (receiver, action) => { if(receiver[action]){ return { excute: receiver[action] }; }else{ return receiver; } } class MacroCommand { constructor(cmdList){ this.cmdList=cmdList; } // 運行宏命令 async excute(i){ i=i?i:0; if(i<this.cmdList.length){ let ret=await setCommand(this.cmdList[i]); if(ret=='finished'){ await this.excute(i+1); } } return 'finished'; } } const setCommand = async (command) => await command.excute(0); const operatorCommand =makeCommand(uploadVideo.approve,'operator'); const sarftCommand = makeCommand(uploadVideo.approve, 'sarft'); const approveCmdList=[operatorCommand,sarftCommand]; const approveCommand= makeCommand(new MacroCommand(approveCmdList)); const uploadCommand = makeCommand(uploadVideo, 'upload'); const decodeCommand = makeCommand(uploadVideo, 'decode'); const publishCommand = makeCommand(uploadVideo, 'publish'); const commandList = [uploadCommand ,decodeCommand,approveCommand,publishCommand]; const mc=new MacroCommand(commandList); mc.excute(0)
執行順序為 上傳-解碼-運營商審核-廣電總局審核:

簡化流程
但是生成命令的方法還是有些麻煩。該過程暴露了太多細節。考慮定義一個生成命令的方法,徹底撕離業務邏輯:
class MacroCommand { constructor(cmdList){ this.cmdList=this.makeCmdList(cmdList); } makeCommand(receiver, action){ if(receiver[action]){ return { excute: receiver[action] }; }else{ return receiver; } } makeCmdList(obj){ return Object.keys(obj).map((key,index)=>{ let ret=null; switch (typeof obj[key]) { case 'function': ret=this.makeCommand(obj,key); break; case 'object': ret =new MacroCommand(obj[key]) default: break; } return ret; }); } async setCommand(command){ return await command.excute(0) } // 運行宏命令 async excute(i){ i=i?i:0; if(i<this.cmdList.length){ let ret=await this.setCommand(this.cmdList[i]); if(ret=='finished'){ await this.excute(i+1); } } return 'finished'; } } const mc=new MacroCommand(uploadVideo); mc.excute()
delete uploadVideo.publish; uploadVideo.aaa=()=>{ return new Promise((resolve, reject) => { setTimeout(() => { console.log('aaa'); resolve('finished'); }, 1000); }); } const mc=new MacroCommand(uploadVideo); mc.excute() //upload -> decode -> operator approved -> SARFT approved -> aaa
這樣,我在定義了方法結構的同時,就定義了執行順序。
從這個例子中可以看到,基本對象可以被組合成更複雜的組合對象,組合對象又可以被組合,這樣不斷遞歸下去,這棵樹的結構可以支援任意多的複雜度。在樹最終被構造完成之後,讓整顆樹最終運轉起來的步驟非常簡單,只需要調用最上層對象的execute方法。每當對最上層的對象進行一次請求時,實際上是在對整個樹進行深度優先的搜索,而創建組合對象的程式設計師並不關心這些內在的細節,往這棵樹裡面添加一些新的節點對象是非常容易的事情。
注意事項
在使用組合模式的時候,還有以下幾個值得我們注意的地方。
- 組合模式不是父子關係(ISA),是一種HASA(聚合)的關係。組合對象包含一組葉對象,但Leaf並不是Composite的子類。組合對象只是把請求委託給它所包含的所有葉對象,它們能夠合作的關鍵是擁有相同的介面。為了方便描述,本章有時候把上下級對象稱為父子節點,但大家要知道,它們並非真正意義上的父子關係。
- 對葉對象操作的一致性:組合模式除了要求組合對象和葉對象擁有相同的介面之外,還有一個必要條件,就是對一組葉對象的操作必須具有一致性。
- 雙向映射關係發放過節費的通知步驟是從公司到各個部門,再到各個小組,最後到每個員工的郵箱里。這本身是一個組合模式的好例子,但要考慮的一種情況是,也許某些員工屬於多個組織架構。比如某位架構師既隸屬於開發組,又隸屬於架構組,對象之間的關係並不是嚴格意義上的層次結構,在這種情況下,是不適合使用組合模式的,該架構師很可能會收到兩份過節費。這種複合情況下我們必須給父節點和子節點建立雙向映射關係,一個簡單的方法是給小組和員工對象都增加集合來保存對方的引用。但是這種相互間的引用相當複雜,而且對象之間產生了過多的耦合性,修改或者刪除一個對象都變得困難,此時我們可以引入中介者模式來管理這些對象。
- .用職責鏈模式提高組合模式性能:在組合模式中,如果樹的結構比較複雜,節點數量很多,在遍歷樹的過程中,性能方面也許表現得不夠理想。有時候我們確實可以藉助一些技巧,在實際操作中避免遍歷整棵樹,有一種現成的方案是藉助職責鏈。職責鏈模式一般需要我們手動去設置鏈條,但在組合模式中,父對象和子對象之間實際上形成了天然的職責鏈。讓請求順著鏈條從父對象往子對象傳遞,或者是反過來從子對象往父對象傳遞,直到遇到可以處理該請求的對象為止,這也是職責鏈模式的經典運用場景之一。
適用場景
表示對象的部分整體層次結構。組合模式可以方便地構造一棵樹來表示對象的部分整體結構。特別是我們在開發期間不確定這棵樹到底存在多少層次的時候。在樹的構造最終完成之後,只需要通過請求樹的最頂層對象,便能對整棵樹做統一的操作。在組合模式中增加和刪除樹的節點非常方便,並且符合開放封閉原則。
戶希望統一對待樹中的所有對象。組合模式使客戶可以忽略組合對象和葉對象的區別,客戶在面對這棵樹的時候,不用關心當前正在處理的對象是組合對象還是葉對象,也就不用寫一堆if、else語句來分別處理它們。組合對象和葉對象會各自做自己正確的事情,這是組合模式最重要的能力。