C++职责链模式实战:从if-else到优雅的审批链设计 1. 从一段臃肿的审批代码说起不用职责链模式会怎样先说个我自己的经历。前几年在做一个内部工单系统里面有个审批模块需求是这样的工单提交之后要根据金额走不同层级的审批——金额小于1000块的直接通过1000到5000的需要组长审批5000到20000需要部门经理审批超过20000还要往上走总监。当时项目比较赶我第一版就按最直白的方式写了bool ApproveService::process(const Order order) { if (order.amount 1000) { // 直接通过 order.status APPROVED; return true; } else if (order.amount 1000 order.amount 5000) { // 找组长 return teamLeader-approve(order); } else if (order.amount 5000 order.amount 20000) { // 找部门经理 return manager-approve(order); } else { // 找总监 return director-approve(order); } }写的时候觉得挺爽的逻辑清清楚楚。结果上线不到一个月需求就开始变了先是加了“紧急工单优先走总监通道”然后又说“金额超过50000的除了总监还要抄送财务”接着技术负责人说想加一个“数据合规校验节点”插在审批最前面再后来产品经理提出来要支持“自定义审批链”——运维同学可以在后台自己拖拽配置审批流程。那段时间我每次改这个函数都提心吊胆。if-else嵌套越来越深方法越来越长最后甚至出现了一个奇奇怪怪的bug因为审批人对象在某个分支里没有初始化某些金额区间的工单走不到审批这一步就抛了空指针。我那时才意识到这段代码最大的问题不是难写而是审批流程本身是一条链——请求沿着链依次经过每个审批节点直到有人处理它。而我却用一个硬编码的if-else把它拍平了。这时候就该上设计模式了。职责链模式Chain of Responsibility的定义很简单让多个对象都有机会处理请求从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链并沿着这条链传递请求直到有一个对象处理它为止。文字有点绕但说白了就一句话把那些“先后判断、依次处理”的逻辑从密不透风的if-else里拆出来变成一个个独立的处理器节点再把它们串起来。本篇我主要分享四点职责链模式的结构和C实现、落地过程中C特有的一些细节考量、几个真实的应用场景以及我在项目中踩过的坑。不管你是刚接触设计模式的新手还是写了两三年C正在整理代码结构的开发者这篇应该都能给你一些拿得走的东西。有一点先说清楚设计模式不是银弹。职责链模式如果滥用反而会让代码变得支离破碎——明明三个if能解决的事非要建五个类那是自找麻烦。所以文章最后我也会聊一聊“什么情况下不该用”。2. 设计模式不是画类图职责链的骨架到底是啥2.1 三要素Handler、ConcreteHandler、Client职责链的类图在网上随便一搜就是一大把GoF经典结构就是几个人物一个抽象的Handler若干个具体的ConcreteHandler还有一个组合这条链的Client。但类图是静态的真正要理解这个模式得看它在运行时的行为。三个角色各干各的事Handler抽象处理者定义一个处理请求的接口并且持有下一个处理节点的指针。这是整个链的“接口契约”。在C里它通常是一个含有纯虚函数的抽象基类或者是一个std::function的包装类型。ConcreteHandler具体处理者实现自己的处理逻辑。如果自己能处理就处理掉处理不了就把请求转发给下一个节点。注意“能处理”和“不能处理”的边界每个节点自己说了算这恰恰是职责链最灵活的地方。Client客户端组装链并把初始请求发给链头。客户端只跟第一个节点打交道完全不关心后面挂了几个节点。这里有个很多人容易忽略的点在职责链模式里请求的流向是“链式”的但客户端看到的只有一个入口。发送者不需要知道这单子最终是谁审批的、经过了哪些中间环节——它只负责把请求丢进链里。这就把一个复杂的多级判断逻辑变成了一个单一入口的调用复杂度被封装在链内部。2.2 为什么说“每个节点自己决定是否处理”是关键很多初学者会把职责链理解成“责任传递链”或者“管道”其实差了一点。管道的语义是每个节点都会处理数据处理后传给下一个有点像流水线。而职责链的经典语义是请求沿着链走一旦某个节点处理了链路就终止后面的节点不再执行。这两种语义在业务上差别很大。审批流就是典型的“短路”式——组长批了就不用再找经理。而日志系统则是“全链”式——debug日志既要写控制台又要写文件还要上报远程每个节点都处理一遍。所以你在实现职责链的时候先想清楚你的业务是“找到第一个能处理的人”还是“每个人都要过一遍手”。这决定了节点的编排方式和返回语义。GoF原书里说的是“直到有一个对象处理它为止”也就是短路式但实际项目中两种都很常见别把它当死规矩。2.3 一个生活化的类比食堂打饭的窗口理解职责链最直观的方式其实是类比日常生活。你去食堂打饭拿着餐盘走到第一个窗口问“有红烧肉吗”阿姨说没有指了指第二个窗口第二个窗口也说没有让你去第三个窗口第三个窗口阿姨说“有”然后给你打了一份。你客户端不需要知道红烧肉在哪个窗口你只从第一个窗口开始问没菜就顺着指的方向走直到有人给你打菜。这就是职责链——每个窗口就是链上的一个节点它要么自己处理打菜要么把请求往后传指路。类比的意义在于你能立刻感受到这个模式的两个优点调用侧极其简单只需要找第一个窗口节点之间完全独立每个窗口只需要知道“下一个窗口在哪”。3. 写一个能跑的C版本审批链的落地实现3.1 经典写法抽象基类 派生节点下面我给出一个完整的、可以编译运行的C实现。就用文章开头的审批场景但会比最初的if-else版本干净得多。#include iostream #include memory #include string #include utility // 工单请求 struct Order { int id; double amount; // 金额 bool isUrgent; // 是否紧急 }; // 1. 抽象处理者审批节点基类 class Approver { public: explicit Approver(std::string name) : name_(std::move(name)), next_(nullptr) {} virtual ~Approver() default; // 设置链上的下一个节点 void setNext(std::shared_ptrApprover next) { next_ std::move(next); } // 处理请求的入口 void handle(const Order order) { if (canApprove(order)) { approve(order); } else if (next_) { next_-handle(order); } else { std::cout [System] 无节点可处理该请求工单 order.id 被挂起 std::endl; } } protected: // 每个节点自己判断是否能处理 virtual bool canApprove(const Order order) 0; // 具体的审批动作 virtual void approve(const Order order) 0; std::string name_; std::shared_ptrApprover next_; }; // 2. 具体处理者A组长 class TeamLeader : public Approver { public: TeamLeader() : Approver(TeamLeader) {} protected: bool canApprove(const Order order) override { return order.amount 5000; } void approve(const Order order) override { std::cout [TeamLeader] 审批通过工单ID: order.id 金额: order.amount std::endl; } }; // 3. 具体处理者B部门经理 class DepartmentManager : public Approver { public: DepartmentManager() : Approver(DepartmentManager) {} protected: bool canApprove(const Order order) override { return order.amount 5000 order.amount 20000; } void approve(const Order order) override { std::cout [DepartmentManager] 审批通过工单ID: order.id 金额: order.amount std::endl; } }; // 4. 具体处理者C总监 class Director : public Approver { public: Director() : Approver(Director) {} protected: bool canApprove(const Order order) override { return order.amount 20000; } void approve(const Order order) override { std::cout [Director] 审批通过工单ID: order.id 金额: order.amount std::endl; } }; // 5. 客户端组装链并触发 int main() { auto teamLeader std::make_sharedTeamLeader(); auto manager std::make_sharedDepartmentManager(); auto director std::make_sharedDirector(); // 组装链路组长 - 经理 - 总监 teamLeader-setNext(manager); manager-setNext(director); Order order1{1001, 3000, false}; Order order2{1002, 8000, false}; Order order3{1003, 50000, false}; std::cout --- 处理工单 1001 --- std::endl; teamLeader-handle(order1); std::cout --- 处理工单 1002 --- std::endl; teamLeader-handle(order2); std::cout --- 处理工单 1003 --- std::endl; teamLeader-handle(order3); return 0; }这段代码的运行结果如下--- 处理工单 1001 --- [TeamLeader] 审批通过工单ID: 1001金额: 3000 --- 处理工单 1002 --- [DepartmentManager] 审批通过工单ID: 1002金额: 8000 --- 处理工单 1003 --- [Director] 审批通过工单ID: 1003金额: 50000注意几个细节。我把canApprove和approve拆成了两个虚函数这是个小设计点判断和处理分离以后如果你想做“记录日志后处理”或者“先校验再处理”直接重写handle或approve就行不用动判断逻辑。另外我在链尾加了一个兜底分支——没有任何节点能处理时打印挂起消息。这个兜底逻辑非常重要实际项目中请求“走完整个链都没人处理”是常态不是异常你一定要对这种情况有明确的行为定义否则就是静默吞掉请求线上排查会非常痛苦。3.2 用现代C改写std::function与lambda派发器基类派生类是教科书写法但如果你项目里只有两三种处理器不想为每个处理器单独立一个类可以用std::function来做轻量级节点。C11之后这个写法很常见代码量少很多而且特别适合“处理器本身就是几行lambda”的场景#include iostream #include functional #include memory #include vector struct Order { int id; double amount; }; using Handler std::functionbool(const Order); class Chain { public: void append(Handler h) { handlers_.push_back(std::move(h)); } void handle(const Order order) { for (auto h : handlers_) { if (h(order)) { return; // 短路某个处理器处理了请求 } } std::cout [Chain] 无处理器可处理请求被丢弃 std::endl; } private: std::vectorHandler handlers_; }; int main() { Chain chain; chain.append([](const Order order) { if (order.amount 1000) { std::cout 自动通过金额: order.amount std::endl; return true; } return false; }); chain.append([](const Order order) { if (order.amount 1000 order.amount 5000) { std::cout 组长审批金额: order.amount std::endl; return true; } return false; }); chain.append([](const Order order) { std::cout 默认处理人工介入金额: order.amount std::endl; return true; }); chain.handle(Order{1, 500}); chain.handle(Order{2, 3000}); chain.handle(Order{3, 99999}); return 0; }这里我用了std::vectorHandler代替链表结构。你可能会问这还算职责链吗算的。职责链模式的核心是“请求沿着处理节点序列依次传递直到被处理”底层用链表还是数组这是实现细节。用vector的好处是遍历快、代码直观坏处是你很难在运行时动态“插入”节点——但大多数业务并不需要真的动态插入配置链的顺序在初始化时就定死了。我个人在实际项目里的习惯是处理器逻辑简单、数量少、不太可能被外部扩展的时候用std::function版本处理器有内部状态、需要复用、逻辑复杂的时候用基类版本。不要一上来就觉得基类版本才“正宗”代码是给人读的不是给设计模式书考的。3.3 生命周期管理shared_ptr还是裸指针这是C实现职责链时一个非常具体的问题。Java、C#里你new一个对象不用太操心释放C不行。链上的节点在运行期会被多次跳转访问如果你用裸指针组装链的代码和触发链的代码在不同的作用域里稍不注意就会出现悬垂指针。我建议统一用std::shared_ptr管理节点链的“next”指针也用shared_ptr。理由很简单链节点的生命周期天然是“多段共享”的——某个节点可能同时被两条链引用比如总监同时挂在一级审批链和紧急审批链上你很难判断谁该最后释放它。shared_ptr引用计数能省掉这部分心智负担。class Approver { std::shared_ptrApprover next_; public: void setNext(std::shared_ptrApprover next) { next_ std::move(next); } };一个要注意的坑是不要用shared_ptr循环引用。如果A的next指向BB又持有A的shared_ptr那两者的引用计数永远不为零内存就泄漏了。职责链通常是单向链表原则上不会形成环但如果你在节点里顺手存了一个“上级节点”反向指针就要非常小心了。我见过一个事故有人为了让节点能“回溯”到链头每个节点都存了一个头节点的shared_ptr结果头节点析构时引用计数直接被抬到了3整条链怎么删都删不掉。如果是这种双向引用需求请用std::weak_ptr打破循环。4. 真实项目的三种应用场景日志、中间件与事件分发4.1 日志系统中的多级过滤链职责链在日志框架里几乎是标配。以我熟悉的轻量级日志组件为例一条日志从产生到落盘要经过“级别过滤器→格式转换器→输出器控制台/文件/远程”每个环节都是一个处理节点。如果按照传统if-else写你会得到一颗“如何路由日志”的逻辑树而且每加一种输出方式就要改主流程代码。用职责链改完后每个输出器是一个独立节点日志请求从链头开始走每个节点各自判断“这个级别的日志我要不要管”。比如Debug过滤器节点只放行DEBUG级别的日志自己处理不了就传给下一个。Error过滤器节点拦截ERROR级别写文件并上报监控。Remote上报节点所有ERROR以上日志都通过HTTP上报。链的好处是你想临时关掉远程上报只需要把那一个节点摘掉不用动其他代码。4.2 Web框架中的中间件管道熟悉Web开发的朋友对“中间件”这个概念应该不陌生。Express、Koa、ASP.NET Core里都有中间件管道请求进来先经过鉴权中间件再经过日志中间件、参数校验中间件、路由处理中间件……每个中间件可以选择直接返回响应短路也可以调用next()把请求传给下一个中间件。这本质上就是一个职责链模式的实践只不过它的节点是“中间件”请求是“HTTP请求”。你看模式本身不绑定任何语言和框架理解了职责链你去看那些“高大上”的中间件原理会突然觉得通透了很多。C世界里如果你写过基于Boost.Beast或Drogon的HTTP服务完全可以自己实现一个类似的中间件管道维护一个std::vectorstd::functionResponse(Request, Next)每个中间件拿到请求后决定是处理掉还是传给下一个。这种模式的扩展性极好——加一个新功能模块就是往vector里塞一个新函数老代码一行都不用改。4.3 游戏开发中的事件系统再说一个大家可能没那么熟悉但非常契合的场景游戏里的输入事件处理。一个UGUI界面或者一个3D场景里鼠标点击、键盘按下、触摸手势每个事件可能要经过UI界面、技能系统、移动系统、音效系统……每个系统都想先看一眼这个事件自己感不感兴趣感兴趣就处理不感兴趣就丢给下一个系统。用职责链实现事件从链头传入沿着系统列表依次传递第一个“消费”掉事件的系统返回true事件就不会再往后传。这跟Qt的事件过滤器、Unity的EventSystem执行顺序本质上都是一路货色。这一类需求最大的特点是处理者的集合和顺序在配置阶段才确定而且不同的场景可能需要不同的链。职责链模式把“场景级别的策略”从业务逻辑里抽离出来变成了纯配置项这就是它最大的实用价值。5. 责任链的两个关键变体短路式与全链式以及如何选择5.1 短路式Classic Chain一个节点处理完就停这是GoF原书里的定义。请求沿链传递第一个声称“我能处理”的节点负责处理处理完就返回后面的节点不再执行。审批、客服工单路由、故障定级都是这个模式。优点效率高职责边界清晰每个节点只需要关心自己关心的那一段。缺点如果业务上需要“多个节点对同一请求依次处理”短路式就做不到了——你不能指望组长批完以后经理的“查看留痕”逻辑还会自动执行。5.2 全链式Pipeline / Filter每个节点都处理一遍全链式在业界还有一个更常见的名字管道-过滤器模式Pipeline and Filter。请求经过整个管道每个节点都对它做一次加工或记录然后把加工后的结果传给下一个。日志输出、数据清洗流程、图像处理流水线都是这种模式。我见过很多人在讨论“职责链”时把这两种混为一谈然后争论“到底哪个算职责链哪个不算”。说实话这不是非此即彼的学术问题你只要在设计你的链时明确说明自己是短路还是全链并且让每个节点都按照同一个约定执行就行了。代码可读性的最大敌人不是“用了哪种模式”而是“调用者猜不到你的行为语义”。从实现上看两种模式的区别很小——短路式的节点处理完就return全链式的节点处理完继续往后传。很多框架比如ASP.NET Core中间件甚至允许一个节点“先处理一部分再决定要不要继续往下传”那是两者的混合体同样很实用。5.3 怎么选一张表看明白对比维度短路式全链式请求经过节点数从链头到第一个能处理的节点全部节点典型场景审批、路由、事件消费日志、数据清洗、过滤器节点关系竞争关系谁能处理谁上协作关系依次加工返回值语义返回是否已处理返回处理后的数据或状态性能特征平均O(n/2)可能提前退出稳定O(n)全程跑完选型建议很简单如果业务诉求是“找出那个合适的处理者”用短路式如果业务诉求是“所有人依次过一遍”用全链式。这个判断几乎是第一反应不需要纠结太多。6. 绕不开的对比职责链和装饰器别再傻傻分不清写设计模式的文章如果不提职责链和装饰器的对比总感觉缺了一块。这两个模式的结构很像——都是“对象持有下一个对象调用层层向下传递”。面试里被问到“职责链和装饰器有什么区别”的概率比你想的高得多。结构上它们确实很相似// 职责链 class Handler { std::shared_ptrHandler next_; void handle() { if (canHandle()) doHandle(); else next_-handle(); } }; // 装饰器 class Decorator { std::shared_ptrComponent wrapped_; void operation() { before(); wrapped_-operation(); // 必须调用不能跳过 after(); } };核心差别在于装饰器倾向于增强责任链倾向于分流。装饰器模式的目标是在不改变原有接口的情况下给对象动态增加新的行为。比如给一个文件流套上缓冲装饰器、加密装饰器、压缩装饰器——每个装饰器都是“在调用前后加点料”但最终目的还是完成同一条操作链调用链是不能断的。职责链的目标则是避免请求发送者和接收者的耦合。链上的节点有“天生的惰性”——它可以选择拒绝处理把请求往后传也可以选择处理掉让链条终止。节点之间的“责任边界”是动态划分的并不是每个人都必须对请求动一刀。一句话总结我自己的判断方式如果每个节点都会执行自己的逻辑并且大概率会调用下一个节点的核心操作那是装饰器如果每个节点都可能“撒手不管”或者“截胡处理”那是职责链。那什么情况下两者可以配合使用有。比如HTTP中间件管道每个中间件可能先做点前置处理装饰器味道然后决定是否短路返回职责链味道。实际框架里两者边界没那么泾渭分明但理解原型的差别能帮助你做设计决策时头脑更清醒。7. 实战暗坑我在职责链上踩过的四个问题7.1 链的构建顺序和直觉正好相反很多新手第一次写职责链时会写出这样的代码teamLeader-setNext(manager); manager-setNext(director);这个顺序看起来是“从链头开始依次往后连”很自然。但如果你以后要动态构建链——比如从配置文件读取节点列表再构建——你会发现递归地倒着构建往往更省事// 从后往前构建最后一个节点的next为nullptr std::shared_ptrApprover build(const std::vectorNodeConfig configs) { std::shared_ptrApprover head nullptr; for (auto it configs.rbegin(); it ! configs.rend(); it) { auto node createNode(*it); node-setNext(head); head node; } return head; }倒序构建的核心优势是你不需要维护“当前链尾”的指针循环结束后的head自然就是链头。这个写法我之前多次遇到过一开始总是用正序tail指针代码多好几行后来改成倒序才觉得顺手。7.2 性能陷阱链过长时的递归栈深经典的职责链实现用的是递归/虚函数调用handle里判断不行就调next_-handle()。如果链比较长比如有几十个节点每一次请求都会递归压栈极端情况下可能栈溢出。场景举例某规则引擎把几十条业务规则串成了一条链每个请求进来都要从头跑到尾高峰期QPS一高监控里频繁看到stack-overflow。解决思路有几个改用循环遍历就像我前面std::function版本做的for循环不递归就不会爆栈如果必须保留节点类结构可以在handle里用while循环显式调用而不是if (next) next-handle(order)void handle(const Order order) { Approver* current this; while (current) { if (current-canApprove(order)) { current-approve(order); return; } current current-next_.get(); } // 兜底 }这个写法既保留了链结构又避免了递归调用的栈开销。7.3 链中某一个节点抛异常请求断在哪里我在实际项目里踩过的最深的一个坑是职责链中间节点抛异常后整个请求状态变得不可预知。比如审批链中“组长审批成功并写库”然后传给“经理审批”时经理的远程接口超时抛异常——此时工单已经处于“一半被处理”的状态上层捕获到异常后重试又会出现“组长重复审批”。这个问题不是职责链模式本身能解决的它需要你在设计链节点时明确“异常边界”每个节点的处理动作尽量保证原子性——要么完全处理成功要么完全失败节点之间的状态传递最好使用不可变对象避免一个节点改了请求字段后面节点抛异常之后数据回不滚如果确实需要跨节点事务请引入补偿机制或把链的应用范围缩小不要试图让职责链去解决分布式事务的问题。职责链擅长的是“路由和分发”不是“事务保障”。硬把事务性要求塞进职责链里代码会变得非常拧巴。7.4 “默认兜底节点”不是可有可无文章前面我写过链尾的兜底分支这里想再强调一下它的重要性。一个没有兜底逻辑的职责链请求到了链尾没人处理顶多是不执行任何代码就返回——表面上看起来“没出问题”实际上业务上往往已经被悄然丢弃了。上线后如果有人抱怨“有些单子莫名其妙消失了”大概率就是链尾没有兜底。我现在的习惯是每一条职责链必须有一个终结节点要么是日志打印要么是默认处理器要么是异常抛出——反正不能什么都不做。甚至对于“什么都不做也是合理行为”的场景也要写一行显式的日志“请求xxx未被任何节点处理”这样才能保证排查问题时有迹可循。8. 面试怎么聊几个高频考察点与学习建议职责链模式是C面试中设计模式板块的高频话题而且面试官经常把它往实战上引。整理几个我常见的考察方式其一手写或口述职责链的核心结构。这时候不需要写完整的业务代码把抽象基类、next指针、setNext、handle这几个关键点讲清楚就够了。最好顺带提一句你对生命周期管理的方案比如shared_ptr这能让面试官觉得你不只是背了书。其二聊聊职责链和if-else的取舍。这是个引战题。我的看法是固定三五个分支、基本不会变的需求用if-else完全没问题一旦分支的增删变得频繁、或者分支顺序需要在运行时调整、或者多个业务方各自维护自己的处理逻辑时if-else就会失控这时候才值得引入职责链。面试官问这个问题的目的往往不是考你“会不会用模式”而是考你“会不会滥用模式”。其三问“如何测试一条职责链”。这个问题很多人答不好。职责链的测试重点是“链路行为”而不是单个节点行为。单测每个节点当然要做但更重要的是集成测试——构造几个典型的请求验证它们分别停在了哪个节点、走了哪些路径。如果链支持动态配置还要验证配置错误比如节点不存在、形成环时系统的表现。我在实际项目中会给每个链配置一个“全量路径测试用例表”把每种典型请求的预期路径列出来回归时直接跑一遍。学习建议方面我自己的路径是这样的先看GoF原书职责链那一章但别急着写代码然后找自己正在维护的项目里有没有“一串if-else判断”的代码尝试用职责链重构一个对比重构前后的可读性和可维护性最后把链的变体短路、全链、混合在自己的demo里各实现一遍这样面试问起来才真有底气。9. 最后分享一个我自己的习惯先写“链的路径表”再写节点类写了很多次职责链之后我养成了一个习惯分享给大家。动工之前不急着写代码先在注释或文档里把这条链的“路径表”列出来。所谓路径表就是回答这么几个问题请求从哪来可能经过哪些节点哪些节点会终止链哪些节点只看看不处理链尾的兜底策略是什么比如审批链的路径表请求特征首节点可能路径终止节点金额1000组长组长组长1000金额5000组长组长组长→经理经理5000金额20000组长组长→经理组长→经理→总监总监紧急工单组长组长→总监总监金额50000组长组长→经理→总监→财务抄送总监这张表的作用很大。它强迫你在写具体节点之前把整条链的行为语义完整地想一遍——至少能发现几个边界case。而且这张表本身就是很好的代码注释几个月后你回来看这段代码打开表就明白当初为什么这么设计了。代码会骗人需求文档会过期但路径表描述的是系统运行时的真实动态几乎不过时。职责链模式本质上是个“组织行为”模式它跟装饰器、策略模式相比并不炫技却是我在实际工程中用得最顺手、重构收益最明显的模式之一。希望这篇文章能帮你把这条链从书里搬到代码里。