尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++职责链模式实战:从500行if-else到优雅的Handler链式处理

C++职责链模式实战:从500行if-else到优雅的Handler链式处理 先说一个我最近在 C 服务端项目里遇到的真实情况一条业务消息进入核心逻辑之前要依次过白名单、频率限制、权限校验、业务开关四道关卡。最开始我用一个大函数把所有检查一股脑写进去每加一道规则就去改那个函数。几个月后它膨胀到接近 500 行光看函数名已经猜不到里面到底做了几件事。直到我把这块重构成职责链模式代码量并没有明显减少但“每加一条规则只需新建一个 Handler 类然后往链上挂”这件事让整个团队都舒服了很多。职责链模式Chain of Responsibility在 C 里不算花哨但它非常契合“请求需要被多个候选处理者依次尝试运行期才知道谁会真正接手”的场景。如果你也在写请求处理、消息网关、数据校验、事件分发这类代码或者正在准备 C 面试这篇文章值得看完。第一个 Handler 不处理就交给下一个下一个也不处理就再往后传请求像走一条流水线那样往下游流动。这篇文章不会只贴一个教材例子我会把裸指针和智能指针的选择、链式组装和容器派发的取舍、多线程和异常安全以及一些我踩过的坑一并讲清楚。1. 被 500 行 if-else 逼疯之后我决定重新理解职责链1.1 一个消息接入业务里if 是怎么越堆越难看的很多 C 后端服务都有这样的入口void OnMessage(const Message msg)。一开始逻辑很简单校验一下消息长度和来源 IP 就放行。后来增加用户 token 校验再后来增加频控、风控、灰度开关。最常见的写法是往函数里继续加 if 卫语句void OnMessage(const Message msg) { if (msg.IsFromBlackList()) { return; } if (!msg.VerifyToken()) { return; } if (!RateLimiter::Allow(msg)) { return; } if (!FeatureGate::IsOpen(new_logic)) { return; } // 真正的业务处理 ProcessCore(msg); }这段代码单看每一步都很合理可一旦规则增加到十个以上问题就来了每加一个规则都要打开这个核心函数测试用例散落在各个规则里很难单独构造最要命的是一旦某个规则内部需要调整顺序牵一发动全身。你会发现这个函数成了整个项目里“所有人都不敢动但所有人都在加东西”的代码。职责链模式解决的核心问题就是把这种“层层判断”从调用方剥离出来让每个规则自己决定要不要处理以及处理完了传给谁。1.2 职责链模式的判断标准什么时候该上什么时候不该上我见过不少团队为了“整洁”过度设计把只有两个分支的判断也拆成职责链结果反而增加了阅读成本。我的经验是出现下面几个信号再考虑职责链候选处理者数量会持续增长且新增规则属于常态化需求请求在运行期才确定由哪个处理者接手编译期无法预判处理者之间的相对顺序可能被动态调整调用方不希望知道具体的处理者有哪些只想把请求丢进去。反过来如果分支固定、数量很少、顺序几乎不变那老老实实写 switch 或者 if 卫语句反而更好。职责链的价值不在“代码更短”而在“扩展点更清晰”。为了模式而模式是 C 工程里最容易翻车的事情之一。1.3 开闭原则视角为什么说它天生适合扩展职责链模式最漂亮的一点是符合开闭原则新增一种处理规则时不需要改已有的 Handler 类也不需要改调用方的代码只需要新增一个子类并把它挂到链上。比如上面那个消息接入场景某天产品说“我们还要对 VIP 用户优先放行”你只需要写一个VipPriorityHandler拦截 VIP 请求并处理掉或者写一个VipFilterHandler只调整后续节点的上下文。这种扩展方式对已有代码几乎没有侵入性。这也是为什么它在 C 服务端框架、中间件、游戏战斗管线里非常常见。2. 职责链的经典 C 骨架一个可以跑起来的 demo2.1 最小可运行代码Handler、Request 和两个具体节点先给你一个能直接编译运行的版本我故意把代码保持在最简方便理解结构。这里用最原始的裸指针写法后面再讨论现代 C 的优化方案。#include iostream #include string class Request { public: int level 0; std::string content; }; class Handler { public: virtual ~Handler() default; // 把 next 挂到链尾返回当前节点引用以便链式调用 Handler setNext(Handler next) { if (tail_ nullptr) { tail_ next; } else { tail_-setNext(next); } return *this; } virtual void handle(const Request req) { if (tail_) { tail_-handle(req); } else { std::cout [Handler] no one handled this request std::endl; } } protected: Handler* tail_ nullptr; }; class LevelOneHandler : public Handler { public: void handle(const Request req) override { if (req.level 1) { std::cout LevelOneHandler handled: req.content std::endl; } else { Handler::handle(req); } } }; class LevelTwoHandler : public Handler { public: void handle(const Request req) override { if (req.level 2) { std::cout LevelTwoHandler handled: req.content std::endl; } else { Handler::handle(req); } } }; int main() { LevelOneHandler h1; LevelTwoHandler h2; h1.setNext(h2); Request req; req.level 2; req.content hello chain; h1.handle(req); req.level 3; h1.handle(req); return 0; }运行结果LevelTwoHandler handled: hello chain [Handler] no one handled this request两个关键点你仔细看第一LevelOneHandler判断自己处理不了时不是自己实现转发逻辑而是调用Handler::handle(req)让基类把请求继续往后传第二基类handle在链尾给了兜底输出避免请求“静默消失”。这两点是职责链在 C 里最容易写错的地方。2.2 setNext 为什么返回 Handler基类析构为什么要 virtual第一次写职责链的人经常会问两个问题setNext返回值有必要吗析构函数有必要写成 virtual 吗setNext返回Handler的理由很实际它让你能以链式语法把多个节点串起来。比如h1.setNext(h2).setNext(h3)读起来和流水线一样顺。如果返回 void就必须一行一行写h1.setNext(h2); h2.setNext(h3);节点一多就显得啰嗦。析构函数必须写virtual这一点比大多数普通类都更严格。因为职责链的节点几乎一定是通过基类指针或引用操作的如果基类析构不是虚函数delete一个Handler*指向LevelOneHandler时只会调用基类析构子类资源不会被释放造成未定义行为。哪怕你的 Handler 暂时没有资源也要写上这是 C 多态类的基本纪律。2.3 无人处理的请求链路应该给出什么反馈很多人写职责链只关心“谁能处理”却忘了处理“没人能处理”的分支。真实项目里这个分支恰恰是最容易出线上问题的请求走到链尾每个节点都表示“不归我管”然后呢如果直接静默返回调用方会以为请求已经被处理了日志里却什么都没有排障时根本无从下手。我建议在基类handle末尾保留一个明确的兜底行为至少打一条 warning 级别的日志或者返回一个错误码、抛一个明确异常由业务决定。上面 demo 里我选择在链尾打印提示就是让你别忽略这个场景。更严谨的做法是单独定义一个TerminalHandler或DefaultHandler挂在最后统一处理“无人认领”的情况后续加告警、加统计也会更方便。3. 现代 C 下的所有权与生命周期这条链到底该由谁销毁3.1 裸指针链教学里最清晰工程里最需要小心上面用裸指针写的 demo逻辑上很清楚但它有个工程隐患h1和h2必须活得比setNext调用的生命周期长一旦其中一个节点提前析构链上就会残留悬挂指针。如果整条链是在同一个作用域里创建和使用的问题不大但如果节点是异步任务创建的、或者在不同模块间传递裸指针就会变成一颗随时会爆的雷。裸指针版本最适合用来理解职责链的本质每个节点知道自己下游是谁请求沿指针向前走。真要上生产环境我一般会换掉裸指针改由所有者统一管理生命周期。3.2 unique_ptr 链所有权归属明确的现代写法用std::unique_ptr持有下游节点是 C 里最推荐的责任链所有权方案。整条链就是一个独立的所有权树链销毁时所有节点自动释放不用担心悬挂指针。下面是一个更接近工程实践的写法#include iostream #include memory #include string #include utility class Request { public: int level 0; std::string content; }; class Handler { public: virtual ~Handler() default; void handle(const Request req) { if (next_) { next_-handle(req); } else { std::cout [Handler] no one handled this request std::endl; } } templatetypename T, typename... Args T addNext(Args... args) { auto node std::make_uniqueT(std::forwardArgs(args)...); T ref *node; Handler* tail this; while (tail-next_) { tail tail-next_.get(); } tail-next_ std::move(node); return ref; } protected: std::unique_ptrHandler next_; }; class LevelOneHandler : public Handler { public: void handle(const Request req) override { if (req.level 1) { std::cout LevelOneHandler handled: req.content std::endl; } else { Handler::handle(req); } } }; class LevelTwoHandler : public Handler { public: void handle(const Request req) override { if (req.level 2) { std::cout LevelTwoHandler handled: req.content std::endl; } else { Handler::handle(req); } } }; int main() { auto root std::make_uniqueLevelOneHandler(); root-addNextLevelTwoHandler(); Request req; req.level 2; req.content unique_ptr chain; root-handle(req); return 0; }这个版本里addNext用模板参数传入节点类型配合std::make_unique自动创建对象调用方基本不需要手动拼unique_ptr。addNext返回的是新节点的引用这样如果你想在挂完之后继续配置这个节点可以直接拿到引用操作。链的生命周期完全绑定在root上作用域结束自动释放整条链。3.3 shared_ptr、异常与多线程真正的复杂度从这儿开始也有团队用std::shared_ptr持有下游节点理由是“某些节点需要被多个链共享”比如一个公共审计处理器同时挂在多条请求链上。这个需求真实存在但引入shared_ptr的同时要小心循环引用如果某个节点反向持有一条链的shared_ptr就会形成引用环链走到哪都释放不掉内存泄漏就来了。我的建议是优先unique_ptr只有当“多个链共享同一处理器实例”成为明确需求时再换成shared_ptr。在多线程场景下职责链的复杂度会明显上升。如果一条链在启动后就固定不变只读遍历是线程安全的但如果运行期间还要动态addNext就必须加锁或者干脆采用“重建链”的方式避免持有旧链的线程读到被破坏的中间状态。异常处理也一样某个节点内部抛异常会导致链中断调用方很难知道请求到底走到哪一步。工程上我会约定各节点内部捕获业务异常转成错误结果继续传或者由链入口统一 try-catch 并记录日志。4. 链式结构还是容器派发两种工程形态的选择逻辑4.1 经典链式请求像流水线一样在节点间流动标准职责链是“链表式”的每个 Handler 持有下游节点指针请求从链头开始逐个尝试。这种方式的精髓在于节点本身知道自己下一个是谁链的结构信息分散在每个节点内部。好处是单个节点可以独立复用想要形成新链只要重新 setNext 就行坏处是一旦链比较长调试时你得从链头一路跑到链尾才能看清完整路径。大多数教材和面试题讲的都是这种形态。4.2 容器派发把处理者放进 vector 的插件化思路实际工程里还有一种非常普遍的变形用一个std::vector保存所有处理器遍历时将请求依次交给每个处理器让它们通过shouldHandle判断是否接手。这种形态严格说已经偏离 GOF 的“链式结构”但它在插件化系统、过滤器链、中间件里非常实用尤其是你希望“运行时能方便地调整处理器的增删顺序”时操作 vector 显然比手工改链表指针更安全。#include iostream #include memory #include string #include vector class Request { public: int level 0; std::string content; }; class Filter { public: virtual ~Filter() default; virtual bool shouldHandle(const Request req) const 0; virtual void process(const Request req) 0; }; class BlockFilter : public Filter { public: bool shouldHandle(const Request req) const override { return req.level 1; } void process(const Request req) override { std::cout BlockFilter process: req.content std::endl; } }; class AuditFilter : public Filter { public: bool shouldHandle(const Request req) const override { return req.level 2; } void process(const Request req) override { std::cout AuditFilter process: req.content std::endl; } }; class FilterChain { public: void add(std::unique_ptrFilter filter) { filters_.push_back(std::move(filter)); } void run(const Request req) { for (const auto filter : filters_) { if (filter-shouldHandle(req)) { filter-process(req); break; } } } private: std::vectorstd::unique_ptrFilter filters_; }; int main() { FilterChain chain; chain.add(std::make_uniqueBlockFilter()); chain.add(std::make_uniqueAuditFilter()); Request req; req.level 2; req.content container chain; chain.run(req); return 0; }FilterChain把处理器统一收拢进 vectorrun里遍历并在第一个匹配的处理器处中止这模拟了职责链“第一个能处理的节点消费请求”的语义。你也可以把break去掉让所有处理器按顺序参与请求处理那就变成标准的过滤器链了。4.3 对比表格组装、调试、动态更新到底差在哪对比维度经典链式 setNext容器派发 vector组装方式每个节点内部持有下一个节点所有节点统一加入容器全链可观测性需要从链头逐个遍历才看得到全貌直接遍历容器即可运行时增删节点改指针需要小心并发链表插入逻辑自己做操作 vector 更直观加锁也更简单节点生命周期需要额外约定unique_ptr 容器统一持有销毁简单扩展新节点新增 Handler 子类后挂链新增 Filter 子类后 add 进容器典型场景固定流水线、经典职责链过滤器链、插件式规则引擎我的选择标准很简单如果这组处理器是相对固定的业务链路用经典链式语义最贴切如果它更像“一堆可插拔规则”需要频繁增删和调整顺序用容器派发。两者不冲突甚至可以在一个项目里共存。4.4 运行时增删处理器容器派发在运行时增删处理器的优势非常明显。比如某个运营活动结束后要临时摘掉一个校验规则只需要erase对应的unique_ptr元素活动再来再insert回去。经典链式要这么做得先找到前一个节点修改它的next_指针还要保证被摘节点不泄漏复杂度明显高一个量级。所以在需要运营配置动态干预的场景里我几乎都用容器派发。5. 工程落地时最容易踩的坑链断裂、对象切片、线程竞争5.1 子类重写 handle 后不转发请求被静默吞掉很多初学职责链的人会犯同一个错误子类重写handle之后判断条件不满足时忘了调用基类的Handler::handle(req)而是直接 return。结果请求走到这个节点就断掉了后面的节点永远收不到而且没有任何报错。这个问题在大型代码里非常难排查因为你看到的只是“某个功能突然不生效”不知道链断在哪一环。规避办法有两个。第一约定所有子类在不能处理时必须显式调用基类转发第二改变设计让子类不再直接重写handle而是实现canHandle和process把转发逻辑完全收敛在基类class Handler { public: virtual ~Handler() default; void handle(const Request req) { if (canHandle(req)) { process(req); } else if (next_) { next_-handle(req); } else { std::cout [Handler] no one handled this request std::endl; } } protected: virtual bool canHandle(const Request req) const 0; virtual void process(const Request req) 0; Handler* next_ nullptr; };这个写法等于把“判断-处理-转发”的流程模板化子类根本没有机会忘记转发。这也是我在生产代码里更偏好的形态强烈建议你试一试。5.2 对象切片、虚析构、值传递C 特性给设计模式加的难度同样一段设计模式Java 里写起来顺理成章换到 C 就会多出很多语言层面的讲究。职责链在 C 里最典型的三连坑是基类析构没加virtual通过基类指针delete派生类对象时行为未定义setNext参数写成了值传递void setNext(Handler next)传入子类对象会被切片动态绑定彻底失效Handler 子类内部持有std::string、std::vector等成员复制、移动语义如果不明确挂链时容易发生隐式拷贝轻则性能损耗重则逻辑错误。我的习惯是setNext只接受引用或指针绝不用值传递基类析构声明为virtual如果节点需要支持拷贝明确做好深拷贝语义否则直接delete拷贝构造和赋值运算符。5.3 调试难题这条链断在哪了我用的排查方法职责链代码一旦上了规模排查问题最怕的就是“不知道请求到底被哪个节点消费了”。我一般会给每个 Handler 预备一个name()虚函数或name_成员然后在基类handle里加一条 debug 级别的日志记录“当前进入哪个节点是否继续转发”。这样打开日志就能清楚看到请求的完整流动路径enter LevelOneHandler - canHandlefalse, forward enter LevelTwoHandler - canHandletrue, process如果没有日志我通常用回调函数链排查法把handle里的转发点单独拆出来暂时在转发前后各打一条日志很快就能定位到是哪个节点丢弃了请求。这个方法笨但特别管用。工程上可观测性对职责链来说不是加分项而是必需品。6. 职责链的三个变体中间件、轻量函数链和 CRTP6.1 中间件风格的洋葱模型所有节点都参与处理经典职责链是“第一个能处理的节点消费请求并中止”但真实系统里还有一种更常见的需求每个节点都要先做前置处理然后传给下一个下游返回后再做后置处理。这就是中间件模式也叫洋葱模型。C 里实现它只要把基类的转发逻辑从“后置”改成“前后包围”就行class Middleware { public: virtual ~Middleware() default; void handle(const Request req) { before(req); if (next_) { next_-handle(req); } after(req); } protected: virtual void before(const Request req) 0; virtual void after(const Request req) 0; Middleware* next_ nullptr; };HTTP 框架里的超时统计、日志记录、鉴权刷新通常都适合这种形态。它和纯职责链的差别在于请求一定要穿透整条链每个节点都有机会在请求经过时做点事情。6.2 轻量级 std::function 链适合不需要完整继承体系的场景如果你的处理器只需要少量状态或者只想快速把几个 lambda 串起来做判断没必要为每个节点定义一个类。用std::function加std::vector可以写出非常轻量的过滤管线#include functional #include iostream #include string #include vector class Request { public: int level 0; std::string content; }; using FilterFunc std::functionbool(const Request); class FilterPipeline { public: FilterPipeline add(FilterFunc f) { filters_.push_back(std::move(f)); return *this; } void run(const Request req) { for (auto f : filters_) { if (!f(req)) break; } } private: std::vectorFilterFunc filters_; }; int main() { FilterPipeline p; p.add([](const Request req) { if (req.level 1) { std::cout first lambda handled: req.content std::endl; return false; } return true; }); p.add([](const Request req) { std::cout second lambda handled: req.content std::endl; return false; }); Request req; req.level 2; req.content lambda chain; p.run(req); return 0; }FilterFunc返回bool表示“是否还需要继续往后走”返回false就中断。这个模式非常适用于业务规则不复杂、但又不想写一堆继承类的场景。如果后续某个 lambda 的逻辑越来越膨胀再把它抽成一个具名函数或类也不晚。6.3 CRTP 静态多态性能敏感时的另一个选择有些性能敏感场景比如游戏服务器里的战斗指令过滤频繁触发虚函数调用会产生可感知的开销。CRTPCuriously Recurring Template Pattern可以把“运行期多态”改成“编译期多态”让handle调用直接静态绑定到派生类templatetypename Derived class HandlerBase { public: void handle(const Request req) { auto me static_castDerived(*this); if (me.canHandle(req)) { me.process(req); } else if (next_) { next_-handle(req); } } Derived setNext(Derived next) { next_ next; return static_castDerived(*this); } private: Derived* next_ nullptr; };注意这个方案有局限因为next_的类型是Derived*一条链里只能放同一种模板实例。如果链上要混合不同类型节点就得靠类型擦除或公共非模板基类等于又绕回虚函数。我的结论是除非你确实测出虚函数是性能瓶颈否则不值得为了性能把代码结构变复杂。6.4 数据校验场景职责链最常见的“非典型”用法职责链还有一个非常实用的应用场景是数据校验。比如用户注册接口需要校验用户名、密码强度、邮箱格式、手机号是否重复。每一条校验规则就是一个 Handler挂了任意一条就直接返回错误全部通过才算合法。这种场景用容器派发特别顺手class Validator { public: virtual ~Validator() default; virtual std::optionalstd::string validate(const UserInput input) 0; }; class ValidatorChain { public: void add(std::unique_ptrValidator v) { validators_.push_back(std::move(v)); } std::optionalstd::string validate(const UserInput input) { for (const auto v : validators_) { if (auto err v-validate(input)) { return err; } } return std::nullopt; } private: std::vectorstd::unique_ptrValidator validators_; };每个校验器只负责一个维度新增规则就是新增一个 Validator 子类然后 add 进链。这个模式我几乎在每个 C 后端项目里都会用简单、干净、特别容易测试。你在面试里也可以提这个场景比光背教材里的日志例子有说服力得多。如果你问我职责链最需要记住的一句话是什么它不是在帮你减少代码量而是在帮你把“变化的规则”和“稳定的流程”分开。C 里做这件事真正的难点从来不是模式本身而是内存所有权、虚函数边界、线程安全和可观测性这些语言层面的细节。把这些细节处理好了职责链会成为你工具箱里非常顺手的一件工具。
返回列表