先说我自己的一个真实项目。早年间接手一个内部HTTP服务,每个接口入口处都是同样五段代码:记录访问日志、校验用户token、检查IP白名单、统计接口耗时、解析公共请求头。新写一个接口,就把这五段代码复制一遍;后来产品要加一个灰度开关,需要改全部二十多个接口,改到一半线上就出了事故。后来我把链路抽成一个管道,每个环节用独立函数对象表示,前后顺序用容器管理,整个重构从动手到上线只花了一天。这个思路的核心就是C++里的过滤器模式,很多人也叫它管道过滤器、中间件链。这篇文章我打算从它的适用场景、C++代码实现、工程细节到面试话术一次讲透,不管你是正在学C++的初学者,还是已经在项目里被重复代码折磨的在职开发,都应该能从里面拿到点能直接用的东西。
1. 过滤器模式到底在解决什么问题:从被横切逻辑淹没的接口说起
1.1 横向逻辑与业务逻辑为什么要拆开
先看一段非常典型的接口代码。假设你有一个下单接口,伪代码大概是这样的:
void handleOrder(Request& req, Response& res) { logger.info("order request, path: {}", req.path); if (!checkToken(req.token)) { res.send(401); return; } if (!checkWhitelist(req.ip)) { res.send(403); return; } auto start = steady_clock::now(); Order order = parseOrder(req); res.send(createOrder(order)); auto cost = duration_cast<milliseconds>(steady_clock::now() - start); logger.info("order response, cost: {}ms", cost.count()); }这段代码的问题不在于逻辑有多复杂,而在于:日志、鉴权、白名单、耗时统计这些“横向逻辑”和“创建订单”这个业务逻辑完全揉在一起。
- 每新增一个接口,这些横切逻辑就要复制一遍;
- 每调整一次顺序,比如先做白名单再做鉴权,二十多个接口全要改;
- 每新增一个横切逻辑,比如加一个风控检查,每个接口的入口处都得手动插一段,漏一个就是事故。
过滤器模式解决的就是这件事:把横切逻辑从业务逻辑里抽出来,装进一个可以统一配置、统一管理、按顺序执行的管道里。业务接口只需要关心业务本身,日志、鉴权、限流、耗时统计这些事交给过滤器链去做。这样做的直接收益有三个:
- 新增横切逻辑时,业务代码一行不用改;
- 调整横切逻辑顺序,改的是管道配置,不用动接口;
- 每个横切逻辑可以独立测试、独立复用。
1.2 过滤器模式、中间件、责任链,别把三个名词搞混
很多人一听到过滤器模式,就会连着冒出“中间件”“责任链”“装饰器”这些名词,面试也爱问。这几个东西确实长得像,但语义侧重不同。
| 模式 | 执行语义 | 是否强制全部节点执行 | 典型实现形态 |
|---|---|---|---|
| 过滤器模式 | 请求/数据依次经过每个节点 | 是,除非某个节点主动中断 | 循环或递归调用 |
| 中间件 | 过滤器模式在服务端框架里的工程叫法 | 是,通过next回调贯穿 | next回调 |
| 责任链模式 | 链上处理器依次询问,谁能处理谁处理 | 否,第一个接管的处理器出来后就结束 | 链表尾递归 |
| 装饰器模式 | 一层层包装原对象,逐层增强 | 是,但结构上是递归嵌套而非线性遍历 | 包装类嵌套 |
我自己的理解是:中间件这个词基本可以当成过滤器模式的同义词,Spring里有Filter也有Interceptor,语义差别并不大;责任链模式则更强调“有人接手就停止”,比如审批流,某个层级的审批人处理完,流程就完事了;而过滤链更像安检通道,每个人都要过一遍,过不了就拦下来,过了就继续走。工程上这几种模式经常混着用,所以不用纠结边界,能说清楚“你的链路语义是全部执行还是第一个执行完就停”就够了。
2. C++里的过滤器长什么样:从继承接口到std::function的进化
2.1 早期做法:继承Filter基类的三个痛点
如果你是照着Java那套Servlet Filter的思路来写C++过滤器,第一反应通常是定义一个抽象基类:
class IFilter { public: virtual ~IFilter() = default; virtual bool apply(Request& req) = 0; };每个过滤器都继承这个接口,实现apply方法,然后用一个vector<unique_ptr >把它们串起来。这个做法能用,但用起来很别扭,至少有三个痛点。
第一个痛点是“类爆炸”。日志过滤器、鉴权过滤器、白名单过滤器、限流过滤器,每个都得定义一个类。如果只是几十行的逻辑,也要单独建文件加类定义,代码量瞬间膨胀。
第二个痛点是“状态传递困难”。过滤器经常要捕获一些上下文,比如数据库连接池、配置对象、统计计数器。用继承类,要么把这些依赖塞进构造函数,要么设置全局变量,怎么都不舒服。而lambda天然能捕获外部状态,这个优势在继承方案里完全体现不出来。
第三个痛点是“组合不灵活”。如果某个请求需要特殊处理,比如放过Admin用户,不走限流,你在继承方案里要么再写一个子类,要么在接口里加一堆条件判断,非常僵硬。用函数对象方案,只需要在组装管道时多写一个lambda来判断,不满足条件就调用next放行。
2.2 核心契约:用std::function表达“过滤动作”
现代C++工程里,我更推荐用std::function来定义过滤器。核心契约只需要两个using声明:
using Next = std::function<void()>; using Filter = std::function<void(Context&, Next)>;解释一下这两个签名。Filter代表一个过滤器节点,它接收两个参数:第一个是上下文Context,整个链共享的请求/响应数据;第二个是Next,一个回调,表示“继续调用下一个过滤器”。这个设计的精髓在于Next回调。
如果只用来判断“过还是不过”,那用bool返回值就够了:
using Filter = std::function<bool(Request&)>;但bool方案有一个致命局限:它没法表达“在下一层执行前干点事,下一层执行完后再干点事”这种前后环绕逻辑。比如我要统计整个链路的耗时,用bool方案只能在过滤器里记录开始时间,却没法在链跑完后统一算总耗时。而Next回调天然支持洋葱模型——过滤器在调用next()之前是前置逻辑,调用next()之后是后置逻辑。这就是为什么主流中间件框架都用next回调而不是返回值。
2.3 一个能直接编译运行的FilterChain
下面给出一个完整的可运行示例,这个骨架是我个人最常用的版本,简单、清晰、适合当模板用:
#include <functional> #include <iostream> #include <string> #include <vector> struct Context { std::string path; std::string token; int status = 0; }; using Next = std::function<void()>; using Filter = std::function<void(Context&, Next)>; class FilterChain { public: void add(Filter f) { filters_.push_back(std::move(f)); } void run(Context& ctx) { std::function<void(std::size_t)> dispatch; dispatch = [this, &ctx, &dispatch](std::size_t idx) { if (idx >= filters_.size()) return; auto& f = filters_[idx]; Next next = [&dispatch, idx]() { dispatch(idx + 1); }; f(ctx, next); }; dispatch(0); } private: std::vector<Filter> filters_; }; int main() { FilterChain chain; chain.add([](Context& ctx, Next next) { std::cout << "[Log] request -> " << ctx.path << std::endl; next(); std::cout << "[Log] response <- " << ctx.status << std::endl; }); chain.add([](Context& ctx, Next next) { if (ctx.token.empty()) { ctx.status = 401; return; } next(); }); chain.add([](Context& ctx, Next) { std::cout << "[Handler] business logic" << std::endl; ctx.status = 200; }); Context ctx{"/api/order", "abc123", 0}; chain.run(ctx); return 0; }运行结果是这样的:
[Log] request -> /api/order [Handler] business logic [Log] response <- 200注意执行顺序:日志过滤器先打印请求信息,调next进入鉴权过滤器,因为token非空就继续调next,进入业务过滤器,业务处理完返回,接着返回日志过滤器,打印响应信息。整个过程就是一层套一层,所以叫洋葱模型。
这里有个特别容易踩的坑:如果一个过滤器在调用next()之前直接return,那么它后面的所有过滤器都不会执行,同时它自己的after代码也不会执行。比如鉴权过滤器里要先检查token为空就return,那日志过滤器里next()之后的“response”日志就打不出来。这其实是合理的短路行为,但刚开始做过滤器链的人经常在这里迷糊,以为return之后还能回到上一层继续after逻辑。实际上return只是从当前函数返回,并不会触发上一层的后置逻辑,唯一的例外是异常栈展开或者RAII析构。
3. 让过滤器链处理真实业务:HTTP中间件的一次完整实践
3.1 用Context承载请求与响应,避免全局变量满天飞
在2.3的例子里,Context只是一个简单的struct。真实业务中,Context要承载的东西多得多:请求头、响应体、用户信息、错误码、性能统计数据。把这些东西都塞进Context,而不是塞进过滤器成员变量,是过滤器链设计里很重要的一条原则。
如果你的过滤器链会在线程池里异步执行,那么上面那个同步版本的dispatch实现就不够安全了。dispatch在run()里是栈上局部变量,如果过滤器把next存下来,等run()返回之后再调用,就会访问已经销毁的栈对象,这是未定义行为。更稳妥的做法是让dispatch自己拥有引用计数,同时把Context也换成shared_ptr,保证异步场景下不会悬垂:
#include <functional> #include <iostream> #include <memory> #include <string> #include <vector> struct Context { std::string path; std::string token; int status = 0; }; using ContextPtr = std::shared_ptr<Context>; using Next = std::function<void()>; using Filter = std::function<void(ContextPtr, Next)>; class FilterChain { public: void add(Filter f) { filters_.push_back(std::move(f)); } void run(ContextPtr ctx) { auto dispatch = std::make_shared<std::function<void(std::size_t)>>(); *dispatch = [this, ctx, dispatch](std::size_t idx) mutable { if (idx >= filters_.size()) return; auto& f = filters_[idx]; Next next = [dispatch, idx]() mutable { (*dispatch)(idx + 1); }; f(ctx, next); }; (*dispatch)(0); } private: std::vector<Filter> filters_; };这里的关键改动是:dispatch用shared_ptr包装,lambda按值捕获shared_ptr,而不是按引用捕获局部变量。这样即使某个过滤器把next保存下来延迟调用,dispatch对象也不会提前销毁。Context同样按值传给lambda,引用计数会一直维持到最后一个持有者释放。
3.2 短路、拦截与“next只能调一次”的铁律
过滤器链的拦截很直观:不调用next就算拦截。比如鉴权失败,设置401状态码然后return,整个链就断了。但有一个细节很多人没意识到——next回调是可以被重复调用的。比如一个过滤器写成:
chain.add([](ContextPtr ctx, Next next) { next(); next(); // 手滑了,或者逻辑有bug });这样一来,后续过滤器会执行两遍。在日志过滤器里可能只是多打一条日志,但在“发送响应”“扣减库存”这类有副作用的过滤器里,这是线上事故级别的问题。
我的做法是给next加一个“一次性闸门”,保证整条链中每个next回调最多只会生效一次:
class OnceGate { public: explicit OnceGate(Next next) : next_(std::move(next)) {} void operator()() { if (called_) return; called_ = true; next_(); } private: bool called_ = false; Next next_; };在FilterChain里把原本的next包一层:
Next next = OnceGate([dispatch, idx]() mutable { (*dispatch)(idx + 1); });OnceGate内部的called_记录这个回调是否已经触发过,哪怕外部过滤器连续调两次next,也只有第一次会真正生效。这个设计不复杂,但能挡住一类非常隐蔽的bug。
还有一种“短路”设计值得说一下:如果一个过滤器在调用next()之后还写了代码,比如打印after日志,那么当next()内部抛出异常时,这些after代码不会执行。这是C++栈展开的自然行为,但也意味着如果你的过滤器必须保证“即使下游异常,我的清理逻辑也要跑”,就得引入RAII。
3.3 异常穿过过滤器链时,日志after为什么丢了
假设你有两个过滤器,第一个负责计时,第二个直接抛异常:
chain.add([](ContextPtr ctx, Next next) { auto start = std::chrono::steady_clock::now(); next(); auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start) .count(); std::cout << "[Timer] " << ms << "ms" << std::endl; }); chain.add([](ContextPtr ctx, Next) { throw std::runtime_error("boom"); });执行顺序是这样的:计时过滤器开始计时,调next进入第二个过滤器,第二个过滤器抛异常,异常一路向外传播,计时过滤器里next()之后的耗时输出语句不会执行。结果就是:你完全不知道这次请求花了多长时间。
解决办法有两个层面。第一层是在FilterChain::run外面统一捕获异常,这能保证程序不崩溃,但没法让已经“被跳过”的after逻辑恢复执行。第二层是在过滤器内部用RAII保证析构时执行清理,这是C++社区更推荐的做法,写一个轻量的ScopeGuard:
class ScopeGuard { public: explicit ScopeGuard(std::function<void()> fn) : fn_(std::move(fn)) {} ~ScopeGuard() { fn_(); } private: std::function<void()> fn_; };把计时过滤器改成这样:
chain.add([](ContextPtr ctx, Next next) { auto start = std::chrono::steady_clock::now(); ScopeGuard guard([&] { auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start) .count(); std::cout << "[Timer] " << ms << "ms" << std::endl; }); next(); });这样即使next()内部抛异常,ScopeGuard的析构函数也会在栈展开时执行,耗时日志一定能打出来。这条经验在真实项目里价值很高——过滤器链上的每个节点都可能抛异常,而异常恰恰最能暴露你“以为会执行但实际没执行”的逻辑漏洞。
4. 进阶玩法:编译期过滤链与C++20的新管道
4.1 模板递归实现的零开销过滤链
前面讲的FilterChain基于std::function和vector,优势是灵活,运行时可增删节点;代价是每次调用都要经过类型擦除和可能发生的堆分配。如果你的过滤器集合在编译期就能完全确定,可以用模板递归把整条链在编译期定死,零类型擦除、零动态分配、每个调用点都可以内联。
思路是这样的:用模板参数列表代表过滤器集合,递归地展开调用链。最后一个过滤器不再接收next,或者把next设为空回调。
template <typename First, typename... Rest> void invoke_chain(ContextPtr ctx, First& first, Rest&... rest) { if constexpr (sizeof...(rest) == 0) { first(ctx, []{}); } else { first(ctx, [&]() { invoke_chain(ctx, rest...); }); } }用法示例:
auto log_filter = [](ContextPtr ctx, Next next) { std::cout << "log in" << std::endl; next(); std::cout << "log out" << std::endl; }; auto auth_filter = [](ContextPtr ctx, Next next) { if (ctx->token.empty()) { ctx->status = 401; return; } next(); }; auto business = [](ContextPtr, Next) { std::cout << "business" << std::endl; }; ContextPtr ctx = std::make_shared<Context>(); invoke_chain(ctx, log_filter, auth_filter, business);这种写法有几个待注意的点。第一,最后一个过滤器的next其实是一个空lambda,如果业务过滤器不小心调用它,会直接返回,不会导致崩溃。第二,lambda之间通过引用捕获来传递next,所以这些过滤器对象必须在整个invoke_chain执行期间活着,不能传入临时对象后立刻析构。第三,编译期链无法在运行时动态调整顺序,所以它适合固定管线的场景,比如消息处理、命令处理;不适合那种需要从配置中心动态加载过滤器顺序的业务。
实际工程里,我常用的做法是“双轨制”:主链路上确定不变的过滤器用模板递归,可插拔的扩展点用std::function版本。这样既保证了热路径性能,又保留了扩展的灵活性。
4.2 C++20 ranges和协程把“管道”带到了数据流世界
C++20标准库引入的ranges让“管道”这个词在C++里有了新的含义。看这个例子:
#include <iostream> #include <ranges> #include <vector> int main() { std::vector<int> v{1, 2, 3, 4, 5, 6}; auto r = v | std::views::filter([](int x) { return x % 2 == 0; }) | std::views::transform([](int x) { return x * 10; }); for (int x : r) { std::cout << x << " "; // 20 40 60 } return 0; }这里的filter和transform是数据视角的管道,关注的是“一个集合里的每个元素如何被变换和过滤”。过滤器模式则是控制流视角的管道,关注的是“一次请求进入系统后要跨越哪些处理层”。两者名字里都有filter,但完全是两种东西。
怎么判断该用哪个?我自己的经验是:如果你的处理对象是一个集合里的每一个元素,用ranges的view管道;如果你的处理对象是一次请求、一个任务、一条消息这种“一次性实体”,用过滤器模式/中间件链。
C++20协程也给这种“管道”带来了新玩法。用generator配合co_yield,可以写出一个惰性生成数据的管道,消费者取多少就计算多少,类似Python生成器。不过协程在中间件链上的应用目前还比较少见,大部分网络框架依然用next回调,协程更多被用在异步IO和流式处理场景。这个方向可以关注,但不用急着往过滤器链的设计里塞。
5. 工程落地才关心的细节:性能、生命周期与多线程纪律
5.1 std::function的性能账该怎么算
std::function很方便,但它不是零开销抽象。它内部有一个小对象缓冲区,能容纳小体积的可调用对象时不堆分配;一旦lambda捕获的上下文太大,放不进小缓冲区,就会发生堆分配。此外std::function的调用走的是类型擦除后的间接跳转,编译器很难内联优化。
在“每个节点都构造一个next回调”的FilterChain里,每一层都会产生一个lambda闭包,再加上OnceGate的包装,闭包数量被放大。如果QPS很高,这些分配和间接调用会积少成多。我的经验是分场景看待:
| 场景 | 过滤器链耗时占比 | 是否要优化 |
|---|---|---|
| HTTP服务,含DB/网络IO | 微秒级 vs 毫秒级 | 不用太在意,IO是瓶颈 |
| 高频内存计算、量化撮合 | 微秒级占比高 | 切模板递归链,消除动态分配 |
| 边缘设备上的内层循环 | 微秒级都很宝贵 | 避免std::function,用函数指针或模板 |
如果你真要优化,方向也很明确。第一,热路径过滤器集合换成模板递归版本,编译期就把链路定死;第二,减少next回调的闭包层级,比如把多个纯转发逻辑合并进一个过滤器;第三,保证过滤器捕获的都是小对象,尽量用值捕获而不是引用捕获大对象。
5.2 过滤器生命周期管理与shared_ptr的取舍
lambda过滤器最常见的悬垂隐患是捕获了裸指针或引用。比如你在类成员函数里写:
chain.add([this](ContextPtr ctx, Next next) { this->logger_.write(ctx->path); next(); });这个this指针如果指向的对象生命周期结束,而这个过滤器还在链上,调用时就是悬垂访问。更隐蔽的是,如果链被异步执行,当前对象可能已经析构,但链还在跑。
我的经验是遵循三条原则:
- 过滤器捕获的依赖对象,生命周期必须大于等于链本身。最省心的方式是捕获shared_ptr,依赖对象由引用计数托底。
- 过滤器链本身最好在服务启动时一次性构建完成,运行期间不增删节点。如果非要动态调整,用mutex保护filters_的读写,否则并发遍历和修改会数据竞争。
- 不要在请求处理中途创建新的过滤器节点然后添加到链上,这最容易引入生命周期混乱。
5.3 多线程下过滤器要遵守的纪律
过滤器链天然适合多线程并发处理:每个请求一个独立Context,同时跑在不同的线程上。但这里有一条必须遵守的纪律——过滤器本身必须可重入。
什么叫可重入?就是同一个过滤器实例同时被多个线程调用的安全性。如果你的过滤器内部有一个成员变量用来计数,比如统计通过的请求数:
class CountFilter { int count_ = 0; public: void operator()(ContextPtr ctx, Next next) { ++count_; // 多线程并发计数,数据竞争 next(); } };这个count_就会在多线程下出问题。解决办法是把可变状态放进Context,而不是放进过滤器。比如在Context里加一个计数器字段,每个请求自己一个Context,互不影响。过滤器自身保持无状态,这是最安全的设计。
如果非要让过滤器持有共享状态,比如统计全局限流次数,那就必须用std::atomic或mutex保护。共享的FilterChain本身在运行时只读遍历是安全的,但一旦有人调用add修改filters_,而另一个线程正在run,就会出问题。工程上最简单可靠的做法是:启动阶段构建完所有过滤器,之后把链标记为只读,禁止再改。
6. 面试和评审里怎么把过滤器模式讲出彩
6.1 一句话定义加上滚瓜烂熟的手写骨架
如果你在面试中被问到过滤器模式,不要上来背概念。我的回答套路是:先说一句话定义,再说适用场景,最后直接手写骨架。
一句话定义:过滤器模式把一次业务处理拆成按顺序执行的一串独立节点,每个节点只负责一项横切职责,通过next回调把控制权交给下一个节点,节点可以随时中断整条链。
适用场景一句话:当多个接口需要共享同样的日志、鉴权、限流、耗时统计等横切逻辑时,用过滤器链替换复制粘贴。
手写骨架就写前面那个FilterChain的核心里面十几行:
using Next = std::function<void()>; using Filter = std::function<void(Context&, Next)>; void run(Context& ctx, const std::vector<Filter>& filters) { std::function<void(std::size_t)> go = [&](std::size_t i) { if (i == filters.size()) return; filters[i](ctx, [&] { go(i + 1); }); }; go(0); }这段代码能把“std::function可以用来存过滤器”“lambda可以递归捕获自己”“next回调如何把控制权交给下一层”三个考点一次性覆盖。面试官看到你能写出这个,基本就认可你真的是写过,不是背概念。
6.2 与装饰器、责任链的边界,我这样一句话说清
这块在面试里被追问的概率极高。我的回答策略是用“干活的角色”来区分:
- 责任链:找一个人来干活。链上每个处理器问一遍,谁能处理谁处理,处理完就结束。
- 过滤器链:大家排队干活。除非有人主动拦截,否则所有节点都会执行一遍。
- 装饰器:层层包装同一个对象,核心是增强原有对象的能力,不是按顺序执行多个独立节点。
中间件这个词就把过滤器模式在服务端框架里的工程别名。Spring里Filter和Interceptor的差别,本质上也是过滤器的不同配置粒度,而不是两个截然不同的模式。
6.3 反问环节,问这三个问题能显得你真做过
面试最后往往有反问环节,问得好能加分。围绕过滤器链,我一般会问:
- 你们项目的过滤器顺序是配置驱动还是代码写死?有没有运行时动态调整的需求?
- 如果某个过滤器执行时间很长,你们是同步等待还是异步放行?这直接决定next回调的设计。
- 过滤器支持重入吗?一个请求会不会经过多条独立链,比如先走认证链再走业务链?
这三个问题都能体现出你思考过过滤器链在真实工程里的边界问题,而不是只在博客里看过示例。
最后说一个我自己的土办法。调试过滤器链,最怕的就是“看代码觉得顺序对,跑起来就偏”。我习惯在链首插一个“调色板”过滤器,把before、next、after都打上日志,用缩进区分层级,肉眼一看执行顺序就清楚了。很多时候你以为的调用栈跟实际的next执行路径完全不是一回事。希望这篇文章能让你少踩我踩过的坑。