【设计模式精讲】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 的现实类比同样经典:拨打技术支持热线,一级支持解决不了就升级给二级、最后到工程师——「升级」就是「上浮」。

三条定性:

  1. 发送者只认链头(在帮助系统里甚至只认「焦点控件」),谁在链上、有几个,它一概不知;
  2. 每个处理者只认识「下一位」(GoF 的实现注记:后继引用可以放在处理者基类共享——本例的父级指针,也可以由具体处理者各自持有,后者能精确控制谁是谁的下家);
  3. 转发是选择不是义务——「没有主题」才上浮,认领了就停。与之配套的工程纪律:链尾必须兜底(明确处理或明确拒绝),否则请求会静默消失,这是责任链最著名的坑——帮助系统的兜底就是那行「显示帮助目录」。

GoF 还点出一个常见组合:责任链常与组合模式(第 12 篇)连用——链不必是线性的,也可以沿一棵树传播(帮助上浮、事件下发都是沿控件树),组合提供树,责任链提供「传递与认领」的语义。第 7 节 AOSP 的例子正是这个形态。

3. UML 图 + 结构说明

父级(可空)

«abstract»

HelpHandler

-parent_ HelpHandler

-topic_ Topic

+handleHelp() : void

Button

+handleHelp() : void

Dialog

+handleHelp() : void

Application

+handleHelp() : void

三个参与者:

  • 处理者(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 篇)——代码形似的三问。两段代码摆在一起确实像:同接口、持一个同接口、收到请求先做自己的事再转发。三问分开它们:

  1. 转发是义务还是选择? 装饰器的转发是义务——它存在的意义就是包住别人的处理并叠加增强,不转发等于欺骗等待结果的客户端;责任链的转发是选择——「处理即停止」,转发只发生在「不归我管」时,这条线写在 GoF 意图的 until an object handles it 里。
  2. 链上的每一层都会执行吗? 装饰器每层必然执行,增强本身就是目的;责任链的后继层可能永远不知道某个请求来过——ok.handleHelp() 那一次,Application 根本没被碰到。所以装饰器的层序是包裹顺序(调用与返回都穿过每层),责任链的层序是优先级(先到先得,单向流动)。
  3. 客户端知道链吗? 装饰器的客户端要的正是「层层增强后的对象」,装配是使用的一部分;责任链的客户端只管把请求递给链头(焦点控件、链头过滤器),谁处理、处理到第几层,它不知道也不该知道。

一句话:装饰器包装「能力」,责任链寻找「归属」;分类学也印证——装饰器是结构型(关心怎么组装),责任链是行为型(关心谁负责),这是同一形状在不同意图下的两次使用,也是「模式 = 意图 + 结构,而不只是结构」的最好例证。两者的合体即第 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 一节。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐