资讯详情

C++职责链模式实战:从if-else困局到可插拔处理链

📅 2026/10/13 21:20:46 | 华诺云谱 👁 阅读
C++职责链模式实战:从if-else困局到可插拔处理链
你可能在代码里见过这种场面一个外部请求进来先要校验登录态再看有没有权限接着做参数检查还要跑一遍限流。传统写法是层层叠叠的 if 嵌套或者在一个大函数里从第一行判断到第一百行。有些同学会用拆函数的办法缓解但函数之间依然是显式调用关系改一处牵一发动全身。我接过这种代码时最深的感受是比起“怎么改”更难的是“哪里该改、哪里不该改”。后来在一个实际项目里用 C 实现了职责链模式把“谁来处理这个请求”这件事彻底解耦才真正体会到这个模式对复杂业务流的价值。这篇聊的是 C 环境下的职责链模式Chain of Responsibility从最朴素的 if-else 困境讲起到接口怎么设计、节点怎么串、要避开哪些坑最后用一个日志链路和一个审批链路把整个流程跑通。适合正在做中后台服务、网关、工作流引擎的同学阅读也适合想认真搞懂“职责链到底怎么落地”的 C 开发。看完可以直接照着写不用翻着设计模式那本书去猜那些 UML 箭头到底怎么翻译成代码。1. 从 if-else 堆砌到职责链先看懂问题1.1 那串能把人劝退的 if 判断假设现在要做一个请求校验模块早期代码通常长这样bool processRequest(Request req) { if (!checkLogin(req.uid)) { return false; } if (!checkPermission(req.uid, req.apiName)) { return false; } if (req.body.size() 1024 * 1024) { return false; } if (rateLimiter.exceed(req.uid)) { return false; } handleBusiness(req); return true; }这段代码在刚写完的一两周里看着还行逻辑直白一屏能装下。可一旦业务跑上三个月需求开始变了要加一个“敏感字段脱敏”要把“限流”从第三位挪到第一位要允许某个内部系统绕过权限检查还有可能要临时开启某种灰度开关。于是你会发现每次改动都发生在这个函数里代码评审时最容易起争执的也是这个函数合并分支时冲突最集中的还是这个函数。问题本质不在于“函数太长”而在于请求发起方直接依赖了每一个具体处理环节。每加一种处理逻辑都要改调用方处理顺序也是写死的想调整排列顺序就得动这段代码。职责链模式做的事情很朴素把所有判断和处理的步骤拆成独立的节点再把节点串成一条链请求从链头进入每个节点觉得自己能处理就处理处理不了就向后传传到最后没人管再走兜底逻辑。1.2 职责链的三个核心角色职责链的实现形态可能千差万别但角色始终是固定的三个抽象处理者Handler定义处理请求的方法持有指向下一个处理者的引用。C 里通常体现为纯虚基类。具体处理者ConcreteHandler继承抽象处理者在handle方法里判断自己能否处理能处理就处理不能处理就把请求交给下一个。请求对象Request在链上流动的数据载体可以是结构体、类对象也可以是指针甚至std::any。理解起来可以打个比方客服热线。用户电话进来语音导航先判断你要不要转人工判断不了就继续等转接后第一线坐席能解决就直接解决解决不了就转给技术专席技术专席判断不了再转给其他技能组。每个坐席只处理自己职责范围内的请求处理不了就往后丢。请求发起人用户完全不需要提前知道“今天哪几个技能组在岗”也不需要知道“该找张三还是李四”。放到业务代码里这种解耦的直接好处是新增一个处理节点只需要新写一个 Handler 子类然后在链上插队调用方代码一行都不用改。删除或调整某个节点同样只是链装配处的事。1.3 中断型与透传型的语义差异职责链在具体实践中会分化出两种非常不同的行为语义很多人写挂了就是因为没在一开始就厘清这两种语义。第一种叫“中断型”也叫短路型。请求沿着链往下走某个节点认为自己能处理返回一个“已处理”信号整个链就停下来不再往后面的节点传。用途最典型的例子是拦截器参数校验没过直接返回错误后面根本不需要继续审批链里某个级别的领导批准了请求流程就算走完。核心设计点在于“处理”意味着“终止”。第二种叫“透传型”。请求会被链上所有节点依次过一遍每个节点只处理自己关心那部分处理完仍然把请求交给下一个。最典型的例子是日志链Info 处理器只输出 Info 级别日志Error 处理器只输出 Error 级别日志同一条日志记录会把整条链走完。也可能每个节点都在做“增强”先脱敏再压缩再异步写入信号像流水线一样流经每个节点。这俩语义从接口上怎么区分中断型要求handle返回“是否已经终止”透传型则可以不关心返回值。很多教程只说“请求沿着链传递”却不说清这个差异你在实际写代码时就会困惑为什么我的链传过了还想再传为什么我想让某个节点拦一下却拦不住所以我建议动手前先想清楚你的节点是“接力赛”还是“流水线”。2. 动手前必做的三个接口设计决策2.1 handle 应该返回 bool 还是 void职责链的抽象基类最常见的设计有三个变体返回booltrue表示“我已处理链终止”false表示“我不处理继续向后传”。这是中断型的标配。返回void所有节点都会被调用适合透传型不关心是否被某个节点“吃掉”。返回std::optionalResult节点可以表达三种状态“没处理”“处理成功”“处理失败且抛出结果对象”。这种最灵活但代码量也要多一些。我个人强烈建议默认用bool。理由很简单C 团队合作时true/false是沟通成本最低的返回形式。如果一个接口返回void碰到需要升级成拦截语义的时候要动所有子类如果返回bool就算现在用不上中断以后需要也能平滑升级。我见过不少项目一开始用void后来业务复杂了链上必须做“拦截”结果把基类签名全部改成返回bool虽然改动不大但总归是折腾。与其后面后悔不如一开始就定义成bool。写抽象基类时我还会把“是否处理”的判断逻辑和“继续传递”的结构逻辑分开所以会加一个doHandle方法作为子类入口而handle本身只负责调度class Handler { public: virtual ~Handler() default; Handler setNext(std::shared_ptrHandler next) { m_next std::move(next); return *this; } bool handle(const Request req) { if (doHandle(req)) { return true; } if (m_next) { return m_next-handle(req); } return false; } protected: virtual bool doHandle(const Request req) 0; private: std::shared_ptrHandler m_next; };这种写法的好处是子类只需要关心doHandle返回什么不需要手写“我要不要调用 next”避免每个子类里重复出现“调下一个节点”的模板代码。后续 if 想调整“先判断 next 还是先执行 doHandle”的细节也不会影响子类。2.2 节点的 next 用裸指针还是智能指针在 C 里职责链节点之间为什么要“记住下一个节点”这是整个模式落地时最容易被忽略的问题。选项有三个裸指针、unique_ptr、shared_ptr。裸指针最轻量链本身由外部容器或者全局变量管理节点只持有不拥有。这种模式适合“链极其稳定、生命周期完全可控”的场景。问题是只要链的某个环节因为业务逻辑被提前释放请求还在链上跑就会踩到悬垂指针排查起来相当费劲。unique_ptr则让链的拥有关系变得清晰链头独占整条链所有节点都是链头的下属链被释放时整条链自动回收。这种设计省掉了手动 delete也不需要考虑节点被意外释放的问题。代价是一个节点只能属于一条链无法同时在两条链里复用。如果你有一个审批处理器想同时挂给“普通报销链”和“大额采购链”用unique_ptr就做不到了因为一个对象只有一个 owner。shared_ptr才是复用场景的最优解。节点可以同时被多条链引用引用计数自动管理生命周期。但共享所有权会带来循环引用的风险后面专门讲坑的时候我会展开。以我的经验在大多数业务系统里优先用shared_ptr因为需求变化实在太频繁了今天可能还是一条链明天就要拆成几条链路共用某个公共节点用shared_ptr会省很多事。2.3 链怎么装配最顺手责任链的装配常见有三种姿势。第一种是方法链method chaining。节点对外提供setNext并且返回自身引用这样就能连着写auto chainHead std::make_sharedLoginHandler(); chainHead-setNext(std::make_sharedPermissionHandler()) -setNext(std::make_sharedParamCheckHandler());这种写法很直观链的顺序一眼能看出来适合节点不多、很少动态调整的场景。缺点是在链比较长的时候容易不小心让某个中间节点的setNext覆盖了自己原本的后继。第二种是构造时传入整条节点列表。我比较喜欢这种方式因为链的最终结构集中在一个函数里后续要改顺序、删节点只需要动这个列表std::vectorstd::shared_ptrHandler nodes { std::make_sharedLoginHandler(), std::make_sharedPermissionHandler(), std::make_sharedParamCheckHandler(), }; auto chain buildChain(nodes);buildChain的内部实现很简单依序遍历列表把第 i 个节点的下一个设成第 i1 个节点返回第一个节点作为链头。用这种方式的维护体验是最好的尤其是测试时想用 fake 节点替换真实节点只需替换列表元素。第三种是工厂方法封装。把“构建整条链”的细节放进一个工厂函数调用方拿到的是链头但不知道也不关心链内部长了什么样。这种方式适合团队里沉淀通用链路避免每个调用方都重复写装配逻辑。3. 实战日志过滤链透传型光讲概念没有用下面直接写一段能编译、能跑的 C 代码。我要实现一条日志链路日志记录进来之后控制台输出组件、文件输出组件、统计上报组件都各自过一遍互不干扰。3.1 定义日志请求与抽象基类先定义日志记录的结构体struct LogRecord { enum class Level { Debug, Info, Warning, Error, Fatal }; Level level; std::string logger; std::string message; int64_t timestamp_ms; };接着定义抽象处理者。刚才我已经写了基础版本这里因为明确知道是透传型handle返回void也可以但为了将来可能升级成中断型我还是保守一点用bool。不过如果你确定是纯流水线返回值确实可以省略。下面的版本保留booldoHandle之后强制继续传class LogHandler { public: virtual ~LogHandler() default; LogHandler setNext(std::shared_ptrLogHandler next) { m_next std::move(next); return *this; } void handle(const LogRecord rec) { doHandle(rec); if (m_next) { m_next-handle(rec); } } protected: virtual void doHandle(const LogRecord rec) 0; private: std::shared_ptrLogHandler m_next; };注意这里handle返回void因为日志链路确实不需要中断。每个节点输出完日志之后必须把同一条日志继续往后传。3.2 三个处理节点的实现第一个是控制台输出节点它只关心 Warning 及以上的日志class ConsoleSink : public LogHandler { protected: void doHandle(const LogRecord rec) override { if (rec.level LogRecord::Level::Warning) { std::cout [console] rec.message std::endl; } } };第二个是文件输出节点这里简化成只打印一条标记class FileSink : public LogHandler { protected: void doHandle(const LogRecord rec) override { std::cout [file] rec.logger : rec.message std::endl; } };第三个是指标上报节点它把日志级别映射成某种计数方便排障时看错误量class MetricsSink : public LogHandler { protected: void doHandle(const LogRecord rec) override { std::cout [metrics] level static_castint(rec.level) std::endl; } };这三个节点彼此完全不知道对方的存在也不知道自己前面和后面挂的是谁它们只知道自己要做什么。这就是职责链带来的直观好处新加一种输出方式只需要新增一个LogHandler子类然后到装配处插入即可。3.3 链的组装、运行与扩展主函数里组装并调用int main() { auto chainHead std::make_sharedConsoleSink(); chainHead-setNext(std::make_sharedFileSink()) -setNext(std::make_sharedMetricsSink()); LogRecord rec{ LogRecord::Level::Error, auth_server, user token expired, 1710000000000 }; chainHead-handle(rec); return 0; }运行结果大约是这样三个节点都会输出自己的处理结果[console] user token expired [file] auth_server: user token expired [metrics] level2这个例子已经足够展示透传型职责链的核心逻辑。实际项目里可能还要加一个节点去“脱敏”比如把token字段替换成掩码再加一个节点去做“采样”比如 10% 的日志才上报。都不需要改动已有节点只需往链上插。有一点值得提醒透传型链路的顺序是有意义的。如果先做采样再做指标上报和先上报再采样结果完全不同。所以不要把顺序隐藏在业务代码里最好把“组装链”集中在一个函数中并用注释写明每个节点的职责。我在项目里甚至会在装配函数里画一个简短的顺序注释这样后续排障能很快定位到“这个链到底哪里开始不对”。4. 实战审批链短路型日志链是不关心“谁最终接管”的流水线而到了审批、校验这类场景行为就变成“找到第一个能处理的节点就停”。下面我用一个费用审批链来做完整示范。4.1 短路语义怎么落到代码上跟日志链最大的不同是审批链里如果当前节点已经做出了“批准”或“拒绝”决定后面的节点就别再看了。所以handle必须返回booltrue表示“这个节点已经处理了请求链可以终止”。写一个独立的审批链抽象class ExpenseHandler { public: virtual ~ExpenseHandler() default; ExpenseHandler setNext(std::shared_ptrExpenseHandler next) { m_next std::move(next); return *this; } bool handle(const ExpenseRequest req) { if (doApprove(req)) { return true; } if (m_next) { return m_next-handle(req); } return false; } protected: virtual bool doApprove(const ExpenseRequest req) 0; private: std::shared_ptrExpenseHandler m_next; };这里doApprove返回true表示当前节点批准了申请链就停下来返回false表示当前节点不处理继续向后传递。跟日志链的结构逻辑非常像只是返回值语义从“已处理”变成了“已批准”。4.2 组装一条金额审批链假设业务规则是金额 1000 元以内由组长批5000 元以内由经理批20000 元以内由总监批超过 20000 元则整个流程视为不通过。定义请求结构体struct ExpenseRequest { int64_t amount; std::string applicant; std::string reason; };实现三个审批节点class TeamLeader : public ExpenseHandler { protected: bool doApprove(const ExpenseRequest req) override { if (req.amount 1000) { std::cout TeamLeader approved: req.amount std::endl; return true; } return false; } }; class Manager : public ExpenseHandler { protected: bool doApprove(const ExpenseRequest req) override { if (req.amount 5000) { std::cout Manager approved: req.amount std::endl; return true; } return false; } }; class Director : public ExpenseHandler { protected: bool doApprove(const ExpenseRequest req) override { if (req.amount 20000) { std::cout Director approved: req.amount std::endl; return true; } return false; } };每个节点只关心自己的额度区间完全不知道也不会问“上面还有谁”。组装链的时候由外部把顺序串好auto chainHead std::make_sharedTeamLeader(); chainHead-setNext(std::make_sharedManager()) -setNext(std::make_sharedDirector());调用时传一个金额为 12000 的申请它会从组长开始找组长认为超出范围传给经理经理认为超出范围传给总监总监在额度内批准并终止ExpenseRequest req{12000, alice, team building}; bool approved chainHead-handle(req);因为 12000 落在总监审批范围内handle返回true链最终在第三个节点停了下来。如果后续想加一个“CEO 审批 100000 元以上”的节点只需要新写一个CEOHandler然后把它挂到链尾整体逻辑完全不用动。4.3 当 handle 返回 false 时的兜底处理上面这个例子还有一个明显的边缘问题如果金额超过 20000所有节点都返回了false链走到头也没有人接handle最终返回false。这时候谁来负责我见过很多初版代码在这个位置直接忽略返回值结果就是超限审批单竟然被“静默打回”没有任何日志没有任何提示面审的人查半天都查不到原因。正确的做法是要么在链尾挂一个默认兜底节点要么由调用方显式处理false的情况。比如从链头拿到的handle返回false时就走一条“拒绝”流程bool approved chainHead-handle(req); if (!approved) { std::cout Rejected: amount exceeds director limit std::endl; }更规范一点是在链尾增加RejectHandler它不判断任何条件直接返回true并把状态置为“拒绝”。这样链上就永远不会出现“无人处理”的空档后续审计也好办每次请求都能定位到是哪个节点最终接管的。我个人比较推荐链尾放兜底节点因为职责链的核心精神就是“每个请求都要有一个最终结果”。5. 常见坑与排查经验职责链模式写起来很快但真要上线跑稳有几个坑必须提前排掉。这些坑我在不同项目里都踩过有些是通过 review 抓出来的有些是线上告警给抬出来的写在这里希望你能跳过去。5.1 缺失虚析构引发的未定义行为C 里只要涉及多态就绕不开虚析构。职责链的抽象基类如果没有virtual ~Handler() default;当你用一个指向基类的shared_ptrHandler删除派生类对象时标准里是不保证正确调用派生类析构函数的后果可能是内存泄漏、资源句柄没有释放甚至更隐蔽的未定义行为。你可能会说我用的是shared_ptr释放逻辑不是已经编译好了吗其实不是。shared_ptr在构造时就已经记住了删除器但如果基类没有虚析构那么删除器最终调用delete时到底执行哪个析构函数取决于对象的静态类型还是动态类型。为了安全抽象基类必须带上虚析构这是职责链代码的第一行基本功。我的习惯是每个 Handler 基类定义里都显式写上前面的虚析构声明不用等编译器默认生成。5.2 shared_ptr 的循环引用shared_ptr让节点可以复用但也埋下了循环引用的地雷。假设节点 A 因业务需要持有一个指向链头或者某个前置节点的shared_ptr同时链上又有前置节点指向 A 的shared_ptr这样就形成了一个环A 等前置释放前置等 A 释放谁都不肯先撒手引用计数永远降不到零内存就泄漏了。实际场景里很容易出现的是“回调型节点”。比如某个校验节点处理完请求之后需要调用链头去重置缓存这时候它顺手把链头存成了一个shared_ptr环就产生了。正确做法是反向引用一律用weak_ptr需要访问的时候临时lock()一把用完就释放引用。或者从根本上重新设计不要存“链头”而是把需要回调的逻辑通过函数对象传入避免直接持有链上节点。排查这种问题最简单的方式是把链在任何可能存反引用的地方打上日志再看运行后节点的引用计数是否归零。如果发现自己无法正常析构优先检查是不是有哪个节点内部存了往回指的shared_ptr。5.3 递归调用太深与栈风险职责链最常见的实现方式是handle内部递归调用下一个节点的handle。这在链长约几十层以内都没有问题但假如业务中允许链动态增加节点或者某个节点里处理完后还要再进入另一条链递归深度就可能叠加最终把线程栈打爆。我建议在链的装配处加一个“最大节点数”约束比如超过 100 个节点直接返回错误。更彻底的办法是把递归改成显式循环用一个std::vector记录已经遍历过的节点顺序在循环里依次调用。不过这样会让代码不如递归版简洁除非你确定节点数会非常夸张否则我不建议一开始就为“假想的长链”做优化。比较稳妥的原则是链上的处理器数量保持在 10 个以内既好维护也不怕栈深度问题。5.4 异常处理与共享状态的访问约定职责链的节点通常都要读取同一个请求对象如果某个节点修改了这个对象后面的节点看到的就不再是初始数据。这本身没问题因为有些场景就是为了让链路逐步加工数据但你必须明确约定链上的节点是“只读”还是“可变”。我见过一个团队在一条请求校验链里前面节点把某个字段先从字符串解析成了 int后面节点没想到类型已经变了直接崩在了解析逻辑上。异常也是一个高频事故点。某个节点的doHandle抛出异常默认情况下会直接打断整条链后面的节点不会再执行。如果你希望链具备“容错继续”的能力必须在handle里捕获异常并记录日志然后决定是继续传给下一个节点还是向外抛出。我最常用的设计是节点内部分两层业务处理层负责真正的处理外层统一 catch把异常信息转换成一种可上报的结果继续往后传。这样链不至于因为一个节点的故障而整体瘫痪监控上也能看到每个节点的异常率。5.5 别为了“设计感”强行上模式最后这个坑比较微妙但非常重要。职责链虽然是经典设计模式但它不是银弹。如果当前只有两三个判断步骤互相之间没有太多变化的可能直接用 if-else 或者一个简单的循环列表可能更合适。硬套职责链的代价是多了一层虚函数调用多出好几个类还要维护装配顺序代码跳转变得不那么直接。我见过有人把两个固定顺序的检查也硬做成了链结果团队里新来的同事看代码看了半天最后说“这不就是两次 if 吗”。模式是为了给复杂问题提供结构不是为了在简历上多写一行这一点真的要想清楚。6. 什么时候该用什么时候要绕开6.1 适合职责链的典型场景职责链模式最典型的用武之地是处理器集合大、先后顺序动态变化、未来还有可能持续新增处理器的场景。举个例子网关中间件。一个外部请求进来可能要经过鉴权、限流、参数校验、数据脱敏、接口路由每个环节的配置可能还要按用户、按环境动态调整。这种场景下把每个环节封装成独立节点链路顺序由配置文件决定是最优雅的方案。同理还有事件处理流水线、审批工作流、消息过滤器甚至测试框架里那些前置处理钩子都可以用职责链来组织。判断标准其实很简单如果你遇到的问题需要“写一堆 if 判断该交给谁处理”并且你可能每隔几天就会新增一种处理方式那用职责链就大概率是划算的。6.2 慎用或不适用的场景有一类场景我不太建议硬套职责链性能极其敏感的热点路径。比如每秒钟被调用几百万次的底噪打点、极其底层的 I/O 路径这时候虚函数调用和递归带来的开销不是理论讨论里“可以忽略不计”的而是实实在在的 CPU 损失。更好的选择是 switch-case 或者一张函数指针表让调用直接命中目标逻辑。另一种不建议用的场景是节点之间存在强耦合。比如 B 节点必须拿到 A 节点的中间状态才能继续C 节点又必须和 B 节点共同修改一套全局缓存这种时候强行把 A、B、C 做成链反而要让它们之间互相传参设计上绕来绕去。职责链强调整体解耦如果你发现链上的节点彼此需要知道对方的存在说明你的场景可能更适合策略模式或者模板方法。6.3 与装饰器模式的临时选择题有人会问装饰器模式和职责链长得很像不都是“一个对象包着一个对象调用往后传”吗但两者的意图完全不同。装饰器的核心是“增强”每个层都在原有对象能力上叠加新行为目标是组合出同一个对象的多个能力比如给文件流加上压缩、加密、缓冲。职责链的核心是“移交”请求由第一个有能力处理的节点接管目标是解耦发送者和处理者之间的关系。做选择的时候可以用一句最朴素的话来判别如果你的消息必须经过所有节点且每个节点都在为最终结果做贡献那它更接近装饰器如果你要把请求交给“第一个能处理它的节点”那就是标准职责链。搞清楚了这一点代码结构才不会做拧巴。我在实际项目中体会最深的是职责链模式真正的价值不是“省代码”而是把“判断”和“处理”的边界切清楚让每段逻辑能在独立节点里被测试、被替换、被复用。它带来的初期复杂度不算很低但一旦业务中“谁来处理”这件事变得多变它省下的维护成本远超当初搭建链的那点功夫。如果读完这篇你也想动手实验建议先挑一个你现在代码里最乱的 if 判断堆把它改造成链实测一下“加一个新节点”到底要动多少行代码那种直观的对比比读十遍理论都有说服力。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑