先交代一下背景。我最近在公司的一个C服务端项目里正好经历了一次日志模块和请求校验模块的重构最终都用职责链模式解决了。这个模式在C里很多人觉得简单但实际上它的高级应用非常能打——小到日志分级、过滤链路大到中间件框架、异步任务管线甚至跟std::function、智能指针、CMake工程结构配合起来能写出扩展性相当好的代码。这篇我就结合自己的实际项目经验把职责链模式在C里的应用从底层原理到高级玩法完整拆一遍思路、代码、踩坑都有适合刚把C语法过完、想提升设计能力的同学也适合已经写了一阵业务、正在为代码膨胀发愁的工程师参考。1. 在C里用职责链先想清楚这四件事1.1 职责链到底解决什么问题职责链模式的核心思想就一句话把多个可能的处理者串成一条链请求从链头进入依次经过每个节点由“能处理的那个”处理掉或者一直传到链尾。用生活中的例子更好理解你在公司提一个请假申请一线主管能批3天以内的假部门经理能批7天以内的总监能批15天以内的再往上就得走HR流程。你提交申请后系统会从主管开始逐级往上推直到有人能处理为止。这个“逐级上报、遇到能处理的就停下来”的流程就是职责链。在C代码里这个模式解决的是“请求的发送者和接收者解耦”的问题。发送端不需要知道链里到底有谁、谁负责处理什么只需要把请求往链头一扔每个节点也不需要关心链上有多少个兄弟节点只需要知道自己处理不了时应该往后传。这样一来新增一种处理逻辑就是在链上追加一个节点不改动已有的任何节点代码符合“开闭原则”。具体到C服务端最常见的职责链应用场景是这几类日志分级链路Debug日志直接输出到控制台Info日志写入文件Warn日志额外上报监控Error日志触发告警。每一条日志在链上“走一圈”不同严重级别在不同节点被拦截处理。请求校验链参数合法性校验、鉴权校验、频率限制、幂等校验每个校验器是一个节点任何一个不通过就终止链路并返回错误。事件派发UI层的鼠标事件、键盘事件在控件树里逐级冒泡谁处理了谁负责没人处理则默认丢弃。中间件框架HTTP服务器的中间件链每个中间件在请求进入业务逻辑前做一件事还可以继续调用下一个中间件这种链路还附带“洋葱模型”特性。1.2 什么时候别用职责链我在实际项目里发现一味追求设计模式反而会让代码变差。下面这些情况最好不要用职责链第一如果所有请求都必须经过所有处理节点而且处理逻辑本身就是固定流水线那就直接写for循环遍历节点不要套职责链。职责链的优势在于“动态决定谁处理”流水线场景没有这个需求套上只会增加间接层。第二如果链路很短、处理者数量固定且几乎不变直接代码顺序调用比职责链更直白。比如一个函数里要做三步操作写成三行调用比搞三个类加一个链更清晰过度设计是新人常犯的错。第三如果节点之间有很强的顺序依赖比如“必须先做A再做BA的结果是B的输入”这是典型的管道模式不是职责链。职责链的一个隐含前提是“每个节点相对独立换一换顺序系统仍然能正确工作”。我的判断标准是只有当“处理者集合会扩展、请求与处理者的对应关系不确定”时职责链才值得用。日志系统、插件机制、消息路由、校验框架都符合这个特征。2. 三种实现形态从虚函数到std::function再到模板链很多讲职责链的教材只会讲一种实现抽象Handler基类加SetNext方法加子类。但C的职责链实现远不止这一种。针对不同的业务场景我分别用过三种实现形态各有各的适用场景。2.1 经典实现虚函数 链表指针这是最正统的写法。定义一个抽象基类里面有一个指向后继节点的裸指针或智能指针以及一个纯虚函数Handle。每个子类实现Handle时先判断自己能不能处理能则处理并返回不能则把请求传给后继。#include memory #include iostream class Handler { public: virtual ~Handler() default; void SetNext(std::shared_ptrHandler next) { next_ std::move(next); } virtual void Handle(int request) { if (next_) { next_-Handle(request); } else { std::cout [Fallback] No handler can process: request std::endl; } } protected: std::shared_ptrHandler next_; }; class ConcreteHandlerA : public Handler { public: void Handle(int request) override { if (request 10) { std::cout HandlerA processed: request std::endl; } else { Handler::Handle(request); } } }; class ConcreteHandlerB : public Handler { public: void Handle(int request) override { if (request 20) { std::cout HandlerB processed: request std::endl; } else { Handler::Handle(request); } } };这种实现的优点非常明显语义清晰、结构直观任何C工程师一看就懂。基类里的兜底逻辑也能统一处理“整个链都没人接得住”的情况。但缺点也同样突出每个具体处理器都必须是一个类如果只是想让某个函数参与处理也得包一层类而且随着C11之后std::function的普及这种为每个处理逻辑都建类的做法显得过于笨重。我给它的定位是责任链节点确实“有状态、有生命周期、需要复用”的时候用。比如一个带连接池的数据库写入处理器它有连接句柄需要管理这种实体节点用类实现最合适。2.2 函数式实现std::function与lambda链现代C里我用的最多的反而是基于std::function的实现。核心思路是把“处理逻辑”抽象成std::function几个处理函数放在一个容器里遍历执行。#include functional #include vector #include iostream using HandlerFunc std::functionbool(int); class HandlerChain { public: void Add(HandlerFunc func) { handlers_.emplace_back(std::move(func)); } void Handle(int request) { for (auto handler : handlers_) { if (handler(request)) { return; } } std::cout [Fallback] No handler processed: request std::endl; } private: std::vectorHandlerFunc handlers_; }; int main() { HandlerChain chain; chain.Add([](int req) { if (req 10) { std::cout Lambda A: req std::endl; return true; } return false; }); chain.Add([](int req) { if (req 20) { std::cout Lambda B: req std::endl; return true; } return false; }); chain.Handle(5); chain.Handle(15); chain.Handle(25); return 0; }这里每个函数返回bool表示“是否处理完成”。处理完成就停止遍历没处理就继续找下一个节点。这个实现把职责链从“类层次结构”变成了“函数组合”好处是肉眼可见的用lambda就能写处理逻辑几行代码就能搭一条链不用定义一堆子类。可以在代码里即时捕获局部状态灵活性高。容器用了std::vector或者std::list动态增删节点非常容易。我在日志模块里就是这么做的——把输出目标抽象成处理器函数控制台输出、文件输出、监控上报各是一个lambda按严重级别顺序注册进链里。后来要加一个JSON格式输出就是多注册一个lambda的事。不过这种实现有个坑std::function本身是有开销的。每次调用它都要经过一次类型擦除的间接调用跟直接调用函数指针比会慢一些。在日志这种对性能不极端的场景无所谓但如果放在网络收包热路径的每包处理上就要考虑性能问题了。对性能敏感的场景我用的是第三种方案。2.3 性能敏感场景模板与编译期职责链先想清楚一个前提如果链上的节点顺序在“编译期”就完全确定而且节点数量也不会变那就根本不需要在运行时建立链表直接用模板把链“烙”在编译期。这样调用链上每个节点都是直接函数调用或inline展开性能跟手写顺序调用几乎没差别。一个最简单的编译期职责链思路是用变长模板加递归templatetypename... Handlers class CompileTimeChain; template class CompileTimeChain { public: void Handle(int) { // 空链兜底 } }; templatetypename First, typename... Rest class CompileTimeChainFirst, Rest... : private CompileTimeChainRest... { public: void Handle(int req) { if (first_.CanHandle(req)) { first_.Handle(req); } else { CompileTimeChainRest...::Handle(req); } } private: First first_; };每个节点类需要提供CanHandle和Handle两个方法。链构造时把节点类型作为模板参数传进去请求沿着模板递归逐层下传。所有节点都在编译期确定运行时没有虚函数开销、没有std::function类型擦除编译器的优化空间很大。这种写法适合的场景比较特殊嵌入式系统里的命令解析、网络协议栈里的报文分帧处理、帧率要求高的图像处理管线。我在一个路由器管理面项目里用类似思路写过报文解析链性能确实惊艳但阅读性和调试性不如前两种。所以这个方案我是“备而少用”只有当profiler明确告诉我职责链是性能瓶颈时才把它请出来。3. 高级实战从日志分级到洋葱模型到异步回调链说完了理论来三个实打实的工程案例。这三个案例都是我真实在项目里落地过的代码我尽量保留核心骨架方便直接照着改。3.1 实战一分级日志链路基于SPDLOG改造思路很多团队直接用spdlog但需要一个“分级处理”能力DEBUG和INFO日志只进文本文件WARN日志要额外上报普罗米修斯监控ERROR日志要触发钉钉告警。原本的逻辑是每次打日志都写一堆if判断散落在代码各处。我重构后把这些判断收敛成了职责链#include memory #include string #include functional #include vector #include iostream enum class LogLevel { Debug, Info, Warn, Error }; struct LogMessage { LogLevel level; std::string content; }; class LogHandler { public: virtual ~LogHandler() default; void SetNext(std::shared_ptrLogHandler next) { next_ std::move(next); } virtual void Handle(const LogMessage msg) { if (next_) { next_-Handle(msg); } } protected: std::shared_ptrLogHandler next_; }; class FileLogHandler : public LogHandler { public: explicit FileLogHandler(LogLevel level) : level_(level) {} void Handle(const LogMessage msg) override { if (msg.level level_) { WriteToFile(msg); // 实际项目里封装spdlog的sink } LogHandler::Handle(msg); } private: LogLevel level_; void WriteToFile(const LogMessage msg) { std::cout [FILE] static_castint(msg.level) : msg.content std::endl; } }; class MonitorLogHandler : public LogHandler { public: void Handle(const LogMessage msg) override { if (msg.level LogLevel::Warn) { ReportToMonitor(msg); } LogHandler::Handle(msg); } private: void ReportToMonitor(const LogMessage msg) { std::cout [MONITOR] msg.content std::endl; } }; class AlertLogHandler : public LogHandler { public: void Handle(const LogMessage msg) override { if (msg.level LogLevel::Error) { SendAlert(msg); } LogHandler::Handle(msg); } private: void SendAlert(const LogMessage msg) { std::cout [ALERT] msg.content std::endl; } };这里注意我让每一级处理完自己的事之后继续调用LogHandler::Handle(msg)也就是**“处理完不阻断继续往下传”这是日志链和请求校验链最大的不同。请求校验链是“谁处理了谁就接管后面不用管了”日志链则每一级都有权处理而且必须让更高级别也感知到。这是职责链一个非常重要的变体“共享处理权”的链**。如果你把“处理完就return”写死在对父类的调用里日志链就会断掉。这一点我在代码评审时见过不少人踩坑。链的组装在业务入口处进行一次class LogPipeline { public: LogPipeline() { fileHandler_ std::make_sharedFileLogHandler(LogLevel::Debug); monitorHandler_ std::make_sharedMonitorLogHandler(); alertHandler_ std::make_sharedAlertLogHandler(); fileHandler_-SetNext(monitorHandler_); monitorHandler_-SetNext(alertHandler_); head_ fileHandler_; } void Log(LogLevel level, const std::string msg) { head_-Handle(LogMessage{level, msg}); } private: std::shared_ptrFileLogHandler fileHandler_; std::shared_ptrMonitorLogHandler monitorHandler_; std::shared_ptrAlertLogHandler alertHandler_; std::shared_ptrLogHandler head_; };链上的节点用了shared_ptr保存后继组装时又保存了每个节点的shared_ptr确保在LogPipeline内部所有节点都活着。这个生命周期设计要特别小心后面第4节详细说。3.2 实战二HTTP服务做“洋葱模型”中间件链如果做过Node.js的Express或者Go的Gin肯定对中间件链不陌生。每个中间件先做前置逻辑然后调用next()进入下一个中间件等下一个中间件返回后再执行后置逻辑。C里同样可以轻松实现这个模型我用它来实现HTTP服务的统一鉴权、频率控制和日志埋点。这是我最喜欢的一个C职责链高级玩法核心在一个“递归next”上#include functional #include vector #include iostream #include string struct Request { std::string path; std::string token; }; struct Response { int code 200; std::string body; }; using Next std::functionvoid(); using Middleware std::functionvoid(Request, Response, Next); std::vectorMiddleware middlewares; void DispatchRequest(int index, Request req, Response res) { if (index static_castint(middlewares.size())) return; auto mw middlewares[index]; Next next []() { DispatchRequest(index 1, req, res); }; mw(req, res, std::move(next)); } // 中间件请求日志 auto requestLogMiddleware [](Request req, Response res, Next next) { std::cout [LOG] request: req.path std::endl; next(); std::cout [LOG] response: res.code std::endl; }; // 中间件鉴权 auto authMiddleware [](Request req, Response res, Next next) { if (req.token.empty()) { res.code 401; res.body unauthorized; return; // 不调用next直接短路 } next(); }; // 中间件业务处理 auto bizMiddleware [](Request req, Response res, Next next) { res.body hello req.path; // 业务中间件一般是最后一层不再调用next }; int main() { middlewares.push_back(requestLogMiddleware); middlewares.push_back(authMiddleware); middlewares.push_back(bizMiddleware); Request req{/api/user, }; Response res; DispatchRequest(0, req, res); std::cout final code res.code body res.body std::endl; return 0; }这个实现的魅力在于中间件既能看到请求调用next之前也能看到响应调用next之后而且通过“是否调用next”能实现短路鉴权中间件发现token为空时直接return后面的中间件就不会执行了。这在逻辑上等价于一个支持前置和后置逻辑的职责链。我在实际项目中用这个结构组合了三个中间件把统一鉴权、请求耗时统计、限流放在链上。新增中间件只需要往middlewares里push一个lambda。有一次线上需要加一个全链路追踪ID注入我加了一个中间件几行代码就完成了。必须提醒一点这个洋葱模型的代码隐藏着一个悬空引用的坑。Next捕获了DispatchRequest的局部变量req和res的引用如果某个中间件把Next保存下来稍后调用比如异步执行那这个引用就失效了。因此这种写法只适用于“同步执行、单线程顺序处理”的场景。一旦你想让中间件支持异步就得改成shared_ptr 或者把状态封装到上下文对象里千万别直接在Next里捕获栈上的引用跑异步任务否则线上崩了都不知道怎么查。3.3 实战三异步任务链与回调函数C项目里大量使用回调函数而职责链和回调结合后能处理一类很现实的问题一个异步任务要按照顺序经过多个处理阶段每个阶段完成后异步地触发下一个阶段。典型的例子是数据库写入请求先做参数预处理然后拿到数据库连接执行写入写入完成后再做结果格式化。每一步都是IO操作不能同步阻塞但又必须严格按顺序走。这时候我把“每个处理阶段”封装成一个节点每个节点接收一个“完成回调”完成回调里启动下一个节点。#include functional #include iostream #include memory #include chrono #include thread using DoneCallback std::functionvoid(bool); class AsyncStep { public: virtual ~AsyncStep() default; void SetNext(std::shared_ptrAsyncStep next) { next_ std::move(next); } virtual void Execute(DoneCallback done) { if (next_) { next_-Execute(std::move(done)); } else { done(true); } } protected: std::shared_ptrAsyncStep next_; }; class AsyncParameterCheckStep : public AsyncStep { public: void Execute(DoneCallback done) override { std::cout [Step] parameter check... std::endl; // 模拟异步校验比如RPC调用参数校验服务 std::thread([this, done std::move(done)]() { std::this_thread::sleep_for(std::chrono::milliseconds(50)); std::cout [Step] parameter check done std::endl; done(true); }).detach(); } }; class AsyncWriteDbStep : public AsyncStep { public: void Execute(DoneCallback done) override { std::cout [Step] write database... std::endl; std::thread([this, done std::move(done)]() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::cout [Step] write done std::endl; done(true); }).detach(); } };这里的DoneCallback参数本质是“链的延续”每个节点完成后回调里继续调下一个节点。发起端只需要调用链头的Execute传一个最终完成回调即可。这个模式在C异步编程里相当实用与协程、回调地狱都有关系。如果你用C20的协程可以把每个步骤换成co_await但底层逻辑仍是“责任链回调延续”。有一点要小心异步步骤里的std::function对象会被拷贝/移动当lambda捕获了shared_ptr节点时要防止循环引用。比如AsyncWriteDbStep的lambda如果捕获了this或者捕获了后继节点的shared_ptr而DoneCallback又在对象内部被保存就可能形成环。我们项目里的规避做法是lambda里尽量捕获裸指针或weak_ptr然后在使用前lock。4. 踩坑实录生命周期、链断裂、顺序陷阱与调试技巧这一节是我最想写的。职责链模式代码量不大但工程坑特别多。我至少踩过下面四类雷。4.1 生命周期智能指针不是万能的职责链节点之间的关系是“链式持有”如果用shared_ptr互相持有很容易出问题。第一种坑是链内的循环引用。如果一个节点的后继节点保存了前驱节点的shared_ptr例如要做回退逻辑而前驱又持有后继的shared_ptr两个节点就互相引用refcount永远不为0析构函数永远不会被调用。这在C里属于最经典的“循环引用”内存泄漏。排查方式是用weak_ptr替代其中的任意一方或者在析构链路里主动清理。第二种坑是把裸指针存进lambda回调里。我用std::function实现职责链时lambda经常捕获this指针。如果链的持有者已经被析构而链上的lambda还被某个异步任务持有调用时就是妥妥的野指针。我的经验法则是链本体由长期存在的manager类持有生命周期跟进程一致那捕获this没问题。链跟随请求创建、跟随请求销毁那就确保请求对象先于链销毁或者lambda捕获请求的shared_ptr。我在日志链里用shared_ptr保存所有节点在LogPipeline里又保存了一份其实形成了“双重持有”。这样设计是故意的对外LogPipeline持有了所有节点的强引用保证它们不会在链使用期间被提前析构对内每个节点持有后继的shared_ptr保证链式调用的连续性。LogPipeline析构时节点引用计数减一链式引用整条都释放没有环很干净。4.2 链断裂没有节点能处理的时候怎么办链的最后一个节点处理不了时请求会掉出链尾。如果基类的Handle默认实现就是什么都不干用户根本不会知道请求没被处理。线上最常见的问题是链上加了一个新节点类型但忘了注册到链上结果请求到了链尾被静默丢弃排查半天才发现。我的解决方案有两个第一用“兜底Handler”。链尾永远挂一个最终处理者它只做一件事记录日志并告警“此请求无节点可处理”。这样不符合预期的情况永远不会静默而是第一时间暴露。在日志系统的示例里我在基类Handle的默认实现中打印了Fallback信息就是这个目的。第二给链提供一个统一的执行结果返回值。调用链后不要只返回void而是返回“是否被某节点处理”。这样上层代码可以感知到链路执行结果超时或者落空都能追踪。我在中间件框架里就是这么做的DispatchRequest返回bool调用方能知道这是“业务正常结束”还是“链路走完了没人接”。4.3 顺序陷阱链的顺序就是业务的顺序职责链的节点顺序往往是隐式的业务需求。最典型的翻车例子是把“频率限制”放在“鉴权”后面结果未登录用户也能触发频率限制甚至被限流后影响正常用户把“幂等校验”放在“参数校验”前面非法参数带了相同幂等键居然返回了“重复提交”。我建议在组装链的代码里加注释把每个节点的执行目的写得清清楚楚并且把这个“顺序文档”放在代码评审的关注点上。如果业务对顺序极其敏感可以做一个编译期的顺序断言或者用枚举把链的位置定死而不是用vector硬push。还有一个反向思考如果你发现链上节点的顺序经常要调那说明你的“职责链”正在退化成“流水线”业务本来就需要强顺序这时候用责任链反而不合适应该退回去用直白的顺序调用。这个判断很难写进代码但作为项目负责人心里要有数。4.4 性能与调试技巧性能上虚函数职责链每个节点都有一次虚调用开销std::function链还要多一次类型擦除模板链没有这两种开销但类型爆炸。给个粗略数据单次节点的虚函数调用大约几个纳秒std::function调用跟虚函数接近但如果lambda捕获了大量上下文拷贝成本才是大头所以传参尽量用const引用和移动。调试上职责链有个通病代码分散在每个节点里发生问题时很难看清请求到底经过了谁。我的实战技巧是给每个节点加一个名字在链执行时打trace日志或者用RAII方式记录进出时间。一个轻量做法是在节点的Handle函数开头加一个原子计数器请求每经过一个节点就把节点ID追加到请求上下文这样最终日志能看到完整经过路径。这在高并发排查现场非常救命。5. 工程落地从demo到生产环境的差距很多教学代码能跑但不能直接搬进生产。我把自己在项目里落地的几个工程化要点列出来这部分是“接不接得了地气”的分水岭。5.1 头文件设计与接口抽象职责链的头文件设计比实现本身更重要。我一般把Handler基类定义在单独的头文件里比如chain_handler.h里面只包含抽象接口和必要的类型定义不include任何具体业务头文件。这样业务节点只要依赖这个抽象头文件新增节点不会导致编译链路的脆弱化。具体业务节点的实现放在.cpp里一个节点一个文件或者按模块聚合。节点之间尽量不要有直接依赖如果有共享工具函数抽成公共库。在这个基础上用CMake管理项目结构cmake_minimum_required(VERSION 3.16) project(chain_example) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(chain_core src/handler.cpp src/log_pipeline.cpp ) add_executable(chain_demo main.cpp ) target_include_directories(chain_core PUBLIC include) target_link_libraries(chain_demo PRIVATE chain_core)核心接口头文件放在include目录下外部使用方只include这些公共头不需要知道具体节点的实现细节。职责链的优点在工程上就体现为新加节点只是新增一个.cpp文件业务代码无感知。5.2 测试技巧链的单元测试和集成测试职责链的单元测试有好写的地方每个节点都可以单独构造单独喂请求验证是否被处理。不好写的点是链的整体行为。我习惯把测试分成三层节点测试构造单个节点传入不同类型请求验证它对哪些请求处理、对哪些请求继续下传。链结构测试验证链的完整性。比如遍历整条链确认没有断链、没有循环引用、节点顺序正确。端到端测试用一个真实请求走完整条链验证最终行为。在C里有一类“断链测试”特别值得写故意在链中间设置一个不处理的节点确认请求能穿透到后面的节点。如果代码里错误地把“不处理”实现成了“终止”这个测试立刻就会变红。另外建议把“链的组装”做成一个独立函数而不是散落在各个业务代码里。比如BuildLogPipeline()、BuildRequestPipeline()这样测试可以直接调用组装函数组装逻辑也只有一份避免生产环境组装和测试组装不一致的问题。5.3 与现有C项目集成的切入点把职责链引入现有C项目最稳妥的切入点是从“一旦改动收益立刻明显”的地方开始。我给你推荐三个最容易出效果的落点日志模块现在很多项目日志逻辑都是一堆if嵌套散落在各处。把日志分级、日志输出目标收敛到职责链改动小、增量明显而且不破坏对外接口。RPC/HTTP请求的前置处理鉴权、限流、参数校验、幂等逻辑。如果这些逻辑目前是顺序写在入口函数里可以直接把它们提取成中间件链这是向洋葱模型重构的最典型路径。数据访问层在数据库写入前增加一个“数据清洗链”每个清洗规则一个节点或者参考热词里提到的TDengine绑定写入流程把“准备SQL语句、绑定参数、执行写入、处理结果”串成一条链方便在中间插入监控或日志节点。我个人强烈不推荐把现有的核心业务逻辑大规模重构成职责链。职责链擅长的是“横切场景”不改变业务核心而是为业务提供前置、后置、旁路的逻辑处理。拿日志、鉴权、监控下手风险小收益明确。等你对职责链的节奏掌握了再考虑在业务异步流程里引入回调链。以我的实际经验来说职责链这个模式代码本身不难真正难的是“在哪里用、怎么组织、如何保证不出生命周期问题”。项目里所有分享给团队的设计原则里最有效的一条是新增节点时不允许修改任何已有节点代码。如果你发现自己为了让新节点适配需要回头改动链上的老节点那一定是抽象边界划错了需要停下来重新设计。这比用哪个版本的C、用shared_ptr还是unique_ptr都更重要。最后分享一个小经验有一次我为排查一个线上请求超时问题花了整整一天最后发现是鉴权中间件在调用next()之后又访问了一个已经失效的局部状态。如果当时能在一开始就遵循上述的生命周期原则其实半小时就能定位。模式是工具工程素养才是根本。希望这篇能帮你把职责链模式在C里的用法吃透用它提升代码的可扩展性也少踩几个我踩过的坑。
阅读完成 · 觉得有帮助?