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方法。每當對最上層的對象進行一次請求時,實際上是在對整個樹進行深度優先的搜索,而創建組合對象的程式設計師並不關心這些內在的細節,往這棵樹裡面添加一些新的節點對象是非常容易的事情。

注意事項

在使用組合模式的時候,還有以下幾個值得我們注意的地方。

  1. 組合模式不是父子關係(ISA),是一種HASA(聚合)的關係。組合對象包含一組葉對象,但Leaf並不是Composite的子類。組合對象只是把請求委託給它所包含的所有葉對象,它們能夠合作的關鍵是擁有相同的介面。為了方便描述,本章有時候把上下級對象稱為父子節點,但大家要知道,它們並非真正意義上的父子關係。
  2. 對葉對象操作的一致性:組合模式除了要求組合對象和葉對象擁有相同的介面之外,還有一個必要條件,就是對一組葉對象的操作必須具有一致性
  3. 雙向映射關係發放過節費的通知步驟是從公司到各個部門,再到各個小組,最後到每個員工的郵箱里。這本身是一個組合模式的好例子,但要考慮的一種情況是,也許某些員工屬於多個組織架構。比如某位架構師既隸屬於開發組,又隸屬於架構組,對象之間的關係並不是嚴格意義上的層次結構,在這種情況下,是不適合使用組合模式的,該架構師很可能會收到兩份過節費。這種複合情況下我們必須給父節點和子節點建立雙向映射關係,一個簡單的方法是給小組和員工對象都增加集合來保存對方的引用。但是這種相互間的引用相當複雜,而且對象之間產生了過多的耦合性,修改或者刪除一個對象都變得困難,此時我們可以引入中介者模式來管理這些對象。
  4. .用職責鏈模式提高組合模式性能:在組合模式中,如果樹的結構比較複雜,節點數量很多,在遍歷樹的過程中,性能方面也許表現得不夠理想。有時候我們確實可以藉助一些技巧,在實際操作中避免遍歷整棵樹,有一種現成的方案是藉助職責鏈。職責鏈模式一般需要我們手動去設置鏈條,但在組合模式中,父對象和子對象之間實際上形成了天然的職責鏈。讓請求順著鏈條從父對象往子對象傳遞,或者是反過來從子對象往父對象傳遞,直到遇到可以處理該請求的對象為止,這也是職責鏈模式的經典運用場景之一。

適用場景

表示對象的部分整體層次結構。組合模式可以方便地構造一棵樹來表示對象的部分整體結構。特別是我們在開發期間不確定這棵樹到底存在多少層次的時候。在樹的構造最終完成之後,只需要通過請求樹的最頂層對象,便能對整棵樹做統一的操作。在組合模式中增加和刪除樹的節點非常方便,並且符合開放封閉原則。

戶希望統一對待樹中的所有對象。組合模式使客戶可以忽略組合對象和葉對象的區別,客戶在面對這棵樹的時候,不用關心當前正在處理的對象是組合對象還是葉對象,也就不用寫一堆if、else語句來分別處理它們。組合對象和葉對象會各自做自己正確的事情,這是組合模式最重要的能力。