尧图精选

现代C++职责链模式实战:从基础链表到可排序异步过滤链

🕒 发布时间:2026/10/1 17:07:46 📁 来源:尧图网络
写C的时间长了你会发现设计模式这个工具箱里有的模式在C里用起来特别顺手有的却非常别扭。职责链模式属于前者但前提是你得知道怎么用。Java和C#里职责链通常是一串对象指针顺着链走下去谁处理谁结束。C里玩法更多也更容易踩坑——虚函数、lambda、移动语义、生命周期、模板展开缠在一起用好了是真舒服用不好就是一场灾难。这篇文章我从最基础的链式处理器开始聊然后重点讲现代C里怎么把职责链模式做成一套可排序、可中断、可异步、甚至可以在编译期就定型的过滤链。如果你在写中间件、网关、游戏服务端或者通用框架这些经验应该能帮你少走不少弯路。全文都是我在真实项目里验证过的写法不是讲PPT那种。1. 职责链模式的核心思路以及在C里为什么值得琢磨1.1 先聊设计意图请求的解耦与逐级传递职责链模式的核心思想很简单把“谁该处理这个请求”的决策过程从发送方手里拆出来。发送方只需要把请求丢给链路入口后续的多个处理器依次获得机会每一个都可以选择自己处理、拒绝处理、或者处理一半再传给下一个。生活中最典型的例子就是报销审批。你提交一笔报销单部门主管先看金额额度内直接批超过主管权限转给部门经理再大一点转给总经理。这就是一条活生生的职责链。每个审批人都知道自己的权限边界也都知道处理不了时应该找谁。放在软件系统里这个模式的价值在于流程的可扩展性。你不需要在调用方代码里塞一大串if-else去判断什么请求走什么逻辑也不需要让每个处理器知道全链路里还有谁。新增一个处理环节只需要往链条上追加一个节点原有代码几乎不动。这一点在C这种特别看重代码组织和可维护性的语言里价值尤其明显。1.2 C实现职责链的三条路线以及怎么选C里实现职责链大概有三条路线经典面向对象路线定义抽象基类Handler每个具体处理器实现处理接口内部持有“下一个处理器”指针。典型的手写链表结构。现代函数式路线用std::function承载处理逻辑用std::vector管理多个处理器按顺序调用。动态性最强写起来最短。模板编译期路线通过模板参数包在编译期展开多个处理函数没有运行时虚函数开销但链路是固定的不能在运行时增删。三条路线没有绝对的优劣完全看场景。如果你在做一个稳定的框架链路基本不变编译期模板路线最干净、性能最好。如果你在写插件系统或者中间件运行时要动态调整处理器顺序std::function加容器的做法最灵活。经典虚函数路线适合教学和简单的继承复用但在现代C里越来越显得笨重。我这篇文章会重点讲第二条路线因为它是项目实战里最常用、也最能体现C特性玩法的一条路线。但在讲高级应用之前先得把基础链条搭明白不然直接上容器方案你会不知道它到底解决了什么问题。2. 搭好一条职责链C基础实现与关键接口设计2.1 用链表手搓一条链以及接口里藏着的坑先看一个最传统的C职责链实现。定义一个请求类定义一个抽象Handler每个Handler内部持有next指针class Request { public: int userId{0}; std::string action; int level{0}; }; class Handler { public: virtual ~Handler() default; virtual void handle(Request req) { if (canHandle(req)) { handleInternal(req); } else if (next_) { next_-handle(req); } else { throw std::runtime_error(no handler for this request); } } Handler* setNext(Handler* next) { next_ next; return next_; } private: virtual bool canHandle(const Request req) 0; virtual void handleInternal(Request req) 0; Handler* next_ nullptr; };然后实现具体的处理器class LevelOneHandler : public Handler { private: bool canHandle(const Request req) override { return req.level 5; } void handleInternal(Request req) override { std::cout LevelOne handled action: req.action std::endl; req.level 10; } }; class LevelTwoHandler : public Handler { private: bool canHandle(const Request req) override { return req.level 10; } void handleInternal(Request req) override { std::cout LevelTwo handled action: req.action std::endl; req.level 20; } };客户端代码像这样auto one std::make_uniqueLevelOneHandler(); auto two std::make_uniqueLevelTwoHandler(); one-setNext(two.get()); one-handle(req);这种写法很直观但有几个坑需要注意。第一个坑处理器所有权归属不清晰。从上边的代码看LevelOneHandler内部保存了LevelTwoHandler的裸指针但是真正持有所有权的是外部的unique_ptr。一旦外部生命周期结束再调用链条就会访问悬空指针。在实际项目里很常见的做法是让链条本身持有所有节点Handler类只负责业务逻辑这样能有效避免谁delete谁的问题。第二个坑接口设计容易把“处理”和“判断”耦合死。很多初学版本会在handle里直接写“如果自己处理不了就传给下一个”但canHandle其实是业务规则不应该和链路调度混在一个函数里。更好的做法是把“是否处理”和“实际处理”拆开或者干脆让处理器自己决定要不要返回“继续传递”这样链路控制更清晰。第三个坑基类析构函数必须是virtual。Handler里有纯虚接口析构函数不写virtual通过基类指针delete子类对象的时候就是未定义行为。这个不算职责链特有但做框架时经常看到有人漏掉。2.2 用std::function代替继承让链条瞬间灵活起来虚函数加继承的写法在C里有一种“过重的仪式感”——每个处理器都要写一个新类哪怕逻辑只有两行。现代C的解法是用std::function保存任意可调用对象再用一个容器按顺序执行。这是我在实战项目里尤其偏爱的一种方式。先用一个容器版本把姿态摆出来class FilterChain { public: using Handler std::functionbool(Request); void addHandler(Handler h) { handlers_.push_back(std::move(h)); } bool process(Request req) { for (const auto handler : handlers_) { if (!handler(req)) { return false; } } return true; } private: std::vectorHandler handlers_; };使用的时候你再也看不到那些类继承的样板代码FilterChain chain; chain.addHandler([](Request req) { if (req.level 5) { std::cout 低级别处理: req.action std::endl; req.level 10; return true; } return true; }); chain.addHandler([](Request req) { if (req.level 20) { std::cout 中级别处理: req.action std::endl; req.level 20; return true; } return false; }); chain.process(req);这个版本的灵活度非常高。因为std::function可以容纳lambda、函数指针、绑定表达式、甚至任意可调用对象所以链路上每个节点都可以写成局部闭包随意捕获上下文不需要都塞到类里。同时std::vector天然支持运行时插入、删除、排序链路调整变成普通的容器操作。但要注意std::function也有它的代价它内部可能使用类型擦除和堆分配如果处理器数量很少但调用频率极高这个容器版本的性能不一定比虚函数版好。不过对于大多数中间件和业务系统来说这点开销远小于你接下来要做的业务逻辑完全不用焦虑。真到了需要极致性能的时候再切模板方案后面会讲到。3. 高级应用把职责链玩出花来3.1 链不只是链表让职责链拥有优先级和动态排序先说一个我早期在项目里交过的学费很多资料都把职责链画成一条单向链表每个节点只认识下一个节点。但真实系统里链路不一定是一条线也可能是一组“带优先级”的过滤器甚至同一类型的处理器会出现多个实例它们之间的顺序必须在运行时可调。把职责链从“链表”改造成“容器”最大的好处就在这里。vector里每个元素天然有下标你可以根据处理器类型和优先级做插入、移动、删除。如果把addHandler升级成带priority参数的版本还能自动维持整体顺序class FilterChain { public: using Handler std::functionbool(Request); void insertHandler(int priority, Handler h) { handlers_.insert( std::upper_bound(priorities_.begin(), priorities_.end(), priority), std::move(h) ); priorities_.insert( std::upper_bound(priorities_.begin(), priorities_.end(), priority), priority ); } bool process(Request req) { for (size_t i 0; i handlers_.size(); i) { if (!handlers_[i](req)) { return false; } } return true; } private: std::vectorint priorities_; std::vectorHandler handlers_; };这里的逻辑是维护两个平行的数组一个是处理器本身一个是对应的优先级插入时用upper_bound找到实际位置保证优先级从小到大执行。严格来说两个vector可能出现不同步的情况但注意插入时是同步插入的所以索引永远一一对应实操中完全没问题。这个设计解决了一个很现实的痛点在中间件系统里过滤器之间的顺序往往很重要。鉴权过滤器必须排在所有业务过滤器之前限流过滤器又要排在业务处理之前但排在鉴权之后。用优先级注册就不用在调用方那里手动排链也不用在初始化时互相传递next指针只要定义好优先级数字链自己会排好。3.2 用模板让职责链在编译期展开彻底消除虚函数开销如果你做的系统对性能非常敏感处理器数量固定链路又是确定不变的话可以走第三条路线编译期展开。C17之后折叠表达式让这件事写起来非常优雅。一个简单的编译期职责链长这样template typename... Handlers bool processChain(Request req, Handlers... handlers) { return (std::forwardHandlers(handlers)(req) ...); }使用bool result processChain( req, [](Request r) { if (!r.authed) return false; return true; }, [](Request r) { if (r.quota 1) return false; r.quota - 1; return true; }, [](Request r) { std::cout final handling: r.action std::endl; return true; } );这里的关键是(handlers(req) ...)它会在编译期展开成多个handler调用并且利用短路特性只要某个handler返回false后面的handler全部不再调用。这正好就是职责链“断掉”的天然语义没有任何运行时容器遍历的开销也没有std::function的类型擦除开销。要注意的是这个方案没有一个真正的“链对象”处理器是通过模板参数一个个传进去的所以你不能在运行时改变处理器的数量和顺序。它适合的场景是固定的请求管线比如一个游戏服务端的每帧输入处理或者网络协议里固定顺序的解码器。如果既要编译期展开又要动态运行时修改那可以结合variant和std::visit做事件分派但复杂度会明显上升。我的经验是绝大多数业务场景用std::function容器版本就足够了模板版本是优化到最后一公里时才会动用的武器。3.3 中断、转移与异步处理真实业务里绕不开的复杂度前面的示例都假设每个处理器只返回bool表示“继续往下走”或者“停住”。但真实业务里这条链上会出现“我需要异步等待”、“我要把这请求转给另一条链”、“我要在某个环节失败时做兜底”这类情况。我一个个来说。先看中断语义。bool返回值只能表达两种状态太模糊了。更好的做法是定义一个枚举enum class ChainFlow { Continue, // 继续下一个handler Handled, // 当前已经处理完成停止链路 Error // 发生错误停止链路并进入兜底逻辑 };这样每个handler返回的状态就具备了明确的语义调度方可以根据结果决定要不要走错误兜底。比如using Handler std::functionChainFlow(Request); bool process(Request req) { for (const auto h : handlers_) { switch (h(req)) { case ChainFlow::Continue: continue; case ChainFlow::Handled: return true; case ChainFlow::Error: handleError(req); return false; } } return false; }这份设计能把原本“返回false就等于失败”的模糊逻辑拆成清晰的控制流在日志系统、网关请求链路上都很好用。再来看转移。某些场景里一个链路上的handler处理到一半发现自己不适合处理希望把请求交给另一条独立的子链。这其实不需要链本身做什么特殊设计只要handler内部持有另一个FilterChain的引用处理时直接调用对应子链即可。但要注意子链的生命周期管理。如果子链是动态创建的有所有权责任更稳妥的做法是在主处理器创建时就注入子链shared_ptr避免裸指针悬垂。最后看异步。想象一个请求进入链路后某个handler要做一次远程调用或者数据库查询需要等结果返回后才能继续后面的handler。此时如果追求高性能就不能阻塞当前线程等I/O。通常的做法是把handler的返回类型从ChainFlow改成std::futureChainFlow或者用C20协程让handler变成协程函数。协程方案在代码上更自然using Handler std::functionboost::asio::awaitableChainFlow(Request);但协程会引入很多依赖和复杂性如果不是系统性的协程架构我反而建议用异步回调或者future来保证这条链的完整性。关键原则是链路的管理者不要假设每个handler都是同步的要允许异步状态渗透进来否则一旦后面加入异步处理器整条链路就得推翻重写。4. 实际案例给中间件写一个RPC请求过滤链4.1 背景和设计决策这个案例是从我参与过的一个RPC框架里抽出来的。当时的需求是所有外部请求进来后必须依次经过鉴权、限流、日志记录、还有最终的业务逻辑处理。业务逻辑模块是可插拔的鉴权和限流顺序必须固定日志记录的位置可以调整整个链路上的处理器数量在运行时会变化。基于这些约束我选了std::function加vector的方案并且给每个handler分配一个优先级。结构是这样的struct RpcContext { int userId{0}; std::string method; bool verified{false}; int quotaRemain{0}; int latencyMs{0}; }; class RpcFilterChain { public: using Filter std::functionbool(RpcContext); void addFilter(int priority, Filter filter) { auto it filters_.begin(); for (; it ! filters_.end(); it) { if (it-priority priority) break; } filters_.insert(it, FilterItem{priority, std::move(filter)}); } bool run(RpcContext ctx) { for (const auto item : filters_) { if (!item.filter(ctx)) { return false; } } return true; } private: struct FilterItem { int priority; Filter filter; }; std::vectorFilterItem filters_; };4.2 核心代码实现与运行效果有了这段链代码注册过滤器就特别直观RpcFilterChain chain; // 优先级1鉴权 chain.addFilter(1, [](RpcContext ctx) { if (!ctx.verified) { std::cout auth failed for user ctx.userId std::endl; return false; } std::cout auth passed for user ctx.userId std::endl; return true; }); // 优先级2限流 chain.addFilter(2, [](RpcContext ctx) { if (ctx.quotaRemain 0) { std::cout rate limit exceeded std::endl; return false; } ctx.quotaRemain--; return true; }); // 优先级3日志 chain.addFilter(3, [](RpcContext ctx) { std::cout log: ctx.method user ctx.userId latency ctx.latencyMs std::endl; return true; }); // 最后业务逻辑优先级100 chain.addFilter(100, [](RpcContext ctx) { std::cout business: ctx.method std::endl; return true; }); RpcContext ctx{42, GetUserInfo, true, 5, 0}; bool ok chain.run(ctx);运行输出大致是auth passed for user 42 log: GetUserInfo user42 latency0 business: GetUserInfo我特意没让限流在日志之前打印因为优先级顺序是鉴权(1)、限流(2)、日志(3)、业务(100)。如果你想让日志记录所有请求包括限流失败的请求那日志就应该放在优先级0之前先执行。这个顺序调整完全可以在注册时决定对已有代码的侵入性很小。4.3 这个案例里踩过的三个扩展点这个链设计最舒服的地方在于扩展。后来系统里加了一个“操作审计”功能要求记录谁在什么时间调了什么接口默认日志只记录成功请求审计则必须记录所有已鉴权请求。我当时只做了一个操作chain.addFilter(5, [](RpcContext ctx) { std::cout audit: ctx.userId ctx.method std::endl; return true; });没有动任何现有代码只是在日志和业务之间插了一个优先级为5的过滤器审计功能就上线了。这个感受说不上震撼但对比之前的if-else嵌套实现确实爽了不止一点。当然这个案例也暴露出一些需要注意的地方。比如RpcContext是一个可变引用所有handler都可以修改它的字段。如果某个handler偷偷把userId改掉了后面所有handler看到的数据就乱了。我在项目里加了一个约定业务逻辑之外的过滤器不允许修改ctx里已经存在的字段只能读取和判断如果需要传递中间数据再单独附加子字段。这个约定比代码层面的强制检查更轻但对团队协作来说已经足够有效。5. 常见问题与排查实录5.1 链断得莫名其妙先看返回值语义和顺序这个问题在我接触过的人里出现频率最高。很多人第一次用vectorstd::function做职责链发现某个请求走到一半后面就不执行了第一反应是链出了问题其实只要做一个动作把每个handler的返回值都打出来。我推荐的调试方式是在chain的run函数里临时加一段诊断日志bool run(RpcContext ctx) { for (size_t i 0; i filters_.size(); i) { auto item filters_[i]; bool result item.filter(ctx); std::cout [filter i ] - std::boolalpha result std::endl; if (!result) return false; } return true; }如果发现某个handler返回false导致链断再去检查这个handler内部的判断条件是否符合预期。很多时候并不是链断了而是你的handler把“无法处理”和“处理失败”混在一起返回了。这也就是我前面强烈建议用枚举状态代替bool的原因。bool太容易被当作真假判断而链调度里你更想要的是“继续、结束、错误”这种三态语义。顺序问题也容易造成困惑。比如一个请求先打日志后鉴权日志把敏感信息打出去了才被鉴权拦截。排查顺序乱掉最直接的办法就是设计时给每个filter打一个调试tagchain.addFilter(1, [tag auth] (RpcContext ctx) { ... } );在run诊断里连tag一起打印顺序一目了然。实际项目里我还会把优先级写在名称里比如05-auth、10-limiter日志里看着就能对上位置。5.2 悬空指针和生命周期C职责链最大的坑这一节得重点说。很多人调C责任链链上每个handler都捕获了一个对象指针比如class RateLimiter { public: bool check(int userId) { ... } }; auto limiter std::make_sharedRateLimiter(...); chain.addFilter(2, [limiter](RpcContext ctx) { return limiter-check(ctx.userId); });这里用shared_ptr捕获还算安全。但如果你用的是裸指针或者捕获了对象的引用而对象生命期比链表短那么一旦对象析构处理器还在链上调用时就是未定义行为。这种问题在gdb里往往表现为非空地址但是内容已乱极难排查。我的建议是凡是外部依赖的资源优先用shared_ptr捕获到lambda里。如果必须用weak_ptr那handler内部要记得lock再使用。如果捕获的是this指针那就更加危险因为this指向的对象可能已经析构lambda自身还活得好好的。这种场景应该把this所在对象的生命周期与链条生命周期绑定比如用std::enable_shared_from_this然后把shared_from_this捕获进来。另外注意全局静态变量的初始化顺序问题。如果职责链本身是一个全局单例在多个翻译单元里向它注册过滤器那么注册顺序由静态初始化顺序决定不一定是你文件里的书写顺序。为了避免这个坑我倾向于不用全局单例链表而是提供一个显式的buildChain函数在main或者模块入口处统一注册。这样链路构建的时序完全可控调试也容易。5.3 性能问题虚函数、std::function到底差多少有些朋友一看到std::function就担心性能总觉得比虚函数慢很多。实测下来std::function的调用成本大约比虚函数高几纳秒到几十纳秒具体取决于处理器的数量、每次调用时的类型擦除复杂度。如果系统每秒钟只处理几千个请求这种差异完全可以忽略。只有当单个请求要跑成百上千个handler且每个handler本身都是几行快速判断时std::function的额外开销才会在性能画像里占一定比例。这时候有一个不需要放弃灵活性的优化思路减少std::function的分配。std::function在包装大的lambda对象时可能做堆分配导致缓存不友好。可以改用sbo优化更好的一些库实现或者直接改用函数指针加void*上下文的方式。函数指针调用比std::function更轻但写起来也更麻烦。经验是先用std::function把功能做对等Benchmark数据告诉我们确实有性能瓶颈再考虑替换。调试技巧方面我一般会在链的入口和出口各放一个高精度计时点。若总耗时异常高再用二分注释法逐个禁用handler找出耗时大户。这条思路比盲目优化有效得多。6. 最后说点自己的体会职责链模式在C里最迷人的地方是它能和现代C的特性无缝贴合。std::function、lambda、vector、shared_ptr、折叠表达式——这些元素组合起来能让这条链变得非常轻巧、灵活同时又比传统写法更容易维护。就我个人的实践而言有一套用得比较顺的准则链路的构建函数必须集中不在十个文件里七零八落地注册过滤器每个handler尽量通过RpcContext传递中间状态不直接依赖全局变量handler的语义尽量用枚举返回而不是bool让“继续、终止、错误”一目了然涉及外部资源的依赖一律用shared_ptr捕获避免裸指针悬垂。这套准则不是设计模式教科书里写的而是踩过几次坑之后总结出来的。如果你正准备在一个C项目里引入职责链模式我建议先把这些经验抄一遍然后好好设计一下你的Context和返回值语义后面的路会顺很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →