恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++职责链模式实战:用链式管道重构复杂流程
首页
资讯中心
/
C++职责链模式实战:用链式管道重构复杂流程
C++职责链模式实战:用链式管道重构复杂流程
发布时间:2026/10/4 19:04:39
1. 职责链的定位它到底解决了什么问题1.1 一个非常常见的“回调地狱”先聊一个场景做过后端或客户端开发的应该都遇到过。你有一个请求进来需要依次做日志记录、参数校验、权限检查、业务处理、结果上报最后把异常兜底。很多人第一版会这样写void handleRequest(const Request req) { logRequest(req); if (!validateParams(req)) { return sendError(invalid params); } if (!checkPermission(req)) { return sendError(forbidden); } auto result processBusiness(req); if (!result.success) { reportFailure(result); return; } reportSuccess(result); }这段代码本身没问题但一旦需求开始变化问题就来了。新增一种渠道的请求需要跳过权限校验某个接口要额外做风控某些请求失败后要重试三次。每加一个逻辑函数就膨胀一截if 嵌套越来越深不同业务之间的逻辑开始互相纠缠。几个月后再看这个函数已经没人敢动了改一行可能导致另一个业务挂掉。这就是典型的“面向流程编程”遇到的困境。处理链的各个步骤强耦合在一个函数里顺序写死没有扩展点没有中途中断的能力也没有复用单个环节的通道。职责链模式解决的就是这个问题把每一个处理步骤拆成独立的节点按顺序串成链条请求沿着链条一路走下去每个节点决定自己要不要处理、能不能中断、要不要继续传递。1.2 职责链模式的核心原则与适用边界职责链模式在 GoF 的《设计模式》里定义是让多个对象都有机会处理请求从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链并沿着这条链传递请求直到有一个对象处理它为止。翻译成人话一个请求从链头进去每个节点看到请求后自己判断能处理就处理不能处理就往后传处理完了要么结束链条要么继续传。关键在于请求方根本不需要知道链条里到底有谁、有多少个节点它只要把请求丢进链头就行。但要注意这个模式不是万能药。它适合的场景有几个特征处理步骤可以清晰拆分成独立单元、步骤之间存在固定的先后顺序、可能出现“某个节点拦截后不需要后续节点处理”的情况或者你希望在不修改调用方代码的前提下动态增删处理步骤。反过来如果所有步骤都必须无条件执行、没有中断需求或者各步骤之间需要深度共享局部状态那用职责链反而把简单问题搞复杂了不如老老实实写循环或者顺序调用。1.3 我从实际项目里总结的选用标准我在工程里判断该不该用职责链一般看三个维度。第一步骤数量少于三步的流程直接平铺就行引入职责链属于过度设计多于五步的流程强烈建议拆链。第二变更频率如果这个流程每个月都有新规则加入比如新的校验策略、新的埋点要求那职责链能让你只追加节点、不改核心代码。第三是否有多套流程变体同一份数据处理逻辑在 A 渠道需要五步在 B 渠道只需要三步职责链可以预先组装成不同的链运行时动态选择。我自己踩过的一个坑是一开始为了“模式而模式”把一个只有两步的简单读写流程硬拆成职责链结果节点之间来回传上下文代码量翻了一倍调试起来也很痛苦。后来把这两步合并回普通函数整个世界清静了。所以我的经验是职责链是一个“规模越大越划算”的模式小场景别硬上。2. C环境下的链式设计从接口到存储2.1 接口设计的第一版抽象基类加 next 指针在 C 里实现职责链最直接的方式是定义一个抽象基类每个节点持有下一个节点的指针handle方法里决定是否调用下一个。#include memory #include iostream class Handler { public: virtual ~Handler() default; void setNext(std::shared_ptrHandler next) { m_next std::move(next); } virtual void handle(const Request req) { if (m_next) { m_next-handle(req); } } protected: std::shared_ptrHandler m_next; };这里我故意用了shared_ptr后面会讲为什么不用unique_ptr。第一版接口看起来很朴素但它有一个隐含的问题handle返回void意味着链上的节点只能“传递给下一个”却没法告诉链条“我已经处理完了后面不用再跑了”。如果整个链条是纯日志链、纯过滤链全部节点都必须依次执行void没问题。但一旦出现权限校验失败需要短路或者某个节点找到了目标需要停止传递这种接口就非常别扭。你只能在节点内部抛异常来中断或者用一个成员标志位来记录状态这两种方案都很丑。2.2 进阶接口返回 bool 来控制在链路上的短路更好的做法是让每个节点的处理函数返回一个布尔值true代表“继续传递”false代表“到此为止”。用这个概念重写接口class Handler { public: virtual ~Handler() default; void setNext(std::shared_ptrHandler next) { m_next std::move(next); } bool process(Request req) { bool shouldContinue handle(req); if (shouldContinue m_next) { return m_next-process(req); } return shouldContinue; } protected: virtual bool handle(Request req) 0; private: std::shared_ptrHandler m_next; };这样链条的行为就非常清晰了每个节点处理完返回true就继续传返回false就中断。日志节点通常返回true校验节点失败时返回false业务处理节点完成后一般也返回false因为后面不需要再有动作了。实际项目中我还会在process里加一层try-catch让单个节点抛异常时可以选择中断整条链或跳到兜底节点这取决于你的容错需求。但要注意异常中断会让链上的状态清理变得复杂后面单独讲。2.3 用 std::function 代替继承类型擦除的取舍抽象基类的方案继承关系清晰但有时候为了一个方法就去写一个派生类会显得非常啰嗦。C 开发者很快就想到了用std::function来做类型擦除把每个节点变成一个可调用对象#include functional #include vector using HandlerFunc std::functionbool(Request); class Pipeline { public: void append(HandlerFunc func) { m_handlers.push_back(std::move(func)); } bool run(Request req) { for (auto func : m_handlers) { if (!func(req)) { return false; } } return true; } private: std::vectorHandlerFunc m_handlers; };这段代码比继承方案清爽太多了。你想加一个日志节点一行代码就够了pipeline.append([](Request req) { std::cout log request: req.id std::endl; return true; });不需要新建类、不需要继承、不需要纠结虚函数。这正是我在“热词”里看到 C 开发者频繁讨论回调函数、lambda、std::function 的原因——在实际项目里std::function方案的使用频率远高于抽象基类方案。但这个方案也有代价。std::function本身有虚函数调用的开销具体看实现而且用它做职责链时每个节点都是一个独立的闭包节点的“身份”不再明显。如果链上每种节点类型都需要保存大量状态闭包会显得局促这时反而应该回到类方案。我的习惯是短小、无状态、可用 lambda 表达的节点用std::function长逻辑、有独立状态、需要被多个链复用的节点用类派生。3. 高级应用一请求处理管道与审计日志3.1 一个可落地的请求处理管道设计聊完基础设计来看一份完整的实战代码。假设你正在做一些需要处理上报数据的程序类似数据采集网关、游戏服务器、或者客户端 SDK 的上报管道每条请求要经过清洗、鉴权、入库、审计四个阶段。用职责链实现struct Request { int userId 0; std::string action; std::string payload; bool authorized false; bool persisted false; };定义节点的处理函数为HandlerFunc std::functionbool(Request)然后依次构建链Pipeline pipeline; pipeline.append([](Request req) { // 清洗节点去掉 payload 里的空白和非法字符 std::string cleaned; for (char c : req.payload) { if (c ! \n c ! \r c ! \0) { cleaned c; } } req.payload cleaned; std::cout [clean] payload cleaned, size cleaned.size() std::endl; return true; }); pipeline.append([](Request req) { // 鉴权节点userId 小于 0 视为非法请求不继续 if (req.userId 0) { std::cerr [auth] rejected: invalid user req.userId std::endl; return false; } req.authorized true; std::cout [auth] user req.userId authorized std::endl; return true; }); pipeline.append([](Request req) { // 业务入库逻辑这里用打印模拟 std::cout [persist] storing action req.action std::endl; req.persisted true; return false; // 入库完成无需后续处理 }); pipeline.append([](Request req) { // 审计节点只有前面的节点都通过时才会执行 std::cout [audit] audit record: user req.userId action req.action std::endl; return true; });运行后你会发现当非法请求走到鉴权节点返回false时后面的入库和审计都不会执行。这就是职责链的短路能力在实际管道设计中非常有用。那什么时候需要审计节点即使鉴权失败也执行呢比如你需要记录所有非法访问日志。方案有两种一是把审计节点提前到鉴权节点之前二是改造Pipeline::run让它支持finally语义的收尾回调。我更倾向于在管道中加一个回调注册接口pipeline.setOnFinally([](const Request req, bool success) { if (!success) { std::cout [security-log] failed request from user req.userId std::endl; } });这样既能利用短路能力减少无用计算又能保证关键日志不丢。3.2 链的复用与动态增删细节管道设计完成后你会发现一个特别实用的能力你可以预先注册多套管线根据不同的请求类型选择不同的链。比如上报数据里有两种请求一种是普通日志上报另一种是敏感操作上报。敏感操作上报要在鉴权之后追加一个风控节点Pipeline normalPipeline; Pipeline sensitivePipeline; // 两者都先注册清洗、鉴权节点 // ... sensitivePipeline.append([](Request req) { bool risky (req.action delete_all || req.action grant_admin); if (risky) { std::cout [risk] high-risk action detected: req.action std::endl; } return true; // 只做标记不中断 });在构建管道时重复的节点可以通过函数封装或工厂方法复用而不用把节点实体拷贝到两个容器里。这也是很多项目把职责链和工厂模式搭配使用的原因——工厂负责根据配置组装不同的链职责链负责执行具体步骤。我实际用下来有个心得不要在链注册之后再频繁调append那是灾难。链的配置应该在启动阶段集中完成运行时只做“选择哪条链执行”这个动作。如果你确实需要运行时增删节点建议给Pipeline加一个removeFirstOccurrence之类的方法但一定要做好并发控制否则链在执行中突然少了一环问题非常难排查。3.3 让参数校验节点返回更丰富的错误信息很多版本的职责链示例里节点只返回bool但这在实际工程里远远不够。用户提交的参数有问题你起码得告诉用户哪里有问题、需要怎么改。所以我一般会在Request里挂一个错误收集器或者让HandlerFunc的返回值变成一个结果对象struct ProcessResult { bool ok; int errorCode; std::string errorMessage; }; using HandlerFunc2 std::functionProcessResult(Request);每个节点处理失败时写入具体的错误码和消息。链尾或者调用方拿到ProcessResult后直接作为接口返回值抛出用户就能看到“字段 age 不能大于 150”而不是一句干巴巴的“请求失败”。这一点在我看来是职责链从“玩具代码”走向“生产代码”非常关键的一步。4. 高级应用二命令分发与权限拦截器4.1 把职责链和命令模式组合起来另一个高频场景是命令分发。简单说就是程序收到一条消息或指令需要根据指令类型路由到不同的处理逻辑。很多人用一条switch-case写到天荒地老每加一种新指令就要修改分发函数还要小心忘了加break。用职责链重构每次新指令只需要新增一个节点。考虑一个终端模拟器或者游戏引擎的命令系统。玩家输入/move 10,20或者管理员输入/ban user1。每种命令都对应一个处理器节点节点内部先检查命令前缀是否匹配不匹配就返回true继续传给下一个处理器。using CommandHandler std::functionbool(const std::string cmd, Player player); class CommandDispatcher { public: void addHandler(CommandHandler handler) { m_handlers.push_back(std::move(handler)); } bool dispatch(const std::string cmd, Player player) { for (auto h : m_handlers) { if (h(cmd, player)) { return true; } } std::cout [dispatcher] no handler matched: cmd std::endl; return false; } private: std::vectorCommandHandler m_handlers; };注意这里和前面管道的差异命令分发的节点一旦匹配成功就表示“这件事情我来处理”然后直接返回不应该再传给后续节点。所以命令分发的语义和过滤链正好相反——过滤链是“处理完继续传”命令链是“匹配上就停下”。如果你在同一个链里同时有“管道节点”和“命令节点”一定要在节点内部用返回值把语义表达清楚否则特别容易混淆。4.2 权限拦截器链的写法与常见坑权限校验是职责链特别擅长做的事情。我见过很多权限系统各个接口的校验逻辑散落在业务代码里一会儿查角色一会儿查 token一会儿查 IP 白名单。用职责链可以把这些规则一条条挂起来struct AccessContext { std::string token; std::string ip; std::string resource; int requiredLevel 1; int userLevel 0; }; class AccessControlChain { public: bool check(AccessContext ctx) { for (auto node : m_nodes) { if (!node(ctx)) { logDenied(ctx); return false; } } return true; } private: std::vectorstd::functionbool(AccessContext) m_nodes; };常见的坑有三个。第一节点顺序至关重要。token 有效性检查一定在最前面因为后续的所有判断都依赖“这个用户是谁”。如果让 IP 白名单放在最前面一个 IP 合法的请求可能带着失效 token 穿透到后面的节点浪费性能且语义混乱。第二权限规则之间有依赖关系。例如“管理员可以访问普通用户可以访问自己的数据”这种规则如果普通用户判断写在前面管理员会被误拦截。这种时候节点内能不能拿到上下文里的完整用户信息就非常关键所以AccessContext里最好只放原始数据让每个节点自己推导避免节点之间偷偷摸摸地改状态。第三鉴权失败后的日志记录。很多团队喜欢把日志写在每个节点里结果一条请求被拒日志刷了三条因为前面几个节点都各自记了一次。更好的方式是在AccessControlChain::check返回false的出口处统一记录节点只管返回布尔值。4.3 数据写入链以 TDengine 绑定写入为延伸场景热词里反复出现tdengine、taos_stmt_prepare、c 绑定写入数据库我知道不少 C 开发者最近在处理时序数据库的写入。其实数据写入场景同样适合职责链。想象一次写入操作前置需要做数据完整性检查、时间戳修正、批量大小判断、写入失败重试。把每件事拆成链节点清晰且容易扩展。假设你正在使用 TDengine 的绑定参数接口写入数据链上会有一个节点专门负责taos_stmt_prepare一个节点负责绑定参数一个节点负责taos_stmt_execute一个节点负责错误重试pipeline.append([](WriteTask task) { // 时间戳修正为空时用当前时间 if (task.timestamp 0) { task.timestamp time(nullptr); } return true; }); pipeline.append([](WriteTask task) { // 绑定参数前的缓冲检查 if (task.values.empty()) { std::cerr [write] empty values, skip std::endl; return false; } return true; });这里职责链最大的优势是可以在不改动数据库写入核心代码的前提下通过增删节点来适配不同库、不同写入策略、不同容错级别。比如某个环境不需要重试直接去掉重试节点即可。这比在写入函数里写一堆 if 分支要干净得多。当然使用这种绑定参数写入时节点之间要特别注意taos_stmt对象的生命周期管理职责链节点最好只持有引用所有权交给最外层的任务对象避免链还没跑完句柄就被析构的诡异问题。5. 性能、生命周期与并发优化5.1 别让职责链成为拷贝灾难C 开发者用职责链最容易踩的性能坑就是节点之间传值传得特别欢。一个小小Request对象里面挂着字符串、向量、嵌套结构体每个节点进来先拷贝一份链上有十个节点就拷贝十份数据量一大性能肉眼可见地崩。解决办法很简单统一传引用。Request、const Request混着用要看节点是否需要修改数据。只读节点用const Request需要改写数据的节点用Request。返回值如果是错误信息这种轻量数据直接返回ProcessResult没问题但如果返回值要携带整个请求的副本就要考虑改用状态码加上下文引用的方式避免不必要的整体拷贝。我还喜欢在Request里用std::string_view来引用外部日志缓冲区的内容进一步减少字符串拷贝。这一点在处理高频日志上报时效果非常明显。一句话总结职责链本身只是指针跳转和虚函数调用开销不大真正拖垮性能的往往是你对数据的搬运方式别把锅甩给模式。5.2 unique_ptr 还是 shared_ptr链的所有权模型很多初学者会问链上的下一个节点指针到底应该用什么智能指针。这个问题要分情况。如果整条链像一个流水线链头运行完就销毁节点之间绝对不存在“两个链共享同一个节点”的需求那么用unique_ptr是最合理的。它表达的是独占所有权链析构时所有节点自动释放没有引用计数开销。组装方式如下auto logger std::make_uniqueLogHandler(); auto auth std::make_uniqueAuthHandler(); auto biz std::make_uniqueBusinessHandler(); logger-setNext(std::move(auth)); auth-setNext(std::move(biz));但问题来了setNext本身需要持有一个原始指针还是所有权呢如果用unique_ptr转移代码写起来就会比较繁琐而且一个节点同时被两条链引用就做不到了。所以当我需要复用节点时会换成shared_ptr让多个链共享同一个验证节点实例内存只存一份逻辑也统一。我的经验法则是大部分内部管道用unique_ptr就够减少心智负担如果节点确实被多处引用、或者你希望将来动态重组链就换成shared_ptr。记住智能指针的选择本质上是在“独占所有权”和“共享复用”之间做权衡没有绝对正确的答案。5.3 多线程环境下安全执行链路职责链模式本身不关心线程但实际工程里链常常被多个线程同时使用。比如服务器上有 16 个 worker 线程每条线程都在调用同一个Pipeline::run。这就要分两种情况来看。如果链上的节点都是无状态的也就是不修改成员变量、只处理传入的Request那么并发执行完全安全std::vector里的HandlerFunc对象只读随便并发。如果某个节点内部持有缓存、计数器、连接池等可变状态那必须自己做同步职责链不会替你解决线程安全问题。一个特别容易出问题的设计是“节点内建互斥锁”。比如一个统计节点用std::mutex保护计数但链上有另一个节点在嵌套调用同一链条就会发生死锁。遇到这种情况把共享状态拿出来放到Request里或者放到一个独立的线程安全统计器里比在节点里加锁靠谱得多。我在实际项目里还会为每条线程准备独立的链实例而不是所有线程共享同一条链。代价是节点对象建立多次收益是整个链完全无锁、无共享、无等待。如果你的链节点本身很轻量强烈建议走这条路。5.4 用移动语义优化链的组装过程链的组装最好一次性完成不要在运行期频繁增删。因此组装链的过程可以放心地用移动语义来传输节点Pipeline createPipeline() { Pipeline p; p.append(make_nodeLogNode()); p.append(make_nodeAuthNode()); p.append(make_nodeActionNode()); return p; }这里append接收HandlerFunc的右值引用内部std::move进容器。返回Pipeline时走移动构造整个组装过程不产生无谓的拷贝。运行期只调用Pipeline::run在这个阶段就不要有任何容器扩容、动态分配了。实测下来一个 8 节点的链请求处理纯开销大约在几十纳秒到几百纳秒级别相对于业务逻辑来说几乎可以忽略。如果你有人在群里说“职责链性能太差”那他大概率是在节点里写了什么重活或者是节点间拷贝了大对象而不是模式本身的问题。6. 常见误区与排查清单6.1 误区一职责链必须用继承实现不少教程都把职责链画成一个抽象的Handler类加一堆派生类很多读者就以为这是唯一写法。实际上在现代 C 里std::function加std::vector的方式更常见、更灵活。继承方案适合节点自带大量私有行为、需要固化为类的场景函数式方案适合轻量、快速、组合灵活的管道场景。两者没有谁替代谁一切以业务需求为准。我见过最诡异的代码是用一个抽象基类派生了十个节点每个节点里都写满了 lambda 不允许写的复杂分支阅读体验极差。这种时候倒不如回归到std::function每个节点写一个具名函数清晰多了。6.2 误区二职责链可以替代所有 if-else也要泼一盆冷水。职责链不是用来无脑替代if-else的。两个分支就能解决的分流逻辑用了职责链反而让人找不着北。我见过一个项目判断一个数是正数还是负数都用职责链写每个节点里一个 if看完直接血压拉满。职责链擅长的是“同一个请求依次经过多道独立工序”的处理而不是“根据不同条件走不同分支”的路由。路由用switch或map就够了。两者如果混在一起用你的链会变得既像管道又像路由器每个节点既要做条件判断又要做数据加工维护成本成倍增长。6.3 常见问题速查与排查思路问题现象可能原因排查思路链上后面的节点没有执行前面的节点返回了false检查每个节点的返回值以及“继续”和“中断”的语义是否被混淆Request里的数据在某个节点后丢失某节点拷贝了局部副本但没写回引用统一传引用杜绝在节点内对参数进行整体值拷贝链的执行顺序和注册顺序不一致代码中多个地方向链上append了节点检查链的注册位置建议把装配代码集中在工厂函数里并发执行时数据错乱节点内共享了可变状态要么节点无状态化要么对共享状态单独加锁要么为每线程准备独立链内存泄漏或悬空引用智能指针选型错误节点生命周期混乱检查setNext的所有权语义确认链的持有者是外层容器调试时不知道哪个节点拦截了请求节点没有日志或错误上下文在Pipeline::run里记录当前节点序号和返回值或统一打日志排查职责链问题最重要的工具就是日志。我在实际开发中一定会给Pipeline::run增加一个可选的onNodeEnter回调调试时把每个节点的执行情况打出来线上就关掉。这个功能成本很低但能帮你少掉一半头发。6.4 三个必须养成的实操习惯第一链上节点的颗粒度要均匀。日志、校验、业务处理、数据上报都应该是一层的概念不要出现一个节点做了所有事情、另一个节点只是打印一个空行的悬殊结构。颗粒度不均链就失去了“拆解”的意义。第二节点内不要藏“隐式依赖”。比如权限节点的执行隐含要求前面的鉴权节点已经运行过这种依赖如果不写清楚后面的人随便调换节点顺序就会踩坑。我的做法是在节点开头检查前置条件不满足直接返回错误并在错误信息中写明“缺少前置处理步骤”。第三给链写单元测试。职责链的每个节点都是独立的非常适合写单测。我通常的做法是每个节点分别测试输入输出再测试完整的链顺序和中断行为。做到这一步重构的时候非常有底气。7. 最后分享一点个人心得写了这么多年 C职责链模式是我用得最频繁的几种设计模式之一但我也很清楚它的价值不在模式本身而在它逼迫你思考一件事你的处理流程到底能不能被拆解每个步骤之间到底有没有隐形的耦合能不能做到“加需求只加节点、改需求只改节点、删需求只删节点”实际动手时我的建议是先用std::function的管道版本把流程跑通不要一上来就建类继承。跑一段时间后如果发现某些节点确实太长、太复杂再提取成具名类节点也不迟。另外给每个节点起好名字非常关键别用node1、node2这种直接写LogRequestNode、ValidateAuthNode、WriteToDatabaseNode读代码的时候一目了然。如果你在项目中尝试职责链建议先从一个小模块开始比如把请求日志和参数校验抽出来体会一下“新增一个节点”比“修改一个函数”优雅多少。等尝到甜头再逐步把更大的流程拆成链条。记得控制节点数量超过十个节点就考虑分组或重新设计别让链本身成为新的泥潭。希望这些经验对你有用。