編碼 · 設計模式
責任鏈模式詳解與範例
簡答
責任鏈是一種行為型設計模式,它讓請求沿著一串處理者物件傳遞。每個處理者要嘛處理這個請求,要嘛把它轉發給鏈中的下一個處理者。傳送者不知道哪個處理者會處理請求,所以你可以新增、移除或重新排列處理者,而不必更動傳送者。網頁中介軟體與 DOM 事件冒泡,都是廣為人知的例子。
意圖與它解決的問題
意圖:讓不只一個物件有機會處理請求,藉此避免請求的傳送者與接收者之間的耦合。把接收的物件串成一條鏈,並讓請求沿著這條鏈傳遞,直到有物件處理它。這個模式是《設計模式》(Gang of Four,簡稱 GoF)一書中的行為型模式之一。
以網路商店的客服系統為例。低於 50 的退款請求可以由客服人員核准,500 以內由組長核准,更大的金額則需要經理處理。第一版往往把這些全部寫進一個函式,裡頭有一長串 if/else。每多一條規則,這個函式就更長,而且檢查的順序也被寫死了。
使用責任鏈時,每一個層級都變成各自的處理者,各負責一條規則。這些處理者被連結在一起,請求從第一個進入。要新增一個核准層級,只需要寫一個小類別,並把它插入鏈中。
結構與參與者
| 角色 | 職責 |
|---|---|
| 處理者(Handler) | 宣告處理請求的方法,並保存指向下一個處理者的參照 |
| 具體處理者(Concrete handler) | 處理自己負責的請求;否則把請求轉發給下一個處理者 |
| 用戶端 | 建立這條鏈(或接收它),並把請求送給第一個處理者 |
常見的變化有兩種。在經典形式中,第一個能處理請求的處理者會終止這條鏈。在管線形式(大多數中介軟體採用)中,每個處理者都可以做一些工作,然後決定是否呼叫下一個。
TypeScript 實作
抽象基底類別會保存下一個處理者,並提供預設的轉發行為。每個具體處理者覆寫 handle,並在無法決定時呼叫 super.handle。
type Refund = { amount: number };
abstract class Approver {
private next?: Approver;
setNext(next: Approver): Approver {
this.next = next;
return next; // allows a.setNext(b).setNext(c)
}
handle(req: Refund): string {
return this.next ? this.next.handle(req) : "rejected: no one can approve " + req.amount;
}
}
class Agent extends Approver {
handle(req: Refund): string {
return req.amount < 50 ? "agent approved " + req.amount : super.handle(req);
}
}
class TeamLead extends Approver {
handle(req: Refund): string {
return req.amount <= 500 ? "team lead approved " + req.amount : super.handle(req);
}
}
class Manager extends Approver {
handle(req: Refund): string {
return req.amount <= 5000 ? "manager approved " + req.amount : super.handle(req);
}
}
const chain = new Agent();
chain.setNext(new TeamLead()).setNext(new Manager());
for (const amount of [20, 300, 2000, 9000]) console.log(chain.handle({ amount }));
// agent approved 20
// team lead approved 300
// manager approved 2000
// rejected: no one can approve 9000用一般函式實作的中介軟體形式
在 TypeScript 中,你不需要類別。一串函式,每個函式接收一個 next 回呼,就能構成一條鏈。Express 和 Koa 的中介軟體就是這樣運作的。
type Req = { user?: string; path: string };
type Middleware = (req: Req, next: () => string) => string;
const auth: Middleware = (req, next) => (req.user ? next() : "401 Unauthorized");
const log: Middleware = (req, next) => {
console.log("request", req.path);
return next();
};
const route = (req: Req) => "200 OK " + req.path;
function run(chain: Middleware[], req: Req): string {
const step = (i: number): string =>
i < chain.length ? chain[i](req, () => step(i + 1)) : route(req);
return step(0);
}
console.log(run([log, auth], { path: "/admin" }));
console.log(run([log, auth], { user: "ana", path: "/admin" }));
// request /admin
// 401 Unauthorized
// request /admin
// 200 OK /admin何時使用、何時不該用
- 當好幾個物件都可能處理請求,而正確的那個要到執行時期才決定時,就使用它。
- 當處理者的集合或順序應該可以設定時使用,例如驗證步驟、核准層級或 HTTP 中介軟體。
- 當你希望每條規則各自放在一個可獨立測試的小單元時使用。
- 對於兩三個永遠不變的固定檢查,不要使用它;簡單的
if敘述更清楚。 - 要注意,過長的鏈比較難除錯,因為從呼叫的地方看不出最後是哪個處理者採取了行動。在處理者內加入記錄會有幫助。
- 鏈對順序很敏感。例如把驗證身分放在路由處理者之後,就失去了它的意義。
實際應用
- Express 和 Koa 的中介軟體:每個函式接收請求和一個
next函式,並決定是否繼續。 - DOM 事件傳遞:事件從目標元素開始,向上冒泡經過它的祖先,任何監聽器都可以用
event.stopPropagation()停止它。 - Java 的 Servlet 過濾器構成一條過濾器鏈,其中每個過濾器呼叫
chain.doFilter把請求傳下去。 - 記錄框架會把記錄從記錄器傳給它的父記錄器及其處理者。
相關模式
| 模式 | 與責任鏈的差異 |
|---|---|
| 裝飾者 | 裝飾者也會形成一串包裝器,但每個裝飾者一定會執行並轉發;處理者則可以中止這條鏈。 |
| 命令 | 鏈中的請求常是一個命令物件;命令著重於把請求封裝起來,責任鏈則著重於找出由誰來處理。 |
| 組合 | 組合樹中的父節點,是很自然的下一個處理者,GUI 事件沿著元件樹向上冒泡就是這樣。 |
| 中介者 | 中介者透過一個知道所有接收者的中央物件來傳遞訊息;責任鏈則是把請求從一個處理者傳給下一個。 |
常見問題
鏈中的每個處理者都必須處理請求嗎?
不必。在經典形式中,第一個能處理請求的處理者會終止這條鏈。在中介軟體形式中,每個處理者可以做一些工作,然後呼叫下一個,所以好幾個處理者可以對同一個請求採取行動。
Express 的中介軟體是責任鏈的例子嗎?
是的。每個中介軟體函式接收請求和一個 next 回呼,它要嘛結束回應,要嘛呼叫 next,把控制權交給下一個中介軟體。
如果沒有任何處理者處理請求會怎麼樣?
這取決於你的設計。好的做法是在鏈的尾端加上預設行為,例如回傳錯誤、拋出例外或記錄,這樣請求就不會無聲無息地遺失。
這和一串 if/else 敘述有什麼不同?
邏輯是一樣的,但每個條件都變成獨立的物件或函式。這讓鏈可以在執行時期設定,並讓你不必編輯一個龐大的函式,就能新增步驟或重新排列順序。