【设计模式精讲】18.责任链模式(Chain of Responsibility)
【设计模式精讲】18.责任链模式(Chain of Responsibility)
【摘要】:GUI 里按 F1,谁该显示帮助?按钮有自己的提示就显示,没有就问所在对话框,再没有就问应用,最后兜底帮助目录。这条「就近上浮」的规则常被写成一个认识所有控件的中心分发函数,新控件一来就改它。本文以 GoF 原书的帮助系统为主线:请求沿处理者链传递、认领即停止,发送者从此不知道谁会接手;层级链(父子指针)之外,现代代码里更常见的「扁平世界」责任链是服务器过滤链——vector 加一个诚实的 bool,再到「必须转发的装饰层 + 可以短路的责任层」合体的中间件洋葱。本文重点辨析责任链与装饰器——两段代码形似,三问即可分开;文末对照 C++ 异常机制、POCO 分层配置与 AOSP 触摸事件分发。
【关键词】:责任链、处理者、请求传递、处理即停、中间件、事件传播
【代码基准】:C++17
1. 按下 F1,谁来显示帮助
GUI 程序有条不成文的惯例:F1 = 就近帮助。焦点在「取消」按钮上、而它有自己的提示,就显示按钮的提示;按钮没有提示,就问它所在的对话框;对话框也没有,就问应用;应用再没有,就显示帮助目录。第一版代码常把这条规则写成一个中心分发函数:
// 说明性片段
// ❌ 「谁来显示帮助」由一个中心函数全知
void onF1(Widget* w) {
if (w->id() == "btn-cancel")
showHelp("取消:不保存返回");
else if (w->type() == W_BUTTON)
showHelp(parentDialogTopic(w)); // 按钮→找对话框?
else if (w->type() == W_DIALOG)
showHelp(w->topic());
else
showHelp("帮助目录");
}
三处硬伤藏在「能用」的表象下:其一,这个函数认识每一种控件——新加一种「嵌在对话框里的面板」,分发函数就得再长一支分支;其二,「我有没有帮助主题」本是控件自己最清楚的事,这份自有知识却被搬到远处的分发函数里代管,两边迟早漂移;其三,「按钮先问自己、再问父级」的层级语义被压扁成了 parentDialogTopic 之类的辅助判断——对话框套面板、面板套按钮时,分支深度跟着嵌套深度一起失控。
而 F1 的语义本来就是组织事实的直接翻译:每个控件都是潜在的帮助处理者,有主题就认领,没有就上浮给父级。发送方(按 F1 的用户与事件分发器)既不知道、也不需要知道帮助最终由谁显示——GoF 原书选来讲解责任链的,正是这个帮助系统。
2. 模式意图与定义
- 一句话定义:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系;把这些对象连成一条链,沿着链传递请求,直到有一个对象处理为止。
解决的问题:发送方不该知道(也常常无法知道)谁会处理请求;处理者的集合、顺序与各自边界需要独立演化。 - GoF 原文意图:Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it. 注意句尾——until an object handles it(直到有对象处理为止):「处理即停止」不是实现细节,是写进意图的语义核心,也是后文与装饰器辨析的分水岭。GoF 的动机示例正是本文主例:上下文相关帮助沿 enclosing widget 层级上浮,
Application对象是链尾。 - Refactoring Guru 的表述:责任链是一种行为型模式,允许你将请求沿着处理者链进行发送;收到请求后,每个处理者均可处理请求,或将其传递给链上的下一个处理者。RG 的现实类比同样经典:拨打技术支持热线,一级支持解决不了就升级给二级、最后到工程师——「升级」就是「上浮」。
三条定性:
- 发送者只认链头(在帮助系统里甚至只认「焦点控件」),谁在链上、有几个,它一概不知;
- 每个处理者只认识「下一位」(GoF 的实现注记:后继引用可以放在处理者基类共享——本例的父级指针,也可以由具体处理者各自持有,后者能精确控制谁是谁的下家);
- 转发是选择不是义务——「没有主题」才上浮,认领了就停。与之配套的工程纪律:链尾必须兜底(明确处理或明确拒绝),否则请求会静默消失,这是责任链最著名的坑——帮助系统的兜底就是那行「显示帮助目录」。
GoF 还点出一个常见组合:责任链常与组合模式(第 12 篇)连用——链不必是线性的,也可以沿一棵树传播(帮助上浮、事件下发都是沿控件树),组合提供树,责任链提供「传递与认领」的语义。第 7 节 AOSP 的例子正是这个形态。
3. UML 图 + 结构说明
三个参与者:
- 处理者(Handler):
HelpHandler,定义处理请求的接口并给出默认实现——有主题则认领、无主题则上浮、到链尾则兜底; - 具体处理者(Concrete Handler):
Button/Dialog/Application,多数只需声明「我的主题是什么」,需要特殊行为的才覆盖handleHelp; - 客户端:F1 事件分发——只对焦点控件调一次
handleHelp(),帮助最终由谁显示,它从头到尾不知道。
把这张图与第 13 篇装饰器的 UML 并排放——拓扑几乎一样:同接口、持一个同接口的引用、逐调用转发。差异藏在两处箭头语义里:装饰器的组合箭头是「包裹」(外层包内层,进出都要穿过),责任链的后继箭头是「上浮/传递」(单向,认领就走不到后面);装饰器的引用装配即拥有(洋葱外层管内层生死),责任链的后继通常只是借道(本例的父级指针方向,与组合树「父拥有子」的所有权方向恰好相反)。这两处差别会在第 5、6 节反复兑现——代码形状会撒谎,箭头语义不会。
4. 传统 C++ 写法(C++11 之前)
照 GoF 原书的骨架写 C++98 版:HelpHandler 基类持有父级指针与主题号,认领/上浮/兜底的默认逻辑全部收在基类,具体控件多数只声明数据:
// C++98/03 写法
#include <stdio.h>
typedef int Topic;
const Topic NO_HELP = -1;
// ---- 处理者:认领,或上浮给父级 ----
class HelpHandler {
public:
HelpHandler(HelpHandler* parent, Topic t)
: parent_(parent), topic_(t) {}
virtual ~HelpHandler() {}
virtual void handleHelp() {
if (topic_ != NO_HELP) {
show(topic_); // 认领:显示自己的主题
} else if (parent_ != 0) {
parent_->handleHelp(); // 上浮:交给父级
} else {
printf("显示帮助目录\n"); // 链尾兜底
}
}
private:
HelpHandler(const HelpHandler&);
HelpHandler& operator=(const HelpHandler&);
void show(Topic t) {
printf("显示帮助主题 %d\n", t);
}
HelpHandler* parent_; // 上浮方向:只借不拥
Topic topic_;
};
// ---- 具体处理者:多数只声明主题 ----
class Widget : public HelpHandler {
public:
Widget(HelpHandler* parent, Topic t)
: HelpHandler(parent, t) {}
};
class Button : public Widget {
public:
Button(HelpHandler* parent, Topic t)
: Widget(parent, t) {}
};
class Dialog : public Widget {
public:
Dialog(HelpHandler* parent, Topic t)
: Widget(parent, t) {}
};
class Application : public Widget {
public:
Application()
: Widget(0, NO_HELP) {} // 链尾:无父、无主题
};
int main() {
Application app; // 兜底:帮助目录
Dialog dlg(&app, 100); // 对话框主题 100
Button ok(&dlg, NO_HELP); // 按钮无主题
Button cancel(&dlg, 201); // 按钮主题 201
ok.handleHelp(); // 上浮 → 主题 100
cancel.handleHelp(); // 认领 → 主题 201
app.handleHelp(); // 兜底 → 帮助目录
return 0;
}
对照第 1 节的 onF1:那个认识所有控件的分发函数整体消失了。想在对话框与按钮之间插一层「面板」,只是构造时把按钮的父级指给面板——已有控件零改动,开闭原则(第 2 篇)在行为型模式上的第一次兑现;新控件类型只需一个构造函数,不碰任何分发逻辑。
三条传统写法的铁律:链尾必须兜底——删掉基类里那行 else,一个无主题无父级的处理者就会让 F1 无声无息,「无人认领」必须是明确答案而不是静默;上浮指针只借不拥——parent_ 是构造期注入的观察指针,控件树由父拥有子(第 12 篇的组合),责任链的传递方向与所有权方向相反,与装饰器「洋葱外层删内层」恰成对照;认领条件用自有知识——「有没有主题」是控件自己的数据,谁也不替谁判断,这正是把第 1 节那坨类型分支还给了类型本身。
5. 现代 C++ 进阶写法
升级零:扁平世界的链,vector 比 next 指针诚实。帮助系统是「嵌套世界」——父子关系天然存在,链就是控件树的一条上浮路径,next 指针顺理成章。现代服务端代码里更多的是「扁平世界」:限流器、鉴权器、路由器是一组平级候选,没有谁是谁的父级,链的顺序是人为定的优先级。这种世界用数组表达更直接,「处理即停」也该写进返回值契约:
// 节选:服务器请求过滤链
#include <memory>
#include <string>
#include <vector>
struct Request {
std::string path;
bool authorized = false;
};
class Filter {
public:
virtual ~Filter() = default;
// true = 已认领(拒绝也是认领),链停止
virtual bool apply(Request& r,
std::string& out) = 0;
};
std::string dispatch(
const std::vector<std::unique_ptr<Filter>>&
chain,
Request& r) {
std::string out;
for (const auto& f : chain) {
if (f->apply(r, out))
return out; // 认领即停
}
return "404 Not Found"; // 兜底:无人认领
}
三层的认领条件各司其职:限流器「超额即认领,返回 429」、鉴权器「未登录且访问受保护路径即认领,返回 401」、路由器「认识这个路径即认领,交给业务」。对照 setSuccessor 版的收益:顺序就是下标,插拔就是 vector 操作,调试时一眼看全链;bool 返回值把「认领/不认领」从注释升格为类型契约。代价是丢了「每个处理者精确指定下家」的灵活性——扁平世界里基本用不上。
改进一:std::function 注册式管道。处理逻辑足够小的时候,类层次可以整个让位给可调用对象,每层逻辑以 lambda 之名住进管道:
#include <functional>
#include <vector>
using FilterFn = std::function<bool(
Request&, std::string&)>;
class FilterPipeline {
public:
void add(FilterFn f) {
filters_.push_back(std::move(f));
}
std::string dispatch(Request& r) {
std::string out;
for (const auto& f : filters_)
if (f(r, out))
return out;
return "404 Not Found";
}
private:
std::vector<FilterFn> filters_;
};
// 注册示意:一整个「鉴权层」就是一个 lambda
// pipeline.add([](Request& r, std::string& o) {
// if (r.path.rfind("/admin", 0) != 0)
// return false; // 不归我,传下一位
// if (!r.authorized) {
// o = "401"; // 认领:拒绝并停止
// return true;
// }
// return false; // 已登录,放行
// });
与第 6 篇工厂方法的注册表遥相呼应:那边注册「怎么造」,这边注册「谁认领」——两个模式共享同一套「vector + 自注册」的现代基础设施。
改进二:中间件洋葱——责任链与装饰器的合体。Web 框架的中间件(日志、鉴权、限流、业务)是最成熟的现代责任链,但它的形状是一条洋葱:请求从外层穿向内核,响应再从内核穿回外层——每一层都有「前置逻辑、调下一层、后置逻辑」三段,而且可以选择不调下一层(鉴权失败直接短路)。装配用装饰器的「从内向外包裹」,语义用责任链的「认领即停止」:
#include <functional>
#include <string>
#include <vector>
// 「调下一层」的回调:由装配过程注入
using Next = std::function<std::string()>;
using Middleware = std::function<std::string(
const std::string& req, Next next)>;
std::string run(
const std::vector<Middleware>& layers,
const std::string& req) {
// 洋葱内核:真正的业务处理者
Next next = [] { return "业务处理完成"; };
// 从最后一层往回包裹:
// 每层捕获它的「下一层」
for (auto it = layers.rbegin();
it != layers.rend(); ++it) {
const Middleware& layer = *it;
Next deeper = next;
next = [layer, req, deeper] {
return layer(req, deeper);
};
}
return next(); // 从最外层进入洋葱
}
// 一层「必须转发」的装饰性中间件(日志):
// Middleware log = [](const std::string& r,
// Next next) {
// // 前置:记录请求
// std::string resp = next(); // 必须转发
// // 后置:记录响应
// return resp;
// };
// 一层「可以短路」的责任性中间件(鉴权):
// Middleware auth = [](const std::string& r,
// Next next) {
// if (!tokenOk(r))
// return "401"; // 不调 next():短路
// return next(); // 放行
// };
同一个 Middleware 类型,装着两种灵魂:日志层不转发就没有意义(装饰器),鉴权层转发与否取决于判断结果(责任链)。这正是「两种模式代码形似」的终极答案——现代框架干脆让它们住在同一根链上,哪一层表现出哪种意图,由那层是否调用 next() 决定。
展望:层序列编译期可定的话,模板可变参数(Layers<Log, Auth, Biz>)能把洋葱搬进类型系统,next() 变成静态调用、可完全内联——第 13 篇 CRTP mixin 与第 16 篇编译期桥的同一思路;代价同样是失去运行时按配置装配的自由。
6. 优缺点与适用场景
- ✅ 优点(GoF 后果清单):降低耦合——发送者不知道谁处理,处理者之间也只认识下一位(帮助系统里甚至只认识自己的父级);增删处理者只动装配——插一层面板、加一个过滤器,已有处理者零改动,各级边界规则各归各类、可独立测试;链的形状自由——线性(过滤链)、嵌套层级(帮助上浮)、树形(配合组合模式的事件传播)皆可。
- ❌ 缺点:请求可能无人认领——链尾不兜底就静默丢失,必须显式设计「没人处理」的答案;不保证被处理、也未必快——请求要走过所有「不认领」的层才停,长链上最坏情况全链空转;调试视野变差——单步会穿过一串「不归我」的上浮,帮助系统「到底谁显示了主题」要沿父级链回溯;扁平链上每个节点都能插入逻辑,职责边界容易糊。
- 🎯 适用场景:一组候选处理者、按序给机会、通常只有一个认领——界面事件与上下文帮助的传播(GoF 主例)、服务器过滤与中间件、审批与工单升级(RG 的技术支持热线)、异常处理、路由/回退策略;处理者集合或顺序会独立演化;发送方与处理方分属两个模块。
〔辨析〕责任链 vs 装饰器(第 13 篇)——代码形似的三问。两段代码摆在一起确实像:同接口、持一个同接口、收到请求先做自己的事再转发。三问分开它们:
- 转发是义务还是选择? 装饰器的转发是义务——它存在的意义就是包住别人的处理并叠加增强,不转发等于欺骗等待结果的客户端;责任链的转发是选择——「处理即停止」,转发只发生在「不归我管」时,这条线写在 GoF 意图的 until an object handles it 里。
- 链上的每一层都会执行吗? 装饰器每层必然执行,增强本身就是目的;责任链的后继层可能永远不知道某个请求来过——
ok.handleHelp()那一次,Application根本没被碰到。所以装饰器的层序是包裹顺序(调用与返回都穿过每层),责任链的层序是优先级(先到先得,单向流动)。 - 客户端知道链吗? 装饰器的客户端要的正是「层层增强后的对象」,装配是使用的一部分;责任链的客户端只管把请求递给链头(焦点控件、链头过滤器),谁处理、处理到第几层,它不知道也不该知道。
一句话:装饰器包装「能力」,责任链寻找「归属」;分类学也印证——装饰器是结构型(关心怎么组装),责任链是行为型(关心谁负责),这是同一形状在不同意图下的两次使用,也是「模式 = 意图 + 结构,而不只是结构」的最好例证。两者的合体即第 5 节的中间件洋葱。顺带两条:责任链 vs 观察者(第 24 篇)——链是一对一传递、可终止,观察者是一对多广播、不终止;责任链 vs 组合(第 12 篇)——组合提供树形结构,责任链提供沿结构传递与认领的语义,GoF 点名的搭档,本篇帮助链与第 7 节事件链都是这对搭档的现场。
7. 开源项目中的身影
标准语言机制:C++ 异常就是一条责任链。throw 发出请求后沿调用栈逐帧展开,每个栈帧的 catch 都是候选处理者:类型匹配即认领、栈展开停止;不匹配则把异常继续传给上一帧;全程无人认领就以 std::terminate 兜底——「发送处不知道谁处理、认领即停、链尾必须有明确结局」,四条语义全部命中,连「链不必显式装配」都占全:调用栈本身就是天然的处理者链。
// 语言级责任链:沿调用栈寻找认领者
try {
parse(config); // 深处 throw JsonError
} catch (const JsonError& e) {
// 本帧认领:链在此停止
return fallback(e);
}
// 不认领的帧无需写任何代码——
// 「传给上一位」由栈展开自动完成
点评:异常机制与本篇帮助链同构——都是「沿既有层级上浮、认领即停」,差别只在一条是控件树、一条是调用栈。它也提醒我们异常的代价:栈展开走过的每一帧都是「不认领却被打扰」的处理者,这正是高性能代码慎用异常的原因。
POCO:分层配置是一条查找链。Poco::Util::LayeredConfiguration 把多个配置来源按优先级叠成一条链,取值时从高到低逐层询问,先持有键的那层认领、查询停止:
// 说明性片段(需链接 PocoUtil,签名有简化)
Poco::Util::LayeredConfiguration cfg;
cfg.add(systemCfg, Poco::Util::
AbstractConfiguration::
PRIO_SYSTEM);
cfg.add(userCfg /*, PRIO_APPLICATION*/);
cfg.add(cmdlineCfg /*, PRIO_HIGHEST*/);
// 逐层询问:命令行 > 用户 > 系统,
// 先持有者胜出,链停止
std::string level =
cfg.getString("log.level", "info");
点评:配置查找是扁平责任链最温和的用武之地——每层「认领」的方式只是「我有没有这个键」。对照第 6 节缺点也一目了然:查一个不存在的键要空转整条链,所以分层配置的层数向来克制。
AOSP:触摸事件沿控件树传播。Android 的触摸事件从 InputDispatcher 进入窗口后,沿 View 树自顶向下分发(dispatchTouchEvent 父到子),子控件不消费则回传给父控件的 onTouchEvent 自己消化,任何一层返回 true 即「认领」、传播停止;全树无人消费则交还系统处理。把这段描述与本篇主例并排读——帮助沿控件树上浮找认领者,触摸沿控件树上下往返找消费者——第 12 篇的组合树提供结构,本篇的认领语义提供传递规则,正是 GoF 点名的「责任链 + 组合」在工业代码里的标准形态(Java 侧实现,结构与 native 场景图同理)。
点评:事件传播也示范了责任链的顺序敏感性——onInterceptTouchEvent 让父控件能在下发阶段拦截,本质是给「优先级」开了一个官方后门;写 UI 框架的人对「事件到底被谁吃了」的调试痛苦,全部来自第 6 节那条缺点:认领是隐式的,走过的层不留痕迹。
三份代码合看:异常机制用调用栈当链、POCO 用「有没有这个键」当认领、AOSP 用返回值当认领——责任链的全部本钱就是那一句「要么处理、要么上交」,至于链长什么样、认领怎么表达,每一代基础设施都在重新发明。
本篇小结
发送者不该背「谁会处理」的答案。责任链把多个候选处理者串起来,请求沿链传递、认领即停止:帮助系统里每个控件只声明自己的主题,无主题就上浮、Application 兜底显示目录;扁平世界里链退化为 vector 加一个诚实的 bool,插拔即下标操作。两条铁律常伴:链尾必须给「无人认领」一个明确结局,认领判断只用处理者的自有知识。与装饰器的形似至此说清:转发是义务还是选择、每层是否必执行、客户端是否知道链——装饰器包装能力、层层都跑,责任链寻找归属、认领即停,现代中间件让两种灵魂同住一根洋葱。下一篇命令模式,把「请求」从链上的接力棒变成可以存储、排队、撤销的对象——请求被谁处理之后,还能留下什么。
本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「责任链」一章,意图译文、帮助系统示例、后继引用的实现注记与「与组合模式连用」的提示参考了 GoF《Design Patterns》第 5 章 Chain of Responsibility 一节。
更多推荐



所有评论(0)