ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++职责链模式深度解析:从消息校验到订单系统的实战应用

C++职责链模式深度解析:从消息校验到订单系统的实战应用 1. 为什么你需要职责链模式1.1 一段让人崩溃的C代码先从一个我真实经历过的场景说起。几年前我在维护一个游戏服务器模块里面有一段处理玩家消息的逻辑。玩家发送一条指令服务端要先判断账号是否被封禁、再判断是否在冷却时间、接着判断背包空间是否足够、再往下还有道具是否绑定、是否过期……代码长这样bool handleMessage(Player player, const Message msg) { if (player.isBanned()) { replyError(player, 账号已被封禁); return false; } if (player.isCooldown(msg.type())) { replyError(player, 技能冷却中); return false; } if (!player.bag().hasFreeSlot()) { replyError(player, 背包已满); return false; } if (msg.item().isBound() msg.item().owner() ! player.id()) { replyError(player, 该道具已绑定); return false; } // ... 真正的业务逻辑 doSomething(player, msg); return true; }这段代码看起来还挺清晰的对吧但问题出现在两个地方第一判断条件越来越多每个星期都在加新规则这个函数很快就膨胀到两三百行第二有些规则要复用比如封禁检查在登录、交易、聊天里都要用你总不能到处复制这一大坨if else吧。最要命的是这种写法把“规则的判定”和“规则的处理”死死绑在一起想调整顺序、想动态增删规则都只能改这段巨无霸代码。后来我重构的时候用的就是C中的职责链模式。它把“每个判断环节”封装成独立对象像一条流水线一样串起来请求沿着链条逐个传递直到某个环节处理掉它。这个模式不是我拍脑袋选的——它特别适合处理“多个条件都有可能处理同一请求、但具体哪个处理取决于运行时状态”的场景。1.2 职责链模式到底是什么职责链模式Chain of Responsibility是行为型设计模式之一它的核心思想很直白把可能处理请求的多个对象连成一条链请求沿着这条链传递每个对象都有机会处理请求或者把它传给下一个对象。我习惯用一个生活化的类比来解释它公司里的请假审批。你请三天假先找组长批组长觉得权限不够就推给部门经理部门经理看超过五天再往上报给总经理。每个审批人就是一个“处理节点”他要么自己拍板要么把申请继续往后传。请求请假申请自己压根不知道会被谁处理它只需要沿着链条走就行。对应到C代码里链条上的每个节点通常是一个类对象它们继承自同一个抽象基类基类里有一个指向后继节点的指针。请求到达时当前节点调用自己的处理函数如果它能处理就处理掉不能处理就调用后继节点的处理函数。这样做的好处很明显发送者和接收者解耦请求方不需要知道链条上到底有哪些处理器也知道最终是谁处理的。动态组合链条的组装完全在运行时进行你可以根据配置、玩家等级、功能开关动态拼接一条处理链。开闭原则要加一个新规则只需要新增一个处理器类在组装链时插进去完全不用改已有节点。我第一次用职责链重构那段消息处理逻辑时把每个判断封装成了独立的Handler最后组装一条链。效果立竿见影主函数从三百行瘦身到三十行新加规则只需要写一个新Handler插进链里同事看了直呼清爽。这个模式在C项目里出现的频率相当高尤其适合这种业务规则经常变动的场景。下面我把整个实现思路、代码细节、踩过的坑都展开讲讲。2. 核心机制与C实现思路拆解2.1 职责链的三个核心角色要把职责链模式用对先得理解它的三个核心角色很多人写不好这个模式就是因为没分清这三者的职责。抽象处理器Handler定义处理请求的接口通常包含一个处理方法和一个设置后继节点的方法。在C中一般实现为抽象基类提供一个纯虚函数让子类覆写。具体处理器Concrete Handler继承自抽象处理器实现具体的处理逻辑。每个具体处理器内部要有两个关键判断第一自己能不能处理这个请求第二如果不能处理怎么把请求转给后继节点。请求Request在链条上传递的数据对象它可以是普通的结构体、类对象也可以是某个方法调用本身。在C里请求类型可以是通用的基类指针也可以是模板化的任意类型。这三者的关系用一个简单的类图就能说清楚但我不画图了直接上代码。抽象处理器接口class Handler { public: virtual ~Handler() default; void setNext(std::shared_ptrHandler next) { m_next std::move(next); } virtual void handle(const Request req) 0; protected: std::shared_ptrHandler m_next; };这里有个细节值得注意setNext返回类型我故意用void后面会讲为什么很多现代C实现会改成返回链尾指针以便链式组装。2.2 构造链的两种方式手动串联与链式调用职责链模式在C里最容易讨论起来的一个点就是链到底怎么搭方式一手动串联这是最古老、最直观的做法。在客户端代码里逐个创建处理器然后用setNext一个接一个串起来auto pBanCheck std::make_sharedBanCheckHandler(); auto pCooldownCheck std::make_sharedCooldownCheckHandler(); auto pBagCheck std::make_sharedBagCheckHandler(); pBanCheck-setNext(pCooldownCheck); pCooldownCheck-setNext(pBagCheck);优点是很简单谁都能看懂。缺点是代码看起来有点啰嗦而且一旦链条节点多了组装代码就变成一长串a-setNext(b); b-setNext(c); c-setNext(d);这种重复劳动。方式二链式调用我之前提到setNext返回void就是没法链式调用。如果你把接口设计成返回后继节点的指针就能写出流畅的组装代码class Handler { public: virtual ~Handler() default; std::shared_ptrHandler setNext(std::shared_ptrHandler next) { m_next std::move(next); return m_next; } virtual void handle(const Request req) 0; protected: std::shared_ptrHandler m_next; };这样组装链就变成auto chain std::make_sharedBanCheckHandler(); chain-setNext(std::make_sharedCooldownCheckHandler()) -setNext(std::make_sharedBagCheckHandler()) -setNext(std::make_sharedBindCheckHandler());注意看setNext返回的是刚设置好的后继节点所以第一次调用返回CooldownCheckHandler紧接着再对它调用setNext继续追加节点。这种写法的好处是修改链条顺序非常直观删掉一行就能拿掉一个处理器。不过得提醒一句链式调用有它的隐患如果你在链条中间插入一个节点返回值容易搞错。我见过有同事用这种写法时把返回的节点指针弄混结果链断了请求直接没人处理出了线上问题。所以要看团队习惯如果大家都能驾驭链式调用代码会清爽很多否则老老实实用手动串联也不丢人。方式三可变参数模板批量组装现代C下还有一个更高级的玩法用可变参数模板一次性生成整条链。这个方案的好处是把“建链”的逻辑收拢到一个函数里客户端只需一行代码templatetypename... Handlers std::shared_ptrHandler makeChain(Handlers... handlers) { auto head std::shared_ptrHandler(handlers...); // 第一个作为链头 // 逐个串联 std::shared_ptrHandler cur head; (..., (cur-setNext(std::shared_ptrHandler(handlers)), cur cur-next())); return head; } // 使用 auto chain makeChain( std::make_sharedBanCheckHandler(), std::make_sharedCooldownCheckHandler(), std::make_sharedBagCheckHandler() );这个方案我用的不多因为C的可变参数模板和折叠表达式虽然强大但代码报错信息极其抽象一旦编译不过新手完全看不懂。如果你的团队都是老C玩家可以考虑否则就选前两种简单可靠职责链模式的价值在于解耦业务逻辑不在于构建链时的语法有多花哨。2.3 处理函数的推进方式返回bool还是设终止标志职责链里最容易设计出问题的地方在于“一个请求在某个节点被处理后链条要不要继续走”。一共有两种处理风格我分别说一下。风格一短路式找到第一个能处理的节点就停这种风格和if else if最像。请求沿链传递一旦某个节点成功处理整条链就结束。这种方案适合校验类场景——比如封禁检查通过了就继续下一个检查任何一个检查失败了就直接返回错误不用再往后走了。C实现时处理函数返回布尔值true表示已经处理false表示继续传递class Handler { public: virtual bool handle(const Request req) { if (canHandle(req)) { process(req); return true; } if (m_next) { return m_next-handle(req); } return false; // 没人处理缺省逻辑 } };风格二全链路式每个节点都处理一遍这种风格要求请求依次经过所有节点每个节点都做自己那一部分工作不做“短路”。它更像个管道而不是链条适合日志系统、中间件、过滤器等场景——比如一个请求进来先压缩、再加密、再记录日志、最后发送每一步都要执行。C实现时处理函数常常返回void不管当前节点能不能处理执行完了就传给下一个class Handler { public: virtual void handle(const Request req) { // 当前节点处理 process(req); // 无论是否处理过都传给下一个 if (m_next) { m_next-handle(req); } } };两种风格没有绝对好坏取决于业务需求。我还见过第三种方案用终止标志位控制链条是否继续。具体来说请求对象内部携带一个bool stop标志每个节点处理完以后判断这个标志来决定是否继续。这种方案不太推荐因为把链条的控制逻辑分散到了请求对象上职责不清而且多线程环境下标志位的传递容易出问题。2.4 为什么在C里要用std::shared_ptr管理链节点说到C实现职责链有一个绕不开的问题链上的节点用什么指针管理我在多个项目里见过几种不同的做法简单分析一下各自的坑。裸指针最原始的做法但是最危险。链的所有权不明确谁负责释放节点如果链在主函数栈上构造结束时就析构了但如果节点被多个模块共享裸指针很快就变成悬垂指针。std::unique_ptr独占所有权语义清晰。但问题是职责链的节点经常需要被多个地方引用——比如同一个检测节点可能要同时出现在多条链里封禁检查既要在登录链里又要在交易链里。unique_ptr无法复制这条链上的节点不能同时属于另一条链用起来束手束脚。std::shared_ptr引用计数管理生命周期多条链共享同一节点完全没问题。绝大多数C职责链实现都选它而且它的setNext和handle传参都方便。代价是引用计数有一定性能开销不过对于业务层处理器来说这点开销可以忽略不计。原始指针 外部容器统一管理用一个vectorunique_ptrHandler持有所有节点的所有权链里的next用普通指针。这种方案既能保证生命周期安全又避免了shared_ptr的引用计数开销适合对性能极其敏感或者节点数量特别大的场景。代价是代码复杂度上去了外部容器和链的关联关系需要维护好。我自己平时的选择是大多数业务场景用shared_ptr图省心如果是写底层框架或者游戏引擎里的高频调用链我会考虑用容器统一管理。注意不管用哪种指针有一点必须确认——链上不能存在环。如果你在某个节点里把后继节点指回前面的节点调用请求时就会无限递归直到栈溢出。我们后面讲坑的时候专门聊这个。3. 实际场景拆解用职责链重构消息校验模块3.1 场景需求与设计决策回到文章开头那个游戏服务器消息处理的例子。我当时的需求可以概括成一句话多条校验规则顺序固定规则可动态增删校验失败时立即中断并返回错误提示。这个需求对应前面说的“短路式”风格请求一旦被某个节点处理校验失败链条立即停止不再继续往下走。在设计时我做了几个决策这里展开说一下为什么决策一校验失败算“处理”成功算“继续传递”这个思路反直觉但很关键。在“短路式”链条里handle返回true应该代表“这个请求被我终结了”。对于校验链终结条件是校验失败如果封禁校验失败节点返回true链条中断主流程收到一个失败结果。如果封禁校验通过节点返回false请求继续往后传。把“成功”和“继续走”划等号“失败”和“终结”划等号代码读起来反而更顺。因为它和业务语义正好对齐这是拦截器链不是流水线链。每一个节点只负责回答“我能不能拦下这个请求”能拦下就拦拦不下就放行。决策二请求对象用具体类型还是抽象基类职责链模式在C里有个老问题不同链上的请求可能是完全不同的类型。比如登录链上的LoginRequest交易链上的TradeRequest消息链上的Message。两种方案方案A请求封装成基类指针具体处理器向下转型dynamic_cast成具体类型再处理。灵活但每次处理都要做一次RTTI运行时类型识别检查而且类型转换出错的风险很高。方案B把处理器做成模板类请求类型是模板参数。每个链条绑定一种请求类型代码更安全但会生成多份代码而且模板化的处理器没法放进同一个容器方便地动态组装。我当时的项目里不同请求类型之间有公共的部分比如都包含playerId和msgType所以最后选了方案A用dynamic_cast去解析。这里我想提醒一下如果链上请求类型单一或者处理器对你的请求类型没有强依赖优先用模板方案更安全只有需要同时处理多种请求类型时才考虑RTTI。3.2 关键代码从抽象基类到具体处理器下面我给出一个完整的最小可复现示例剥离掉游戏业务细节保留核心逻辑。假设我们在做一个订单系统创建订单前需要经过一系列校验用户是否黑名单、商品是否上架、库存是否充足。抽象基类#include memory #include iostream #include string struct OrderRequest { int userId; int itemId; int count; }; class OrderValidator { public: virtual ~OrderValidator() default; // 返回true表示拦截终结返回false表示放行 virtual bool check(const OrderRequest req) 0; void setNext(std::shared_ptrOrderValidator next) { m_next std::move(next); } protected: bool passToNext(const OrderRequest req) { if (m_next) { return m_next-check(req); } // 走到链尾都没有拦截默认放行 return false; } std::shared_ptrOrderValidator m_next; };注意这个实现里的一个巧妙之处passToNext返回的是下一个节点的check结果。如果整个链条走完了都没人拦截最后返回false代表“放行”。所以外部调用方拿到false就表示校验通过。具体处理器一黑名单校验class BlacklistValidator : public OrderValidator { public: explicit BlacklistValidator(std::initializer_listint bannedIds) : m_bannedIds(bannedIds) { } bool check(const OrderRequest req) override { if (m_bannedIds.find(req.userId) ! m_bannedIds.end()) { std::cout 用户 req.userId 在黑名单中下单被拦截\n; return true; // 被拦截 } std::cout 黑名单校验通过\n; return passToNext(req); } private: std::setint m_bannedIds; };具体处理器二商品上架校验class ProductActiveValidator : public OrderValidator { public: explicit ProductActiveValidator(std::setint activeIds) : m_activeIds(std::move(activeIds)) { } bool check(const OrderRequest req) override { if (m_activeIds.find(req.itemId) m_activeIds.end()) { std::cout 商品 req.itemId 未上架下单被拦截\n; return true; } std::cout 商品上架校验通过\n; return passToNext(req); } private: std::setint m_activeIds; };具体处理器三库存校验class StockValidator : public OrderValidator { public: StockValidator(std::mapint, int stock) : m_stock(std::move(stock)) { } bool check(const OrderRequest req) override { auto it m_stock.find(req.itemId); int available (it ! m_stock.end()) ? it-second : 0; if (available req.count) { std::cout 商品 req.itemId 库存不足剩余 available \n; return true; } std::cout 库存校验通过\n; return passToNext(req); } private: std::mapint, int m_stock; };客户端组装链int main() { // 前置数据 std::setint banned {1001, 1002}; std::setint activeItems {5001, 5002, 5003}; std::mapint, int stock {{5001, 10}, {5002, 0}, {5003, 5}}; // 组装链 auto blacklist std::make_sharedBlacklistValidator(banned); auto activeCheck std::make_sharedProductActiveValidator(activeItems); auto stockCheck std::make_sharedStockValidator(stock); blacklist-setNext(activeCheck); activeCheck-setNext(stockCheck); // 发起请求 OrderRequest req1{1001, 5001, 2}; bool rejected blacklist-check(req1); std::cout 订单1结果: (rejected ? 被拦截 : 已通过) \n\n; OrderRequest req2{1003, 5002, 1}; rejected blacklist-check(req2); std::cout 订单2结果: (rejected ? 被拦截 : 已通过) \n\n; OrderRequest req3{1004, 5001, 20}; rejected blacklist-check(req3); std::cout 订单3结果: (rejected ? 被拦截 : 已通过) \n; return 0; }输出结果用户 1001 在黑名单中下单被拦截 订单1结果: 被拦截 黑名单校验通过 商品未上架下单被拦截 订单2结果: 被拦截 黑名单校验通过 商品上架校验通过 库存不足剩余 10 订单3结果: 被拦截这段代码直观展示了职责链的几个核心特性每个校验器只关心自己的规则不知道链条上有谁。加新规则比如风控校验、限购校验只需要写一个类然后插进链里。调整顺序只需要改变组装链时的setNext调用顺序。这正是我在项目里最看重的优点业务规则变更的成本极低。3.3 装配顺序与处理器状态的设计经验说一下装配顺序的讲究。职责链里各节点的顺序往往是业务逻辑最深层的经验同一个校验器换位置结果可能完全不同。以订单校验为例我一般会把“成本最低、最容易失败”的检查放在前面。这样做的背景很实在如果一个用户在黑名单里你根本没必要去查库存库存查询可能涉及数据库访问代价高。先用内存缓存里的黑名单把人拦住能省下大量IO开销。如果是风控系统顺序就更讲究了频率限制必须放在最前面因为它最快然后是账号状态校验再是设备指纹校验最后才是业务规则校验。这个顺序是风控团队根据线上数据调优出来的不是拍脑袋定的。所以在职责链里顺序本身就是一种“可配置的逻辑”我建议把链的组装逻辑抽到一个独立工厂函数里别散落在各个调用点。另外关于处理器内部的状态有一个值得注意的细节。处理器类最好是无状态的也就是它的成员变量只包含外部注入的配置数据黑名单列表、库存表等不要包含“本次请求处理到哪里了”这类运行态信息。为什么因为职责链的处理器经常被多个线程共享如果处理器内部有可变状态必须加锁否则就是数据竞争。而无状态处理器天然线程安全这也是职责链模式在并发环境下依然好用的重要原因。如果确实需要记录处理过程中的某些中间结果应该把中间结果放到请求对象里而不是放到处理器里。4. 常见问题与排查技巧实录4.1 链断了的三种典型表现与定位方法职责链模式用久了最常踩的坑就是“链断了”——请求发出去了但没有任何节点处理它或者链路走到一半就终止了。我总结了三类典型表现。表现一请求走到链尾返回默认放行结果这种问题最隐蔽。比如校验链本意是任何一个节点拦截都要返回失败但如果链在中间某个环节接错了请求实际上从链头直接跳到了链尾或者某个后继节点是nullptr最终passToNext返回false看起来就是校验通过。而实际上该检查的都没检查。定位方法很简单在passToNext和每个check入口加一行日志打印当前类名和请求ID。跑一轮测试看日志里链的走向和预期是否一致。表现二请求被中间某个节点意外终结典型场景是某个处理器的canHandle判断写得太宽。比如商品校验里本来只校验“上架状态”结果判断条件成了“只要商品ID在集合里就拦截”这就误伤了正常商品。排查方法是检查每个节点的“放行条件”和“拦截条件”是否都正确尤其是边界值——空集合、0值、无效ID等。表现三整个程序直接崩溃或死循环通常是链上出现了环或者处理函数里有递归调用导致栈溢出。定位方法是在每个处理函数的入口打印一条足够长的标识日志如果循环打印同一个类的日志就说明形成了环。另一种可能是指针管理出了问题比如某个节点已经被reset了但链上还持有它的裸指针调用时就悬垂了。4.2 动态增删节点时如何处理引用关系职责链的卖点之一是动态组合但动态组合也带来了生命周期管理的难题。举个例子你有一个长度校验链某天你在运营后台配置了一个新规则“骨灰级用户跳过冷却校验”需要在运行时往链中间插入一个节点。如果用shared_ptr管理插入操作本身不难找到插入位置的前驱节点 A让 A 的next指向新节点 B让 B 的next指向 A 原来的后继 C。难点在于如果原来已经有其他代码持有了 C 的shared_ptr在 A 断开对 C 的引用之后C 的引用计数可能归零直接被销毁。如果某个处理器还持有 C 的裸指针就悬垂了。我处理这个问题的经验有两条第一链上的节点尽量全部用shared_ptr不要混用裸指针这样引用计数能自动维持生命周期第二如果链很复杂、增删频繁考虑引入weak_ptr来做观察者某个节点想要获取后继节点做额外操作时先用weak_ptr::lock()间接触达避免悬垂。还有一点动态插入节点时别忘了检查新节点是否能够独立处理请求。有些处理器构造时依赖外部配置如果配置缺失它的check可能直接返回异常或未定义结果。所以动态加链时最好把“节点能否加入链”做成一个校验函数加链前先验证一遍。4.3 调试职责链时最好用的工具调试职责链最土的方法其实最管用在每个节点的入口和出口加日志打印当前节点类名和处理结果。我在代码里会采用统一格式的日志宏方便用脚本分析链路走向#define LOG_CHAIN(handlerName, req, result) \ std::cout [Chain] handlerName \ | userId req.userId \ | itemId req.itemId \ | result (result ? BLOCK : PASS) std::endl;在每一个check函数里入口打一行出口打一行。跑一轮测试整个请求的链路走向就清清楚楚了。如果日志量太大还有一种方式用条件断点。在passToNext里加一个永远不会命中的断言然后在调试器里设置条件断点只对特定userId生效这样就能精确定位到某个请求在哪个节点出了问题。实操心得职责链最让人头疼的就是“链太长看不出谁改了请求内容”。如果你发现请求对象在经过某个节点后内容变了但业务上不该变优先查这个节点里是否不小心修改了请求对象的成员。我遇到过一次某个校验器顺手把req.count重置成1了排查了好久才找到。4.4 性能优化职责链是否适合高并发场景说到性能很多C开发者第一时间会担心职责链模式层层虚函数调用会不会拖慢速度实话说在业务层做校验这个开销完全可忽略。一次虚函数调用的开销大约是几纳秒到几十纳秒一条链就算有十个节点加起来的耗时也远小于一次数据库查询或网络IO。真正的性能瓶颈不在虚函数调用而在每个处理器内部做的事情。但我确实见过有人在游戏服务器的高频路径上滥用职责链导致性能劣化的情况。原因是每个处理器都要做dynamic_cast而RTTI的转换开销和被转换对象的继承深度成正比链长了、请求类型复杂了成本就上去了。如果你在高频路径上使用职责链我给三个优化建议能用模板就用模板模板方案没有虚函数调用也没有RTTI开销编译器会直接内联。请求对象用紧凑数据比如用int类型ID而不是字符串做条件判断。链尽量静态化如果一个链的装配顺序在启动时确定后不再变化可以把它注册成一个函数指针数组或者一个std::array用索引跳转代替指针跳转性能更好。还有一个更激进的方案不用虚函数改用std::function构建链。每个节点是一个可调用对象内部用函数指针代替虚函数表调用开销更低而且可以用lambda表达式快速构造处理器using CheckHandler std::functionbool(const OrderRequest); // 建链 std::vectorCheckHandler chain; chain.push_back([](const OrderRequest req) - bool { // 黑名单逻辑 return false; // pass }); chain.push_back([](const OrderRequest req) - bool { // 库存逻辑 return false; }); // 执行 bool rejected false; for (const auto h : chain) { if (h(req)) { rejected true; break; } }这种写法更轻量但代价是丢掉了类的封装能力。如果处理器内部逻辑简单只是几条条件判断用std::function反而更清晰如果处理器包含复杂状态和多个辅助函数还是用类实现更合理。5. 职责链模式的适用边界与实战心法5.1 什么样的场景适合用什么样的场景千万别用职责链不是万能的用错地方反而增加复杂度。根据我的经验适合用的场景有这么几个特征有多个条件可能处理同一请求且到底哪个条件生效取决于运行时状态规则的优先级或顺序可能变化希望用配置而不是硬编码控制规则经常增加希望不改动已有代码就能扩展新功能逻辑上天然是流水线/关卡结构比如多层审批、逐级上报、过滤器链、中间件管道。反过来这几个场景就很不适合用请求只会被唯一节点处理且规则完全不会变化——直接用if else或switch更直白处理严格依赖前一个节点的计算结果——职责链模式下每个节点只知道前面有节点但并不知道也无权访问前一个节点的内部状态强行共享状态要么把状态塞进请求对象里要么搞全局变量都别扭节点数量少且永不增加——一个拥有两个节点的“链”就叫过度设计。对执行顺序有极其苛刻的性能要求——比如每纳秒都要控制在常数级的底层网络库就不该用虚函数调用来处理数据包。我见过最典型的滥用案例是有人把所有业务判断都抽象成职责链结果一个只有两个判断的逻辑也写了五个类。这种代码维护起来比if else痛苦得多因为你要跳好几个文件才能看全整条链路。5.2 和策略模式、装饰器模式的区别很多初学者容易把职责链和策略模式、装饰器模式混在一起这里我做一个简单对比。职责链 vs 策略模式策略模式是在一组算法里选一个作为整体替换。职责链是让多个处理器依次尝试直到某个处理器接管。如果用做饭类比策略模式是你选择“今天用烤箱还是炒锅”而职责链是“先洗菜再切菜最后炒菜每一步都可能被某个环节接手”。职责链 vs 装饰器模式装饰器模式也是链式结构但它是在同一个对象上逐层包装增强功能每一层都会执行自己的逻辑然后调用下一层。职责链强调的是“传递和终止”装饰器强调的是“增强和转发”。说直白点装饰器每一层都干活职责链找到能干活的节点就停下。看一个简单判断方法如果每个节点都必须执行比如日志中间件很可能你在写装饰器或者管道如果每个节点只是“有机会处理、有机会跳过”那就是职责链。5.3 组件化设计的建议把链做成可配置的到了大型C项目里职责链往往不是自己手写的而是配合配置系统使用。我的建议是把链的组装过程做成可配置的比如用一个工厂类根据配置项动态创建节点并组装。配置驱动的好处非常明显运营想调整规则顺序直接改配置文件不用重新编译代码。这在游戏服务器、电商风控等领域几乎是刚需。一个简单的示范class ValidatorFactory { public: std::shared_ptrOrderValidator buildFromConfig(const std::vectorstd::string ruleNames) { std::shared_ptrOrderValidator head; std::shared_ptrOrderValidator current; for (const auto name : ruleNames) { auto validator createValidator(name); if (!validator) continue; if (!head) { head validator; current validator; } else { current-setNext(validator); current validator; } } return head; } private: std::shared_ptrOrderValidator createValidator(const std::string name) { if (name blacklist) return std::make_sharedBlacklistValidator(loadBlacklist()); if (name active) return std::make_sharedProductActiveValidator(loadActiveItems()); if (name stock) return std::make_sharedStockValidator(loadStock()); return nullptr; } };注意这个实现里的一个细节current-setNext(validator)之后current要立刻更新为新节点。这就是我在链式调用组装时提到过的坑之一用循环组装链时要格外注意当前游标的推进。5.4 项目落地后的维护建议最后分享几条我在实际项目中积累的维护经验。第一每条链都要有一个名字。不管是日志、监控还是异常上报能直接看出是哪条链上的哪个节点出了错排查问题的速度会快很多。我习惯在抽象基类里加一个虚函数name()每个具体处理器返回自己的类名。第二建链和调用链要分开。建链一般发生在初始化阶段调用链发生在业务运行阶段。不要把建链逻辑写进业务代码里否则链的结构散落各处根本没法维护。第三时刻记得给链尾留兜底逻辑。请求走完整个链都没人处理的情况一定要有清晰的行为是抛异常、记日志、返回默认值还是干脆忽略我最开始写职责链时忽略了兜底结果线上出现了一批“没有任何节点处理但系统默认成功”的请求差点出大事。后来我统一规定链尾返回成功要打WARN日志提醒开发者这里可能有遗漏的规则。第四写单元测试时针对每个节点单独测一条单节点链。职责链模式天然适合测试——每个处理器只依赖注入的配置和请求对象没有全局状态可以很方便地构造单节点链验证核心逻辑。整条链的集成测试再单独写覆盖节点之间的传递和短路行为。我个人在实际操练中的体会是职责链这个模式本身不复杂复杂的是怎么在真实项目里克制地使用它。它像一把手术刀用好了能精准分离业务规则用不好就会在代码库里切出层层叠叠的抽象让后来者头晕目眩。如果你正在写一段越来越长的if else if不妨停下来想一想是不是该引入一条链了。而如果你已经用上了职责链也希望上面的经验能帮你避开一些我当年绕了很久的弯。
返回列表