先说我自己的一个真实项目。早年间接手一个内部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_castmilliseconds(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方法然后用一个vectorunique_ptr 把它们串起来。这个做法能用但用起来很别扭至少有三个痛点。第一个痛点是“类爆炸”。日志过滤器、鉴权过滤器、白名单过滤器、限流过滤器每个都得定义一个类。如果只是几十行的逻辑也要单独建文件加类定义代码量瞬间膨胀。第二个痛点是“状态传递困难”。过滤器经常要捕获一些上下文比如数据库连接池、配置对象、统计计数器。用继承类要么把这些依赖塞进构造函数要么设置全局变量怎么都不舒服。而lambda天然能捕获外部状态这个优势在继承方案里完全体现不出来。第三个痛点是“组合不灵活”。如果某个请求需要特殊处理比如放过Admin用户不走限流你在继承方案里要么再写一个子类要么在接口里加一堆条件判断非常僵硬。用函数对象方案只需要在组装管道时多写一个lambda来判断不满足条件就调用next放行。2.2 核心契约用std::function表达“过滤动作”现代C工程里我更推荐用std::function来定义过滤器。核心契约只需要两个using声明using Next std::functionvoid(); using Filter std::functionvoid(Context, Next);解释一下这两个签名。Filter代表一个过滤器节点它接收两个参数第一个是上下文Context整个链共享的请求/响应数据第二个是Next一个回调表示“继续调用下一个过滤器”。这个设计的精髓在于Next回调。如果只用来判断“过还是不过”那用bool返回值就够了using Filter std::functionbool(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::functionvoid(); using Filter std::functionvoid(Context, Next); class FilterChain { public: void add(Filter f) { filters_.push_back(std::move(f)); } void run(Context ctx) { std::functionvoid(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::vectorFilter 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_ptrContext; using Next std::functionvoid(); using Filter std::functionvoid(ContextPtr, Next); class FilterChain { public: void add(Filter f) { filters_.push_back(std::move(f)); } void run(ContextPtr ctx) { auto dispatch std::make_sharedstd::functionvoid(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::vectorFilter 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_caststd::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社区更推荐的做法写一个轻量的ScopeGuardclass ScopeGuard { public: explicit ScopeGuard(std::functionvoid() fn) : fn_(std::move(fn)) {} ~ScopeGuard() { fn_(); } private: std::functionvoid() fn_; };把计时过滤器改成这样chain.add([](ContextPtr ctx, Next next) { auto start std::chrono::steady_clock::now(); ScopeGuard guard([] { auto ms std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start) .count(); std::cout [Timer] ms ms std::endl; }); next(); });这样即使next()内部抛异常ScopeGuard的析构函数也会在栈展开时执行耗时日志一定能打出来。这条经验在真实项目里价值很高——过滤器链上的每个节点都可能抛异常而异常恰恰最能暴露你“以为会执行但实际没执行”的逻辑漏洞。4. 进阶玩法编译期过滤链与C20的新管道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_sharedContext(); invoke_chain(ctx, log_filter, auth_filter, business);这种写法有几个待注意的点。第一最后一个过滤器的next其实是一个空lambda如果业务过滤器不小心调用它会直接返回不会导致崩溃。第二lambda之间通过引用捕获来传递next所以这些过滤器对象必须在整个invoke_chain执行期间活着不能传入临时对象后立刻析构。第三编译期链无法在运行时动态调整顺序所以它适合固定管线的场景比如消息处理、命令处理不适合那种需要从配置中心动态加载过滤器顺序的业务。实际工程里我常用的做法是“双轨制”主链路上确定不变的过滤器用模板递归可插拔的扩展点用std::function版本。这样既保证了热路径性能又保留了扩展的灵活性。4.2 C20 ranges和协程把“管道”带到了数据流世界C20标准库引入的ranges让“管道”这个词在C里有了新的含义。看这个例子#include iostream #include ranges #include vector int main() { std::vectorint 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管道如果你的处理对象是一次请求、一个任务、一条消息这种“一次性实体”用过滤器模式/中间件链。C20协程也给这种“管道”带来了新玩法。用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::functionvoid(); using Filter std::functionvoid(Context, Next); void run(Context ctx, const std::vectorFilter filters) { std::functionvoid(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执行路径完全不是一回事。希望这篇文章能让你少踩我踩过的坑。