C++职责链模式变体实战:从链式返回到责任树 职责链模式这东西教科书里讲得头头是道Handler 连 Handler一个个往下传谁接得住谁处理。但我这几年的实际体会是真正项目里的“链”很少是教科书那种一个萝卜一个坑的样子。标题里的“变体”两个字特别关键因为现实场景里你会遇到“多个处理者都想插一脚”、“处理结果要一层层改写”、“中间环节要做前置和后置工作”、甚至“一条链不够还要分叉成树”的情况。这篇文章我打算把我自己整理过的几种 C 职责链变体一次讲透每种都带可直接落地的代码结构和踩坑记录。适合正在写业务框架、中间件、事件分发器或者回调管线的朋友参考。基础版本的职责链我就不再从头科普了默认你已经知道 Successor 是怎么串的我们直接聊变体。1. 先搞清楚教科书版职责链为什么“不够用”很多同学学设计模式的时候觉得职责链很简单抽象一个 Handler里面有个setNext()再有个handle()子类自己判断要不要处理不处理就调next。代码二十行就能写完。但实际接到业务需求的时候你很快会发现三个痛点是教科书没提的。1.1 教科书版本的三个局限第一个局限是“只能有一个最终处理者”。经典职责链的语义是沿着链找第一个能处理请求的对象找到了就结束。但产品经理的需求往往是“所有环节都要看到这个请求”。比如一个订单状态变更风控要记录、积分要变动、消息要推送你没法用“谁处理完就拉倒”的链来表达。第二个局限是“没法做环绕处理”。经典链里每个 Handler 只做自己的事然后把请求丢给下一个。但如果你想在链的最外层统一做耗时统计、日志埋点、异常兜底就得在链头和链尾各塞一个 Handler还容易漏。其实你真正需要的是中间件那种“洋葱圈”结构一层包一层每层都能在调用下一层之前和之后做事情而不是线性传参。第三个局限是“职责链结构写死”。经典实现里setNext()是在编译期或者初始化阶段手工串联的一旦中间某个环节需要根据运行参数动态跳过、动态替换、甚至动态插入一个过滤器这套静态链表就非常别扭。动态编排能力才是变体要解决的核心问题。1.2 现实中常见的三种变形需求我自己梳理了一下项目里高频出现的职责链其实可以归纳成三种语义链式返回变体每个节点都看一遍请求但结果会被后续节点继续改写类似多级缓存逐级命中、数据管道逐级加工。事件/广播变体请求被完整地发给所有节点每个节点独立响应互不遮蔽类似 Qt 的信号槽、游戏里的输入事件分发。中间件变体每个节点都持有对下层的调用权既能预处理也能后处理类似 Web 框架的中间件管线。除了这三种还有一种责任树变体什么时候用我会在第五节专门讲。这三种变体并不是互相排斥的很多系统里是混合用的比如中间件里嵌了一个广播器广播器里再挂一条链式返回管道。2. 链式返回变体让每个环节都有机会改写结果先讲我实际用得最多的变体。为什么最常用因为它解决的是“多级处理且结果可被层层调整”的问题尤其在读多写少的查询场景里特别合适。2.1 核心思路和接口设计这种变体的核心语义变了不再是“谁能处理谁处理”而是“每个节点都有机会处理并把处理结果交给下一个节点”。你可以理解成一条流水线每个工位都可以往工件上加工最后出厂的成品是所有工位叠加的结果。接口上我一般会这样设计template typename Request class Processor { public: virtual ~Processor() default; // 返回 std::optionalResult有值时说明本节点处理成功并产出结果 // 空 optional 表示本节点不想处理继续往下游传递 virtual std::optionalResult Process(Request req) 0; };这里有个关键点每个节点返回的optionalResult只代表“它自己是否成功产出”不代表整条链结束。真正的链式编排器会决定是拿第一个非空结果作为最终答案还是把每个非空结果都传给下游做二次加工或者等所有节点跑完后做一个聚合。我一开始设计的时候把“终止”和“产出”混在一起节点既返回结果又决定是否短路结果代码里到处是if (!result) continue;和if (shortcut) break;混在一起问题排查非常痛苦。后来把“是否继续”和“产出什么”彻底拆开接口干净很多。2.2 C 实现里容易忽略的细节先说所有权和生命周期。Request和Result如果拷贝开销很大建议用智能指针或者移动语义。我一般把Request传成非 const 左值引用让每个节点都能在原对象上修改。Result用std::optional如果结果对象本身不支持拷贝返回std::optionalResult时要注意移动构造别不小心写成拷贝。再说std::function和虚函数的取舍。如果你只有十来个固定节点虚函数完全够用代码还好调试。但如果你需要动态组合一组 lambda 作为链路节点那就得用std::function或者模板泛型。我实际项目的经验是纯虚函数接口适合节点本身有状态、生命周期复杂的情况std::function适合节点是无状态小函数、需要频繁组合的场景。两种都保留适配不同层次的需求。2.3 一个多级缓存查询的完整示例用一个多级缓存的例子来演示这类变体最直观。假设查询用户资料链路是本地缓存 → Redis 缓存 → 数据库。每层命中就返回没命中就往下走而且数据库查到结果后要回填到上层缓存里。struct UserQuery { std::string userId; }; struct UserInfo { std::string name; int level{0}; }; // 节点1本地内存缓存 class LocalCacheNode : public Processor { public: std::optionalResult Process(Request req) override { auto it cache_.find(req.userId); if (it cache_.end()) { return std::nullopt; // 未命中交给下一级 } // 命中后记录访问时间但不直接返回而是把结果标记为“已命中” req.hitLevel 1; return it-second; } private: std::unordered_mapstd::string, UserInfo cache_; }; // 节点2Redis缓存简化示意 class RedisCacheNode : public Processor { public: std::optionalResult Process(Request req) override { auto info redis_.Get(req.userId); if (!info.has_value()) { return std::nullopt; } req.hitLevel 2; local_-WarmUp(req.userId, *info); // 回填本地缓存 return info; } private: std::shared_ptrLocalCacheNode local_; }; // 节点3数据库兜底 class DbNode : public Processor { public: std::optionalResult Process(Request req) override { auto info db_.Query(req.userId); if (!info.has_value()) { return std::nullopt; // 真没有返回空 } redis_-Set(req.userId, *info); local_-WarmUp(req.userId, *info); return info; } }; // 编排器按注册顺序执行取第一个非空结果 class Pipeline { public: void Add(std::shared_ptrProcessor node) { nodes_.push_back(std::move(node)); } std::optionalResult Execute(Request req) { for (auto node : nodes_) { auto result node-Process(req); if (result.has_value()) { return result; } } return std::nullopt; } private: std::vectorstd::shared_ptrProcessor nodes_; };这个例子看起来简单但它把链式返回变体的两个核心点体现出来了一是“短路但带回填”数据库节点命中后没有直接结束它把结果回填了上层缓存二是“节点之间通过 Request 结构共享临时状态”比如hitLevel这种元信息不会污染 Result 本身。实际做的时候我建议把WarmUp这类副作用操作放到节点自身逻辑里不要放到编排器里。否则编排器会慢慢膨胀成一个容纳所有业务逻辑的“上帝类”。3. 事件广播变体从“找到处理者”到“全员可见”接下来这个变体在概念上跟经典职责链差得最远但它确实是从职责链演化来的——因为广播器里也有一条“注册顺序链”而且同样会遇到“要不要继续分发”的问题。3.1 广播和链式返回的本质区别链式返回是“加工流水线”数据流是一维的每个节点看到的是上一个节点处理后的结果。广播则是“会议通知”每个订阅者拿到的是一份原始通知副本订阅者之间互不影响。我最早做输入系统的时候把按键事件做成职责链窗口管理器先处理然后UI层处理然后游戏逻辑处理。结果发现一个问题某些全局快捷键希望“无论焦点在哪都先响应”某些 UI 弹窗又希望“拦下所有键盘事件阻止穿透”。如果只靠一条顺序链根本表达不了“所有人同时看到事件、各自决定吃不吃”的语义。3.2 基于 std::function 的轻量广播器实现广播变体的核心数据结构就是一个有序的监听器数组每个监听器都是一个可调用对象。这里要特别关注两个细节遍历时的迭代器失效问题以及监听器内部追加/移除监听器的场景。class EventBus { public: using Handler std::functionvoid(const Event); void AddHandler(Handler h) { handlers_.push_back(std::move(h)); } void Publish(const Event e) { // 快照遍历先拷一份当前列表避免回调中修改 handlers_ 导致迭代器失效 auto snapshot handlers_; for (auto handler : snapshot) { handler(e); } } private: std::vectorHandler handlers_; };快照遍历这个技巧非常实用。如果你直接遍历handlers_某次回调里又往同一条链上AddHandler轻则本次分发不包含新监听器重则迭代器失效直接崩掉。快照虽然有一定的拷贝开销但换来的是“回调可以安全地修改订阅列表”在很多交互系统里这个安全性比性能重要得多。3.3 处理“拦截”需求的两种方案广播变体里最常被问到的问题是我怎么让某个监听器拦截事件不让后面的监听器收到这等于是在广播里引入了职责链的“短路”语义。方案一在 Event 结构里加一个bool consumed字段每个监听器检查该字段决定是否继续处理。这个方案最轻量但问题在于所有监听器都得维护这个约定漏了就出 bug。方案二回调接口改成std::functionbool(const Event)返回 true 表示“我消费了停止分发”。这个方案更符合顺序链的习惯但需要业务方清楚返回值语义。我个人的建议是如果广播器只是内部工具选方案二代码意图明确。如果广播器要开放给外部业务方使用选方案一因为强迫每个回调都返回一个 bool对调用方并不友好而且很容易出现“我只是想记个日志结果忘了返回 false把整个链路截断了”的事故。4. 中间件变体一层包一层的洋葱模型这个是很多人最熟悉的变体——Web 框架里的中间件。中间件模型和其他变体最大的不同在于每个中间件不是“水平地”传给下一个而是“垂直地”把下层的执行包在自己里面。调用顺序是 A 的前置逻辑 → B 的前置逻辑 → 核心处理 → B 的后置逻辑 → A 的后置逻辑先进后出像洋葱剥开又合上。4.1 用 next 回调控制执行流的 C 实现C 里实现洋葱模型核心签名是// 中间件接收一个请求和一个 next 回调 // 先做前置处理然后决定是否调用 next() // next() 返回后做后置处理 using Middleware std::functionvoid(Request, std::functionvoid() next);这里next是一个无参回调它代表“下一个中间件的完整执行”。如果当前中间件不调用next链条就在这里终止这就是鉴权中间件拦截非法请求的原理。实际实现的时候要非常小心递归深度。洋葱模型的执行本质上是递归调用中间件数量很多或者每次请求都递归几十层虽然通常不会爆栈但在嵌入式环境或者协程栈较小的场景下是真实存在的风险。我见过一个项目里挂了 40 多个中间件在默认 8MB 栈的线程里跑没问题切到协程栈默认 128KB直接栈溢出。解决方案是接受参数调整协程栈大小或者把深递归改成显式栈的循环迭代写法但后者实现复杂度会高不少。4.2 一个带超时和日志的洋葱链示例假设要构造一个中间件链实现“日志 → 鉴权 → 业务处理”其中日志中间件还要统计耗时。class MiddlewareChain { public: void Use(Middleware mw) { middlewares_.push_back(std::move(mw)); } void Run(Request req) { // 用索引和栈上递归代替链式对象 std::functionvoid(size_t) invoke [](size_t index) { if (index middlewares_.size()) { // 所有中间件都调用完了最后的业务处理 req.businessHandler(req); return; } auto mw middlewares_[index]; mw(req, []() { invoke(index 1); }); }; invoke(0); } private: std::vectorMiddleware middlewares_; }; // 使用示例 MiddlewareChain chain; chain.Use([](Request req, auto next) { auto start std::chrono::steady_clock::now(); std::cout before handle: req.path std::endl; next(); auto elapsed std::chrono::steady_clock::now() - start; std::cout after handle, cost elapsed.count() ns std::endl; }); chain.Use([](Request req, auto next) { if (!req.authenticated) { // 不调用 next直接短路 req.statusCode 401; return; } next(); });很多人第一次写这个invoke递归的时候会懵其实核心就一句话next并不是一个普通的成员函数它是“从下一个中间件开始的整条调用链”。理解到这一层洋葱模型就算吃透了。4.3 中间件变体的典型误区第一个误区是把中间件当成普通函数列表顺序执行。普通函数列表是 A 完全结束再进 B中间件却是 A 调用 B、B 返回后再执行 A 的尾部逻辑。如果你只写A(); B(); C();日志耗时统计这种对称的前后逻辑是实现不了的。第二个误区是在中间件里捕获了this指针而不控制生命周期。很多前辈踩过这个坑中间件里调了某个服务对象的成员方法请求还没处理完对象被销毁了next()一回来就悬空。我的习惯是中间件优先捕获shared_ptr或者用 weak_ptr 在调用前 lock 一下别裸捕获 this。第三个误区是对next()里抛出的异常不做处理。中间件模型一般希望最外层统一兜底但如果你每个中间件都 try-catch 一遍就会变成“谁都不兜底到处留残局”。比较好的做法是约定一个全局异常中间件放最外层内部中间件只抛不接由最外层统一转成错误响应。5. 责任树变体当一条链变成一棵树的时候这个变体是我在一个权限解析项目里总结出来的。当时需求是这样的一个请求可能匹配多条规则规则之间既有优先级又有依赖关系比如“命中全局规则之后还要看子规则里是否命中排除项”。单条链根本表达不了“同级并列 子级递归”的层次关系于是我把职责链的每个节点改造成可以拥有子节点形成责任树。5.1 什么场景下必须用树而不是链链是线性的每个节点只有一个后继。但很多时候一个请求可能有多个候选处理分支而且这些分支是“互斥/可并行/可嵌套”的。举个例子一个表达式规则引擎每个规则可以包含子规则子规则全部通过则父规则通过其中任意子规则拒绝则父规则拒绝。这本质上是 AND/OR 树的判定逻辑用责任树表达最自然。还有一种场景是菜单权限用户访问一个菜单先判断该菜单是否有父菜单权限再判断当前菜单本身是否被显式禁止最后判断按钮级别的权限。每个节点的结论都可能影响兄弟节点和父节点这是树形回溯。5.2 用递归遍历实现责任树的判定逻辑责任树的节点接口可以设计成这样struct DecisionContext { // 请求参数、用户上下文、之前节点的结论等 }; struct DecisionResult { enum class Action { Unknown, Allow, Deny } action{Action::Unknown}; std::string reason; }; class DecisionNode { public: virtual ~DecisionNode() default; void AddChild(std::shared_ptrDecisionNode child) { children_.push_back(std::move(child)); } DecisionResult Evaluate(DecisionContext ctx) { // 先执行当前节点自身的前置策略 auto local EvaluateSelf(ctx); if (local.action DecisionResult::Action::Deny) { return local; // 直接拒绝不再看子节点 } // 再递归评估所有子节点 for (auto child : children_) { auto childResult child-Evaluate(ctx); if (childResult.action DecisionResult::Action::Deny) { return {DecisionResult::Action::Deny, childResult.reason}; } } return local; } protected: virtual DecisionResult EvaluateSelf(DecisionContext ctx) 0; private: std::vectorstd::shared_ptrDecisionNode children_; };这只是一个最简的 ALLOW/DENY 判定版本实际项目里还会遇到“子节点全通过才通过”、“任一子节点通过就通过”、“子节点权重大于父节点”等不同策略。这些策略建议做成可配置的聚合策略而不是在Evaluate里写死。5.3 责任树和动态规划的配合责任树如果节点很多而且子节点会被多个父节点共享就要小心重复计算。我遇到过一个权限系统同一棵子树被好几个父节点引用了每次请求都重复遍历整棵子树浪费明显。解决方案有两个一个是在节点上做结果缓存缓存键可以带上上下文的关键属性另一种是改用带剪枝的回溯先算所有叶子节点再向上聚合跟动态规划的自底向上思路一样。我自己更推荐后者因为权限上下文多变缓存很容易失效后还要处理失效通知复杂度不低。相比之下把树展开成“先收集叶子、再按权重聚合”的平坦列表虽然代码结构上不像是职责链了但在性能和可调试性上都很舒服。6. 常见问题与排查技巧实录这块我放在最后但可能是最值钱的。因为设计模式的文章很多真正把这些模式落地到 C 项目里踩过的坑很少有人系统地列出来。6.1 悬空引用和生命周期问题职责链最怕悬空节点持有了一个外部对象的指针或引用外部对象销毁了链还在。特别是用 lambda 当节点的时候[this]捕获太顺手了可一旦这个中间件被异步任务持有this指向的对象可能早就没了。我的排查经验是凡是节点可能被异步调用、被拷贝存储、被延长生命周期的场景一律捕获shared_ptr。如果对象不需要共享所有权用weak_ptr在进入节点时lock()一次相当于做一次生命周期检查。不要嫌lock()有开销跟空指针解引用崩溃比起来这点开销完全可以接受。6.2 顺序错乱注册顺序和执行顺序不是一回事广播变体里的快照遍历解决了容器修改的问题但没解决顺序语义的问题。很多人以为 add 的顺序就是执行顺序其实中间插入、延迟注册、移除再注册都会让顺序变得不可控。如果业务对顺序敏感我建议显式传入优先级参数而不是依赖添加顺序。链式返回变体也有类似问题某个节点把 Request 字段改了后续节点的行为随之改变这种隐式依赖最难排查。我的对策是在 Request 里放一个std::vectorstd::string trace_每个节点处理时把节点名和关键结论记上去。请求结束之后打一条日志就能看到整条链的完整路径比断点调试舒服太多。6.3 性能开销虚函数和 std::function 的真实代价一定有人问职责链这么多层函数调用性能够不够我的实测结论是如果你的链路只有十几个节点单次请求只走一条链开销完全可以忽略。虚函数调用大概是几纳秒到十几纳秒的量级std::function会比虚函数再贵一些但如果每个节点内部都有一点业务逻辑这点纯调度开销占比极小。真正的性能杀手不是函数调用本身而是std::function的堆分配。一个捕获了多个变量的 lambda 塞进std::function如果容量放不下会触发一次动态分配。在高 QPS 场景下这里的内存分配压力比函数调用还明显。缓解手段是尽量保证 lambda 捕获的对象小这样 SBO小对象优化能兜住或者直接改用自有函数指针/虚函数。6.4 责任链调试技巧可视化链路最后分享一个调试心得。不管哪种变体我都建议在链路上预留一个“追踪钩子”每个节点执行前和执行后打日志或者往一个 context 里 push 一个步骤。不要等到出了问题再加日志而是从写第一版链开始就默认打开 trace 开关。我自己常用的做法是给每个节点定义一个name()虚函数或一个名称字符串变量这样日志里能直接输出“A → B → C”的完整路径而不是只看到一堆无名的 lambda。遇到“结果不对”的问题先看 trace 里节点顺序对不对再看每个节点修改了哪些字段基本能定位到 80% 的疑难杂症。我还有一个习惯是加一个“链条自检”函数把整条链的节点名称按顺序打出来开发阶段每次改动代码后跑一遍确认没有重复节点、没有空节点、没有顺序错乱。这东西简单但真的能省事很多。