从 Forces 开始分析责任链模式:「写一个 Discord 对话机器人」
目錄
前言
你收到了一份需求
面向對象分析 (OOA)
初版程式實作
察覺 Forces
套用責任鏈模式 (OOD)
封裝變動之處 (Encapsulate what varies)
萃取共同行為 (Abstract common behaviors)
委派/複合 (Delegation / Composition)
重構出第二版程式碼
責任鏈模式總結
責任鏈模式中重複的 If-Else 程式碼
責任鏈模式 x 樣板方法程式實作
總複習
軟體設計模式精通之旅
試吃課程送 Astah UML Editor
前言
大家好,我是水球潘。
今天我們要來介紹一個 GoF 軟體設計模式中的一位狠角色,責任鏈模式 (Chain Of Responsibility Pattern),簡稱 CoR。責任鏈模式是我最喜歡的模式之一,因為他能夠允許開發者持續地往一個類別中添加新的行為,使該類別得以組合各式各樣的需求。
我喜歡借由「完整的實戰演練」來談論軟體設計模式,而非「過度簡化的案例」。如此一來才能讓大家感受到設計理論與實務上之間的緊密配合。因此,我會遵照以下步驟來介紹每一個模式:
你收到了一份需求
今天我們的實戰演練就是在知名社群平臺 Discord 上開發一款多功能的 Discord 機器人!
你要為水球軟體學院開發一個 Discord 機器人 (WaterballBot)。這個機器人支援許多功能,社群成員可以在 Discord 頻道中發布訊息 (Message) 來請求某一道功能。而當機器人收到訊息指令時,就會做相對應功能的處理 (Handle)。
舉例來說,如果你想要看看貨幣匯率,你可以在頻道中發布訊息 "currency",然后學院機器人就會立刻回復你匯率資訊。
目前支援以下三者訊息指令:查看機器人有哪些訊息指令可用。
訊息指令不區分大小寫,不管你是打成大寫開頭的 Help 或是 全大寫的 HELP,機器人全都能辨認。每一個訊息指令只會對應一個功能。如果訊息無法匹配到任何功能的話,機器人就會無視這則訊息。
再來,稍微說一下 Discord 的推播機制,由于這是第三方的技術,并不是本文的重點,各位稍微有個印象就好。我們能夠簡單地使用 Discord API,來讓機器人連線 (Connect) 且登入至 Discord 之 中。登入后就能開始偵聽 (Listen) 各種事件 (Event),而我們主要想偵聽的事件是??MessageCreateEvent 也就是新訊息事件。每當有人在某頻道中傳訊息時,Discord 會通知機器人說哪一個文字頻道 (Message Channel) 有了一則新訊息 (Message)。
滿實用又滿有挑戰性的,對吧。
走吧!先進行面向對象分析。
面向對象分析 (OOA)
好,這就是我們分析出來的初版類別圖了,灰色區塊的部分為第三方的 Discord API 中的類別。我們先專注在學院領域模型中的 WaterballBot 類別:當呼叫 WaterballBot 的 connect operation 時,WaterballBot 會透過 Discord API 連線和登入,之后 WaterballBot 會向 Discord API 偵聽MessageCreateEvent。每一次有新訊息 (Message) 時,Discord 會推播新訊息給 WaterballBot 并告知此訊息是來源自哪一個文字頻道 (MessageChannel),此時 WaterballBot 就會處理 (handle) 這則訊息,訊息處理方法 (handle 方法) 的行為如同粉紅色便條紙所示;主要有三種不同的訊息指令,各對應到不同的處理。
接著,我們來實作初版的程式碼~
初版程式實作
WaterballBot 類別的實作分成兩部分,第一部分是第三方套件的使用細節:connect 方法中撰寫著我們如何透過 Discord API 讓機器人連線、登入和偵聽新訊息的事件:
public class WaterballBot {// 透過 Discord API 讓機器人連線、登入和偵聽新訊息的事件public void connect() throws IOException {String token = getDiscordToken();DiscordClient client = DiscordClient.create(token);GatewayDiscordClient gateway = client.login().block();gateway.on(MessageEvent.class).subscribe(e -> {if (e instanceof MessageCreateEvent) {MessageCreateEvent mce = (MessageCreateEvent) e;Message message = mce.getMessage();MessageChannel channel = message.getChannel().block();handle(message, channel);}});gateway.onDisconnect().block();}private static String getDiscordToken() throws IOException {try (InputStream in = currentThread().getContextClassLoader().getResourceAsStream("token.properties")) {Properties properties = new Properties();properties.load(in);return String.valueOf(properties.get("TOKEN"));}}...這部分大略閱讀過去就行,直接來看第二部分今天的重點,機器人該如何做 message handling:
public void handle(Message message, MessageChannel channel) {if ("help".equalsIgnoreCase(message.getContent())) {channel.createMessage("▋ HELP ▋").block();channel.createMessage("Commands: dcard, currency").block();} else if ("dcard".equalsIgnoreCase(message.getContent())) {String dcardBody = crawlDcardBody();channel.createMessage("▋ DCARD ▋").block();channel.createMessage(dcardBody).block();} else if ("currency".equalsIgnoreCase(message.getContent())) {String currencyBody = crawlCurrencyBody();channel.createMessage("▋ CURRENCY ▋").block();channel.createMessage(currencyBody).block();}}private String crawlDcardBody() { /* 實作 Dcard 爬蟲 */ }private String crawlCurrencyBody() { /* 實作匯率資訊爬蟲 */}將 Message 和 MessageChannel 傳入 handle 方法。handle 方法中使用條件式來匹配不同訊息對應的處理行為。舉例來說,如果新訊息的內容是 “currency”,那便是請求匯率資訊的功能,此時訊息處理的行為就是:「先去匯率相關網站爬蟲來搜集匯率資訊,然后再將這些資訊整理成簡單易讀的文字回傳到 Discord 頻道中」。而 Dcard 和 Help 訊息也有對應的處理行為。
至于?crawlDcardBody?和?crawlCurrencyBody?的實作內容就不帶各位理解囉,裡面是非常單純的爬蟲使用,有興趣的伙伴可以至文末的 Github 連結中閱讀原始碼。
接著我們回到 Main method 中把 WaterballBot 實體化出來,讓它連接且登入至 Discord,如此一來就大功告成了。
public class Main {public static void main(String[] args) throws IOException {WaterballBot waterballBot = new WaterballBot();waterballBot.connect();} }察覺 Forces
接著,我們要來感受一下這個 handle method 內部的程式碼,察覺一下我們面臨了哪些 Forces?
我們第一個要察覺的 Force 就是「變動性 (Variation)」。
??1. 我們能從這段程式碼或是需求中感受到行為變動性 (Behavioral Variation):
需求方希望能增加五花八門的功能到機器人的指令列表中,像是支援 CNN 新聞爬文功能、查看今日天氣、翻譯英文單字??等等。而社群成員 (可視為是機器人的 Client) 請求每一道功能所下的訊息指令也不同,導致程式實作中被迫新增更多 If-Else cases。而正因為 WaterballBot 的訊息處理行為會不斷增加,此為行為變動性的 Force——行為會變,而且變動的方向是讓 WaterballBot 的行為量由少變多。
好,需求方滿意了;接著,各個功能端的開發者就有意見了:「不管是處理 Dcard 訊息還是處理 Currency 訊息,大家都擠在 WaterballBot 類別中開發,導致程式碼之間常常需要相互顧忌著沖突,既不好維護也不好擴充,有完沒完啊?能不能將這些程式獨立出來成各個訊息處理者類別啊?」
??2. 此時我們察覺到了第二道 Force:工程師對于程式碼維護性 (Maintainability) 上的要求:能否讓工程師更專注地維護各個既有功能程式碼而不受其他功能干擾?
再來,應用端的工程師也跟著有意見了:「如果把不同功能獨立出去的話,那到底該如何將這些功能整合進 WaterballBot 中?要是未來負責 CNN 爬文功能的工程師開發好 CNN 功能的話,他要怎麼有彈性地在 WaterballBot 中 擴充此功能啊?如果哪天我們不需要 Dcard 功能時,要怎麼有彈性地從 WaterballBot 中移除此功能?」
??3. 此時我們察覺到了第三道 Force:程式彈性 (Flexibility) 上的要求 ,讓工程師能夠有彈性地在 WaterballBot 中決定要支援哪些功能好嗎?
這三道 Forces 彼此沖突,重重地束縛了相關的程式碼,使工程師們感到壓抑。
因此,我們必須解決這一道難題,我們將 Problem 定義為:如何解耦 WaterballBot 和所有的訊息處理者(i.e., 訊息處理行為),以致于能夠有彈性地在系統中決定要支援哪些功能?
套用責任鏈模式 (OOD)
我們來看看責任鏈模式的模式語言,與策略模式十分相似:先使用「依賴反轉之重構三步驟 (Dependency Inversion 3 Steps),重構出責任鏈模式的 Form,然后再套用依賴注入模式來處理依賴。
封裝變動之處 (Encapsulate what varies)
這一步,我們要將行為變種封裝至類別方法中。所以在這段程式碼裡頭有哪些行為變種 (Behavioral Variant) 呢?這邊有三個條件式的 cases,每個 case 都撰寫不同的「需求 → 處理需求」,從這裡我們可以看出有三個行為變種。
開完這三個 Handler 類別并將各自變種的行為封裝進 handle 方法中之后,就算是做完第一步了,結果如下圖所示:
萃取共同行為 (Abstract common behaviors)
下一步,我們要觀察這三個行為變種,并把他們共同的行為,萃取至一個介面之中。
首先,我們能發現這些 Handler 之間有一個共同的行為:「只處理隸屬于自己責任范圍內的訊息」。因此,開了 MessageHandler 介面并宣告一個 handle operation 在介面中表示著「處理隸屬于自己責任范圍內訊息」的能力。
但是,我們依然還沒有萃取「如果訊息不隸屬于自己的責任范圍,那就要把訊息丟給下一個 case」 —— 條件式 else 部分的行為。我們來想一下,把訊息丟給下一個 case,是什麼意思呢?
其實,就是請下一位 Handler 來處理的意思。因此,每一位 Handler 都必須認識在其之后的下一位 Handler,當訊息不隸屬于自己的責任范圍時,才有辦法請下一位 Handler 接續處理。
所以我們修改類別圖,將「下一位 Handler (The Next handler) 」萃取至 MessageHandler 介面之中,但是由于介面只能包含純然抽象的行為,我們必須將 MessageHandler 改成抽象類別。
并且多開設一道關聯:「每一位 MessageHandler 都認識他的下一位 MessageHandler」。大家可以把此關聯想像成會在類別中多一個類型為 MessageHandler 的 next 屬性。next 的參與者數量為 0..1,意即此 next 屬性可能存在,也可能為 null。
接著我們便能完整地定義這三個 Handler 子類別的共同行為,如果訊息隸屬于自身的責任范圍就進行處理,否則先檢查下一位 Handler 是否存在,存在的話就請下一位 Handler 接續處理。
委派/複合 (Delegation / Composition)
而最后一步,就是在 WaterballBot 類別之中,將訊息處理的職責委派給此 MessageHandler 抽象類別。
如此一來,我們就做完依賴反轉之重構三步驟了。而重構完后也就套完了責任鏈模式 (Chain Of Responsibility) 囉。接著,我們來重構出第二版的程式碼吧!
重構出第二版程式碼
? 1. 首先先創建 MessageHandler 抽象類別,
public abstract class MessageHandler {protected MessageHandler next;public MessageHandler(MessageHandler next) {this.next = next;}public abstract void handle(Message message, MessageChannel channel); }從建構子依賴注入下一位 MessageHandler,next 可能為 null 也可能不為 null。 另外,我們必須將此 next 屬性的存取權設為 protected,所以子類別才能夠存取它。
之后宣告抽象方法 handle,這一步就算完成了。
??2. 再來我們依序實作 Help、Currency、Dcard 訊息的處理者類別,先以 HelpHandler 為例:
開好 HelpHandler 類別后,使其繼承 MessageHandler、復寫建構子和 handle 方法。然后將原本 case 中的行為搬進來 handle 方法中,并且將不隸屬于自己責任范圍內的訊息交給 the next handler 接續處理:
public class HelpMessageHandler extends MessageHandler {public HelpMessageHandler(MessageHandler next) {super(next);}@Overridepublic void handle(Message message, MessageChannel channel) {if ("help".equalsIgnoreCase(message.getContent())) {channel.createMessage("▋ HELP ▋").block();channel.createMessage("Commands: dcard, currency").block();} else if (next != null) {next.handle(message, channel);}} }再來 Currency 訊息的處理者也是如法炮製:
public class CurrencyHandler extends MessageHandler {public CurrencyHandler(MessageHandler next) {super(next);}@Overridepublic void handle(Message message, MessageChannel channel) {if ("currency".equalsIgnoreCase(message.getContent())) {String currencyBody = crawlCurrencyBody();channel.createMessage("▋ CURRENCY ▋").block();channel.createMessage(currencyBody).block();} else if (next != null) {next.handle(message, channel);}}private String crawlCurrencyBody() {...} }最后則是 Dcard 的訊息處理者,如此一來我們就將所有訊息處理的部份封裝進各個處理者類別中了。
public class DcardHandler extends MessageHandler {public DcardHandler(MessageHandler next) {super(next);}@Overridepublic void handle(Message message, MessageChannel channel) {if ("dcard".equalsIgnoreCase(message.getContent())) {String dcardBody = crawlDcardBody();channel.createMessage("▋ DCARD ▋").block();channel.createMessage(dcardBody).block();} else if (next != null) {next.handle(message, channel);}}private String crawlDcardBody() {...} }現在回到 WaterballBot 類別,將 MessageHandler 從建構子中依賴注入:
public class WaterballBot {private MessageHandler handler;public WaterballBot(MessageHandler handler) {this.handler = handler;} ...并將訊息處理的職責全部委派給 MessageHandler:
public void handle(Message message, MessageChannel channel) {handler.handle(message, channel); // 委派 }如此一來就能解耦 WaterballBot 與所有訊息的處理細節,WaterballBot 的類別中再也看不見處理的相關行為,可以說是成功地解耦了 WaterballBot 以及所有的處理者實作。
最后,我們回到 Main method 中,把整條 MessageHandler 責任鏈實體化出來,這就是套用完責任鏈的程式碼:
public class Main {public static void main(String[] args) throws IOException {WaterballBot waterballBot = new WaterballBot(new HelpMessageHandler(new DcardHandler(new CurrencyHandler(null))));waterballBot.connect();} }非常帥氣吧!就像一個鏈條一樣,把所有功能串在一起,一個串一個,直到在最后一個 Handler 傳入 null 為止。
責任鏈模式總結
Context:你的程式需要支援很多不同種類的需求,并且每個需求都對應到一個處理行為。
注意:每個需求都只有對應到「一個」處理行為。
責任鏈模式解決的 Problem 為:如何解耦需求者及所有的需求處理者,以致于能夠有彈性地在系統中決定要支援哪些需求?
具體來說,責任鏈模式化解了以下這三道 Forces:
責任鏈提出的解決方案 (Form or Solution):
首先會萃取出一個 Handler 抽象類別,每一個 Handler 都認識(關聯)the next Handler,就像一條鏈子。然后,Handler 的具體實作 ConcreteHandler 在 handle 時首先會先判斷這個 Request 是否隸屬于自己的責任范圍內,如果是的話就 handle 它,要不然就會將此 Request 交給 the next Handler 接續處理(如果 the next handler 存在)。
將所有 ConcreteHandler 串起來串成一條鏈子,并且每個 ConcreteHandler 都只處理隸屬于自身責任范圍內的 Request,這也是為什麼這個模式被稱之為責任鏈 (Chain Of Responsibility, CoR) 。
責任鏈上的每個 Handler,就像公司中那些本位主義的員工,不在其位,不謀其政。在責任鏈模式的設計中,給定一個 Request,只會被一個 ConcreteHandler 所處理。也有可能沒有 ConcreteHandler 愿意處理它,就代表系統不支援這個 Request,你可以依照自己的 Context 來做小幅度的變化。
各位可以很容易地將責任鏈模式的 Form 對應到我們的案例中。
最后,套用完每一個模式之后,都會得到一個結果;或者說,套用完模式后我們會得到一個 Resulting Context(以 Form 類別圖來看):
??1.??Context 類別 (i.e., WaterballBot) 作為需求者,已經再也無法看到每個需求究竟會被哪一位 ConcreteHandler 所處理,也完全看不見處理行為的細節—— 完全解耦了 Context 和 ConcreteHandler 。而正是只有在完全解耦的前提之下,我們才能在不影響 Context 類別內部程式碼的情況,輕鬆地支援新的需求。
??2.??另外,現在我們能夠專注地維護各個 ConcreteHandler 類別:你能夠在 ConcreteHandler 類別中實作著非常複雜的處理行為,而完全不會影響到其他 ConcreteHandler。很多時候在一個專案中,不同功能是由不同的工程師、甚至是由不同團隊負責開發,將大家的程式碼獨立出來,對團隊的整體生產力有正向幫助。
試想一下,假如公司有一百位工程師,而現在要開發一百個獨立功能,如果我們在套用責任鏈模式的 Resulting Context 中將這一百個獨立功能平均分配給這一百位工程師來開發,則工作的平行度就會趨近于 100,意即能夠平行開發這一百個獨立功能,頂多只有在整合責任鏈時才比較有可能發生程式碼沖突。
??3. 除此之外,責任鏈模式最厲害的地方是,我們能夠透過依賴注入 (Dependency Injection, DI),來在不修改 Context 類別內部程式碼的前提之下,不斷地擴充新的 Handler 來支援新的需求。
這種能做到「在不修改某些類別的前提下、擴充新功能」的特色又被稱之為遵守著開閉原則 (Open-Closed Principle, OCP) 。
責任鏈模式中重複的 If-Else 程式碼
我們回到責任鏈模式的類別圖,看一下兩個 ConcreteHandler 的 handle 行為,有沒有發現這些行為其實都有著重複的程式碼呢?可以說,唯一有變動的部分就只有需求的處理行為。
if can handle {do handling 1 } else if (next != null) {next.handle(request) } if can handle {do handling 2 } else if (next != null) {next.handle(request) }在這裡,大家是否有想到我們可以套用哪個設計模式,來減少重複的程式碼?
是的,我們能夠套用樣板方法 (Template Method),來減少責任鏈中 Handler 之間重複的程式碼。在大多數 (99%) 的情境下,責任鏈模式和樣板方法會一起套用,套用完的類別圖如下圖所示:
Handler 抽象類別中的 handle 被轉成具體方法,我們將重複的程式提取至 handle 中成為樣板方法,并把會變動的部分萃取成抽象的步驟。像是將需求責任范圍的判斷 萃取成抽象的 match 方法,將實際的需求處理行為萃取成抽象的 doHandling 方法。
帶著這樣子的設計,我們來重構我們的程式碼吧。
責任鏈模式 x 樣板方法程式實作
public abstract class MessageHandler {protected MessageHandler next;public MessageHandler(MessageHandler next) {this.next = next;}public void handle(Message message, MessageChannel channel) {if (match(message)) {doHandle(message, channel);} else if (next != null) {next.handle(message, channel);}}protected abstract boolean match(Message message);protected abstract void doHandle(Message message, MessageChannel channel); }依照類別圖,將樣板方法提取至 handle 方法中,如果 match 就 doHandling,要不然就請 next handler 作處理。而每一個 ConcreteHandler 都只要複寫好 match 和 doHandling 就行了,
如此一來,每一個 ConcreteHandler 的程式碼就不會重複,也乾淨許多。
public class HelpMessageHandler extends MessageHandler {public HelpMessageHandler(MessageHandler next) {super(next);}@Overrideprotected boolean match(Message message) {return "help".equalsIgnoreCase(message.getContent());}@Overrideprotected void doHandling(Message message, MessageChannel channel) {channel.createMessage("▋ HELP ▋").block();channel.createMessage("Commands: dcard, currency").block();} } public class CurrencyHandler extends MessageHandler {public CurrencyHandler(MessageHandler next) {super(next);}@Overrideprotected boolean match(Message message) {return "currency".equalsIgnoreCase(message.getContent());}@Overrideprotected void doHandling(Message message, MessageChannel channel) {String currencyBody = crawlCurrencyBody();channel.createMessage("▋ CURRENCY ▋").block();channel.createMessage(currencyBody).block();}private String crawlCurrencyBody() { ... } } public class DcardHandler extends MessageHandler {public DcardHandler(MessageHandler next) {super(next);}@Overrideprotected boolean match(Message message) {return "dcard".equalsIgnoreCase(message.getContent());}@Overrideprotected void doHandling(Message message, MessageChannel channel) {String dcardBody = crawlDcardBody();channel.createMessage("▋ DCARD ▋").block();channel.createMessage(dcardBody).block();}private String crawlDcardBody() { ... } }總複習
你已經學完責任鏈模式了,現在是總複習時間。
什麼時候會套責任鏈模式?當你的程式需要處理各種不同種類的需求,并且你希望能夠解耦需求者及所有的需求處理者,以致于你能夠有彈性地在系統中決定要支援哪些需求。
怎麼套呢?藉由依賴反轉之重構三步驟來將「處理隸屬于自己責任范圍內的需求」這項能力萃取出來,并封裝至不同的處理者類別中,然后透過依賴注入,來讓每一位處理者都認識下一位處理者,如同一條責任鏈。
責任鏈模式和樣板方法、策略模式一樣都是行為型模式 (Behavioral Pattern),并且大多數時候我們會用樣板方法來去除責任鏈中重複的 if-else 程式碼。
軟體設計模式精通之旅
喜歡我的文章和我的教學方式嗎?為了能夠持續優化我的教材,水球軟體學院持續招募「想要試吃免費設計模式課程,并給予我們回饋的朋友們」唷!課程中帶有大量含金量高的內容的挑戰題,歡迎來嘗試和體驗!
趕緊文末 Discord 連結加入學院的社群報名下一梯次吧!
試吃課程送 Astah UML Editor
另外,現在加入還會直接送 Astah UML Editor —— Astah 是筆者我已經使用了六年的 UML Editor,可以說是自我學會軟體設計模式之后,一路陪著我過關斬將的武器呀——十分專業而且體驗還很流暢。
軟體設計是非常耗費腦力的事,如果沒有一套好的工具幫你從思想禁錮中解放,那麼最后做出來的設計一定會充斥著盲點。這就是為什麼學院愿意為每一位學員準備好這一套專業的 UML Editor,如此一來學員們才能在優秀工具的加成下,熟悉善用工具來活化設計思維的過程,來在課程的任務挑戰中持續過關斬將,最后滿載而歸。?
水球軟體學院 Discord 連結:https://pse.is/49bxqg
水球軟體學院 軟體設計模式精通之旅 Github?連結
總結
以上是生活随笔為你收集整理的从 Forces 开始分析责任链模式:「写一个 Discord 对话机器人」的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 基于STM32的TFT-LCD触摸屏实验
- 下一篇: 数据的处理方法及触摸屏终端