前阵子我接手了一个历史包袱比较重的服务,里面有一段典型的经典职责链实现:抽象基类、手工串链表、每个节点自己持有下一个节点的指针。代码跑是能跑,但每次加一个处理节点,都得上上下下翻半天,改错一个SetNext的调用顺序,线上就是事故。后来我把这段东西重构成了基于std::function的职责链变体,逻辑一下子清爽了,新增节点只需要在构建阶段多注册一行,链路顺序也一目了然。
这也是我这篇文章想聊的:C++ 里的职责链模式,远不止教科书上那一种写法。根据链的组织形式、节点的协作方式、终止条件的不同,它至少能演化出四五种变体。每种变体都有自己的适用场景和隐藏成本。这篇文章不会跟你复述概念,而是从实战出发,把经典实现踩过的坑、各种变体的代码形态、选型依据,以及我在真实项目里积累的排查经验,一次性都讲清楚。
读这篇文章的人,不需要懂 C++ 底层编译原理,但最好写过一段时间 C++,用过 STL 容器和智能指针。因为文中讨论的问题,比如生命周期、异常安全、调用顺序,都是你真正在工程里躲不开的东西。
1. 经典职责链的痛点:为什么要折腾变体
1.1 教科书实现长什么样
先看一段最标准的经典职责链实现,GoF 书里的结构搬到 C++ 大概是这样:
class Handler { public: virtual ~Handler() = default; void SetNext(std::shared_ptr<Handler> next) { next_ = std::move(next); } void Handle(int request) { if (CanHandle(request)) { Process(request); } else if (next_) { next_->Handle(request); } } protected: virtual bool CanHandle(int request) const = 0; virtual void Process(int request) = 0; private: std::shared_ptr<Handler> next_; };然后业务方会做几个子类,比如AuthHandler、RateLimitHandler、CacheHandler,各自实现CanHandle和Process,最后在主流程里挨个串起来:
auto auth = std::make_shared<AuthHandler>(); auto rate = std::make_shared<RateLimitHandler>(); auto cache = std::make_shared<CacheHandler>(); auth->SetNext(rate); rate->SetNext(cache); auth->Handle(request);这套实现逻辑上没毛病,也确实解决了一个问题:请求发起方不需要知道处理链的完整结构,它只需要把请求丢给链头就行。但等你真的在一个模块里维护十几个这样的处理器,问题就开始冒头了。
1.2 四个典型痛点
第一个痛点是侵入性强。所有处理节点必须继承同一个抽象基类,哪怕你只是想临时加一个小逻辑,也得为它新建一个类文件、写两个虚函数、处理关联合法性、再考虑能不能复用其他工具类。这在小功能场景下严重过度设计。我见过不少同事为了一段十几行的判重逻辑,硬是建了一个DedupHandler类,头文件和实现文件加起来上百行,就为了挂到链上去。
第二个痛点是链结构的组装散落在业务代码里。如果没有一个统一的地方来组装链条,每个业务模块都会各自SetNext一次,链到底长什么样,最后的顺序是什么,靠人眼根本看不出来。更麻烦的是,一旦链上的顺序出现分歧,比如两个同事加节点时都插到了链头,版本合到一起后一部分请求走了新链路,一部分还走老链路,排查代价极高。
第三个痛点是生命周期与所有权。经典实现里节点之间持有指针,不管是裸指针还是智能指针,都会引入“谁持有谁、何时释放”的问题。用裸指针容易悬垂,用shared_ptr容易让链尾的节点一直不释放,用unique_ptr又没法在多个节点间反复传递同一请求对象。这些麻烦本质上不是由职责链模式制造出来的,而是由“用对象指针表达顺序结构”这种组织方式带来的。
第四个痛点是短路语义脆弱。有的处理器返回布尔值表示是否继续,有的返回空指针表示自己不处理,有的直接修改请求状态来隐式表达“我已经处理过了”。这些语义如果只在注释里写,不落在类型系统上,链上任何一个新人都可能理解错,而且编译期完全无法发现。一句话总结:经典职责链适合教学,但不适合规模化的真实业务。
1.3 变体到底改变了什么
变体的本质,是把“链”从我脑袋里的对象关系图,改造成一段显而易见的顺序逻辑。它不再要求每个节点继承同一个基类,顺序信息从“散落在每个节点的私有指针”变成“集中在一段注册代码里”,终止条件从隐式的返回约定变成显式的结果状态。
打个比方:经典职责链就像一条手工接好的水管,每一段接头处都是上一节管子自己拧到下一节上的。变体则像一根统一的管道支架,各段管子都被固定在同一根支架上、顺序由支架决定。后者修改顺序只需要拧一颗螺丝,而前者得把整段管线拆开重接。
2. 常见变体形态与适用场景
2.1 函数式处理器链:std::function 版
是我采用最多、也最推荐作为默认方案的变体。把处理器从“类”降到“函数”的粒度,用容器保存一组可调用对象,执行时按容器顺序逐个调用。
using Handler = std::function<bool(Request&)>; class HandlerChain { public: void AddHandler(Handler h) { handlers_.push_back(std::move(h)); } void Execute(Request& req) { for (auto& handler : handlers_) { if (!handler(req)) break; } } private: std::vector<Handler> handlers_; };它的优势非常明显:
- 非侵入。任何函数、lambda、成员函数,只要能包装成
std::function,就能挂到链上。老代码里的工具函数可以直接复用。 - 顺序集中。链的最终顺序,就是
AddHandler的调用顺序。代码审查的时候一眼就能看出逻辑先后。 - 链的构建可以和业务分离。你可以写一个
BuildChain()函数,按照固定的逻辑顺序注册各个处理器,然后业务方只调Execute,根本不知道链上发生了什么。
它也有局限:处理器之间不能优雅地传递复杂的状态,只能靠请求对象的引用。不过在实际使用中,业务数据的载体往往是一个上下文结构体,问题不大。
2.2 优先级调度型责任链:注册表版
很多所谓的责任链场景,其实真正需要的是“按优先级挑选处理器”,而不是严格按顺序挨个尝试。这种时候更适合用一个优先级注册表。
class EventDispatcher { public: void RegisterHandler(uint32_t priority, Handler h) { handlers_.emplace(priority, std::move(h)); } bool Dispatch(Event& e) { for (auto it = handlers_.begin(); it != handlers_.end(); ++it) { if (it->second(e)) return true; } return false; } private: std::multimap<uint32_t, Handler, std::greater<uint32_t>> handlers_; };这里的核心思想是:处理器之间相互独立,它们并不关心自己后面是谁,只关心自己被调用的优先级高低。multimap天然按优先级排序,允许同一优先级有多个处理器,保证保序性。这种变体特别适合插件系统、外部接入方较多的事件网关。每个接入方只需要声明自己的优先级,不用了解别人注册了什么,链的顺序控制被中央化。
代价是执行顺序的推导变得不那么直接:你没办法从代码里一眼看出完整链路,只能通过运行期打印来观测。所以这个变体更适合规模大、扩展频繁、但性能要求不极端的场景。
2.3 分布式中间件式责任链
这种变体的骨架很像中间件,常见于网关、RPC 框架、请求过滤管道。它跟经典职责链在外形上最接近,每个处理器仍然持有下一个处理器的引用,但链条的组装委托给框架层统一完成,节点自身只负责“执行自己的逻辑,然后决定是否调用 next”。
struct MiddlewareContext { int user_id{}; std::string path; // 其他业务字段... }; using NextHandler = std::function<void(MiddlewareContext&)>; using Middleware = std::function<void(MiddlewareContext&, NextHandler next)>; std::vector<Middleware> middlewares; void ApplyMiddleware(MiddlewareContext& ctx) { std::function<void(size_t)> run = [&](size_t idx) { if (idx >= middlewares.size()) return; Middleware current = middlewares[idx]; auto next = [&, idx](MiddlewareContext& c) { run(idx + 1); }; current(ctx, next); }; run(0); }注意这里有个显著变化:每个处理器被调用时,由外部注入一个next函数。这个设计让处理器既能“提前终结”,也能“在 next 执行后做后处理”。这种对称性的价值很大,因为很多场景需要的是“洋葱模型”:在业务逻辑前后分别插入前置校验和后置日志。经典职责链做后处理很别扭,这种中间件式变体却天然支持。
它适合用于纵向贯穿:一条请求链路横跨多个模块、多个层次、甚至多台机器的处理管线。相比前面几种,它要求所有处理器遵循同一个签名,约束更强,换来的能力也更丰富。
2.4 编译期模板变体:零开销链
如果职责链上的处理器数量在编译期就是固定的,而且你极度关心运行时性能,可以让模板在编译期展开整条链。
template<typename... Handlers> class StaticChain; template<> class StaticChain<> { public: void Execute(Request& req) { (void)req; } }; template<typename H, typename... Rest> class StaticChain<H, Rest...> { public: explicit StaticChain(H h, Rest... rest) : head_(std::move(h)), tail_(std::move(rest)...) {} void Execute(Request& req) { if (head_(req)) { tail_.Execute(req); } } private: H head_; StaticChain<Rest...> tail_; };这种实现没有任何运行时多态、没有虚函数表、没有std::function的包装开销,所有调用在编译期就已经确定。代价是链的组成必须在编译期写死,不能运行时动态增删处理器。它适合那种链路极其稳定、每次调用都要极致优化的场景,比如高频消息包解析的固定管线。
我在实际项目里很少把这种变体用在大业务链上,因为灵活性损失太大。它更像是为极限场景准备的一把手术刀,不该当日常菜刀用。
2.5 变体技术对比表
| 变体 | 结构载体 | 顺序控制 | 运行时开销 | 适用规模 | 灵活度 |
|---|---|---|---|---|---|
| 经典对象链 | 指针链 | 节点各自持有 next | 虚函数调用 | 小 | 低 |
| 函数式链 | vector<std::function> | 注册顺序 | std::function 调用 | 中 | 高 |
| 优先级注册表 | multimap | 优先级键值 | 容器遍历 | 大 | 高 |
| 中间件式链 | vector + next 注入 | 注册顺序 | std::function 调用 | 中 | 高 |
| 编译期模板链 | 类型串联 | 编译期固定 | 零/内联 | 固定小链 | 极低 |
从表里能看出一个共同趋势:现代 C++ 的职责链变体,几乎都在把顺序信息往“数据”上靠,而不是往“对象关系”上靠。顺序是数据,就可以被集中检查、集中排序、集中切换。这个认知上的转变,比选哪个容器更重要。
3. 一个完整的函数式职责链变体实现
3.1 设计目标与上下文结构
设计目标很明确:
- 链上的节点是普通函数或者 lambda,不需要继承。
- 请求上下文通过一个结构体承载,所有处理器共享。
- 执行顺序由构建期注册顺序决定,运行期不改。
- 支持短路终止,支持异常安全。
先定义上下文和结果状态。为了让终止语义清晰,我不建议直接用bool表示“是否继续”,而应该用一个显式的枚举:
enum class ChainAction { Continue, Stop }; struct RequestContext { std::string request_path; int user_level = 0; bool authenticated = false; std::map<std::string, std::string> headers; // 按业务需求扩展其他字段... };这里的ChainAction替代了模糊的布尔值。阅读代码时,return ChainAction::Stop比return false传达的信息量高出一个档次,而且Stop这个词天然表达“我处理完了,别继续了”的含义,不会有歧义。
上下文结构体用引用传递贯穿整条链。为什么不传shared_ptr?因为链上的处理器只是瞬时操作者,不拥有上下文的所有权。共享指针会引入“谁最后释放”的语义负担,而业务根本不关心这个,纯属自找麻烦。引用传递配合生命周期约束,让上下文只在调用栈内有效,简单、清晰、可预期。
3.2 核心代码:链容器与调用逻辑
链容器本质就是一个有序的处理器集合:
class Chain { public: using Context = RequestContext; using Handler = std::function<ChainAction(Context&)>; Chain& Add(Handler h) { handlers_.push_back(std::move(h)); return *this; } void Execute(Context& ctx) const { for (auto& handler : handlers_) { ChainAction action = handler(ctx); if (action == ChainAction::Stop) { break; } } } private: std::vector<Handler> handlers_; };Add返回*this是为了支持链式调用,方便在构建器里连续注册多个处理器。这段代码很短,但有几个细节值得展开。
首先,std::vector作为容器。如果链长度通常在 20 以内,用std::vector其实非常高效:处理器之间内存连续,遍历时 CPU 对缓存友好;每次handler(ctx)调用的开销远低于虚函数调用。对于链上元素大多是小 lambda 的情况,这个吞吐量在业务场景下绰绰有余。
其次,Execute里对ChainAction::Stop的 break。这种半路终止是职责链模式的核心语义。但实现里还有一个隐含设计:所有处理器都必须同步调用,这保证了链上的异常、副作用都是线性堆栈,而不是交叉异步的。
如果你担心一个处理器抛异常导致整条链中断,见 4.2 的做法。更完善一点,链容器还可以提供RemoveAll、Count、Dump等调试接口,但核心就是这个,多余方法反而会导致误用空间变大。
3.3 构建器与注册机制
直接在业务代码里chain.Add(...).Add(...)很常见,但我不推荐把构建过程写在调用现场。更好的做法,是专门写一个静态构建函数返回已组好的链:
class RequestProcessor { public: static Chain BuildDefault() { Chain chain; chain.Add(Authenticate) .Add(CheckPermission) .Add(RateLimit) .Add(HandleRequest); return chain; } };处理器函数甚至可以做成普通成员函数,通过std::bind包进去。C++20 之后也可以直接用泛型 lambda 或模板参数,但作为通用的入门方案,std::function可以接受任何满足签名要求的可调用对象,包括成员函数绑定的结果,所以不必为每一种写法加机制。
用静态构建器有几个好处:调用方不需要知道链上有什么,它可以拿到一个已经构建好的Chain对象直接执行;想要变更链的组成,只需要改一处代码,而不是去改调用方;测试代码也容易构造不同组合,传入不同构建器得到一个预期链。这套构建器模式本质上是在“配置”和“执行”之间划了一条线,符合关注点分离,也让我在排查线上问题时很容易定位是哪一层链注册出了问题。
3.4 异常安全与事务补偿
链上的处理器各自独立,但请求状态是共享的。如果第三个处理器抛异常,前两个处理器已经对RequestContext做过修改,这就会留下不完整的中间状态。
处理思路分两层。第一层是约定:业务处理器内部尽量不抛异常,把错误信息写入RequestContext的状态字段,由链末端的处理器统一决定如何响应。这类似于错误码风格,虽然朴素,但异常流程在链式架构里更难追踪,能不引入就不引入。
第二层是兜底:在Execute外部包一层 try-catch,捕获异常后走统一的异常处理逻辑。如果把链执行放在一个事务模板里,可以考虑记录“已处理到第几个处理器”,异常发生时不回滚已成功处理的部分,而是执行补偿处理器,例如释放临时资源、恢复部分状态。
try { chain.Execute(ctx); } catch (const std::exception& e) { // 统一记录异常并构造错误响应 ctx.error_message = e.what(); }这里的关键不是怎么写 catch 分支,而是把“异常处理”也当成一条变体链的一部分。必要时可以把异常处理器也注册到链上,让异常处理也遵循同样的顺序机制。当然这属于进阶设计,初版先做到兜底记录,保证系统不崩即可。
3.5 效果验证与性能表现
我在某个内部项目里对比过这套实现和经典多态实现。场景是请求校验加转发,链上有 5 到 8 个处理器。压测结果:两者的总耗时差距在 5% 以内,基本可以忽略。std::function的调用开销通常略高于虚函数,但因为调用次数少,且链本身的计算逻辑占大头,这点损耗不构成实质影响。
真正值得优化的地方反而是处理器内部逻辑:日志输出、字符串处理、锁竞争,这些才是性能瓶颈。职责链变体优化的核心是减少链上做无意义工作的节点,而不是纠结那几十纳秒的std::function调用。
这里也要提醒一个容易被忽略的点:std::function并不是剥掉类型信息的裸函数指针,它需要一个抹型包装,通常会在创建时产生一次堆分配。所以大量创建短生命周期 Chain 会导致分配压力。缓解办法:链对象尽量构建一次、复用多次,比如做成单例或者进程启动时构建;或者使用std::move_only_function这类轻量包装,降低分配频率。工程上一个原则是,把链的构建放到高频调用循环之外,别让注册代码跑在热路径里。
4. 实操中容易踩的五个坑与排查思路
4.1 短路语义混乱
所有处理器都得返回ChainAction,但不同人写的处理器可能会对“Stop”的理解不同。有人把它理解成“该请求已经被我的逻辑处理完了,后续不需要了”,有人理解成“这个处理器自己不匹配该请求,希望后续处理器继续试”。这两种理解一旦混用,链的执行结果就会跟预期不符。
我的做法是约定三种语义标签:Stop表示彻底终止链;Continue表示当前处理器已做完自己该做的事,请下一个处理器接手;如果处理器对该请求完全不感兴趣,它也返回Continue,但不会修改上下文。最关键的一点是:不要在Continue分支里留下未处理的脏状态。如果请求已经满足终止条件,就该明确返回Stop。代码评审时,我会要求每个处理器至少写一行注释,说明什么情况下它会Stop。
4.2 捕获 this 导致的生命周期隐患
用 lambda 注册处理器时,最常见的窝点就是[this]捕获:
class UserServiceImpl { public: void Init(Chain& chain) { chain.Add([this](RequestContext& ctx) { return this->ValidateUser(ctx); }); } };this被裸捕获进std::function。一旦UserServiceImpl对象先于链被析构,链上就会留下悬垂引用。执行到该处理器时,调用就是未定义行为。这个 bug 很难稳定复现,因为往往和对象析构顺序强相关。
应对方案有几种:把处理器的生命周期绑定到链的生命周期,保证链内处理器都比对象活得短;用std::weak_ptr传参,执行时先 lock;最稳妥的是不要在成员函数里通过捕获this注册回调,而是把业务逻辑本身做成自由函数或静态函数,参数里显式传入对象指针或引用。这样对象存活期由调用方负责,链上不窃取任何所有权,排查时也容易定位。
4.3 异常隔离与日志跟踪
链执行期间如果处理器抛异常,默认行为会让Execute退出循环,后续处理器全部不再执行。但某些处理器可能在Stop前已经写入了数据库或改了缓存,这种部分完成状态很难回溯。
解决方案是在每个处理器入口和出口打点日志。对高频链路,日志可以分级,只在 debug 或 trace 级别输出。我常用一个简单的调试工具:链对象内部维护一个std::vector<std::string> trace_names_,执行每个处理器前把处理器名称写入一个当前执行栈;异常时统一捕获,把“执行到哪一个处理器停住了”打到日志里。这个信息对排查线上问题特别有效,能让你立刻定位第几个处理器有问题,而不是从头猜到尾。
void Execute(Context& ctx) { for (size_t i = 0; i < handlers_.size(); ++i) { execution_trace_.clear(); execution_trace_.push_back(handler_names_[i]); try { auto action = handlers_[i](ctx); if (action == ChainAction::Stop) break; } catch (...) { LOG_ERROR("exception in handler[{}] name={}", i, handler_names_[i]); throw; } } }给每个处理器固定的名称,而不是用std::function::target_type().name(),因为后者在不同编译器下的输出可读性很差。有名称之后,链上顺序变更、异常定位、测试校验都会轻松很多。
4.4 链上注册顺序错误
优先级注册表或者函数式链的注册顺序如果搞错,最常见的后果是请求走完了整条链但没有一个处理器命中。比如校验处理器排在业务处理器之后,或者某一个处理器永远返回Continue,导致后面的处理器白跑一遍。
排查思路:写一段单元测试,构造典型的正常请求和非正常请求,分别验证链上的终止结果。再进一步,打印每条链的注册顺序作为静态检查的一部分。很多问题在代码审查阶段就能看到:比如某个处理器逻辑上明明应该先鉴权再查用户信息,但注册顺序反了。这也印证了顺序数据化的好处:一旦顺序从节点内部暴露到注册处,它就能被测试、被审查。
4.5 过度设计风险
责任链本身很容易被滥用。如果一个项目里只有两三个处理器,硬要整出链式结构,只会增加复杂度。我接触过的很多项目里,原本用简单的if-else也就够了,但为了“模式感”强行引入职责链,最后的维护成本反而更高。
判断是否该用职责链变体,我通常看两个信号:处理器数量是否会随着业务演进而不断增加?处理顺序是否独立于处理器自身逻辑?如果两个答案是肯定的,变体就是合算的;如果只是临时跑一次逻辑,请直接用函数调用或 switch。模式的价值在于匹配变化点,不在装饰代码。
5. 变体选型决策建议
5.1 按场景选型
拿三个典型场景举例。
场景一:某个服务的请求过滤管道,过滤器数量固定,预计半年内增长不超过 5 个,团队内部维护。这种场景我首选函数式职责链变体:实现简单、顺序直接、团队好理解。
场景二:插件系统,外部接入方会动态注册自己的处理器,优先级各不相同,可能同时有几十个处理器。这种时候multimap优先级调度型更合适,它弱化明确的执行顺序,强化优先级独立性。
场景三:核心网关链路,跨多个中间件,每个中间件既要前置处理也要后置处理,还希望往后扩展。选中间件式变体,因为它天然支持洋葱模型和next注入。
模板变体留给性能敏感并且链路固定的局部场景,普通业务不要碰。别为了那几十纳秒的收益把整个代码库的可维护性牺牲掉,这是我在项目里反复体会到的教训。
5.2 从老代码迁移到变体
如果你现在正维护一套经典职责链,想迁移到函数式变体,直接重写风险较大。稳妥的做法是分三步走:
第一步,为老链路增加一个只读接口,打印链的完整顺序。先保证现有行为可以被白盒观察。第二步,新写一个BuildChain()函数,照着旧链路顺序把所有处理器包装成 lambda,同时用旧实现和新实现同时在测试环境执行,比对两者的短路次数和处理结果是否一致。第三步,把线上调用切到新链,保留旧代码一份,供回滚使用。
这里最关键的一步是第二步的“双跑验证”。很多人上来就删旧代码,结果迁移后行为差异找不到原因。双跑虽然多花一点机器时间,但在真实流量下验证等价性,比写一百个单元测试更靠谱。
5.3 一点扩展想法
职责链变体不仅用于“请求处理”。它完全可以当通用的管道工具使用:数据处理流水线、图像滤镜链、日志格式化链路,都是同一套思想。核心区别只在于ChainAction表达的含义:数据处理链里,它可能不再是“继续/终止”,而是“是否还有下一阶段”。
如果你把ChainAction换成枚举Phase(表示当前处理阶段),链变成了一个阶段机;如果你把处理器改成异步的,返回值变成std::future<ChainAction>,它又能适配协程。变体的方向可以很多,关键要记住一个主线:链把无序的代码组织成有序的协作,而变体改变的是这个协作的表达形式。
最后再分享一个我自己的体会。职责链模式在 C++ 里之所以被很多人用得很别扭,是因为他们一开始就把“Handler”定义成了“类”。一旦你把 Handler 降格为函数,链条的组织从指针关系变为容器关系,整个设计就会突然变得轻巧起来。虽然经典实现永远值得学,但在真实项目里,函数式职责链变体会是你用得最多、也最不容易出错的版本。