ARTICLE DETAIL

资讯详情

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

C++过滤器模式实战:从虚函数到std::function与lambda

C++过滤器模式实战:从虚函数到std::function与lambda 你可能也遇到过这种场景手里要处理一批数据或请求先后经过长度校验、黑名单检查、格式解析、日志记录、业务处理好几个环节每个环节各管一件事代码却全都挤在一个大函数里。一旦要加一个新的校验规则就得去翻那段几十行的if嵌套改了还担心影响原有逻辑。C里的过滤器模式Filter Pattern就是专门解决这类问题的把处理流程拆成一段段彼此独立的过滤器按顺序串成管道数据和请求像流水线一样流过每一个环节。这篇文章写给三类人正在写C服务端、客户端或工具链整天跟请求校验、日志解析、数据清洗打交道的开发者对设计模式有基础了解但想知道过滤器模式和责任链、管道模式到底什么关系的学习者以及想看看现代C从C11到C20怎么用lambda、std::function甚至模板写出更轻量过滤器的人。我会从设计思路讲到完整代码再讲到排查技巧尽量让你看完就能直接抄作业。1. 过滤器模式的核心思路流水线而不是俄罗斯套娃1.1 先从一次登录请求说起假设你在写一个用户登录接口收到请求后要依次做这些事检查用户名和密码不为空、检查用户名是否在封禁名单里、检查密码强度是否满足安全策略、记录一条访问日志最后才去数据库比对账号密码。新手最容易把代码写成这样bool handleLogin(const LoginRequest req) { if (req.username.empty() || req.password.empty()) { return false; } if (isBlocked(req.username)) { return false; } if (!checkPasswordStrength(req.password)) { return false; } log(login attempt: req.username); return verifyWithDB(req); }这个写法的问题不是不能跑而是所有规则都硬编码在同一个函数里。几天后产品提了新需求IP的访问频率也要限制再后来安全部门要求对密码失败次数做统计再再后来日志格式变了要从明文改成JSON。每一次改动都要钻进这个函数里小心翼翼地调整顺序生怕把某个return搞错了。过滤器模式的做法完全不同把“校验不为空”做成一个过滤器“黑名单检查”做成另一个过滤器“密码强度”再做第三个最后“日志记录”也是一个过滤器。每个过滤器只负责一件事完成之后把结果交给下一个。上层代码不需要知道谁在前谁在后只需要把过滤器按顺序排好请求就从这头进去从那头出来。1.2 过滤器模式和责任链的边界到底在哪里C开发者很容易把过滤器模式跟责任链Chain of Responsibility搞混。责任链的核心是“找一个人来处理”请求在链上挨个问谁能处理谁就接住处理完可能就终止了。典型场景是客服工单系统普通客服能解决就解决解决不了升级给主管主管不行再给经理。过滤器模式的区别在于每个过滤器通常都要执行而且执行的是“加工/校验/变换”动作不是“谁来接管”的裁决。而且过滤器链上每个节点是对等关系前面的结果会传给后面通常在整条链跑完之后才得到最终结果。当然工程上两者经常混用过滤器在某个环节发现数据不合格可以直接截断并返回失败这不叫责任链这叫“短路”。理解这个区别很重要因为它决定了你怎么设计接口。责任链的接口通常返回“是否被处理”而过滤器模式的接口通常返回“是否继续”或者干脆返回处理后的数据对象。接口设计错了后面写起来会别扭得多。1.3 这模式到底该用在什么场景根据我的实际经验下面几种情况最适合上过滤器模式网络请求的多阶段校验参数不合法、频率超限、权限不足这种有明确“失败即短路”需求的场景。数据管道处理原始数据进来先清洗、再解析、再规范化、再持久化每步都不改原始数据而是生成新的中间形态。日志和调试链路一条日志要经过格式化、脱敏、采样、上报好几个环节每个环节可独立开关。游戏技能判定链攻击命中后要依次经过闪避判定、护甲减免、抗性计算、最终伤害修正每层逻辑独立可插拔。反过来如果处理流程只有两三个步骤而且以后几乎不会增删就别硬上过滤器模式。设计模式是服务于可维护性的不是用来炫技的。为两行校验写四个虚类只会让项目变得更啰嗦。2. 经典实现拆解一套能跑的过滤器基座2.1 接口怎么定义才能兼顾校验和数据变换先用最基础的多态方式把框架搭起来。我建议定义这样一个过滤器接口class IFilter { public: virtual bool handle(RequestContext ctx) 0; virtual ~IFilter() default; };这里有几个设计细节值得讲一下。第一handle接收的是一个RequestContext引用而不是原始请求对象。为什么要多包一层因为过滤器链需要传递的信息往往不止原始数据本身还包括错误码、错误信息、中间计算结果、耗时统计等等。如果每个过滤器都只能操作原始的LoginRequest那“记录一条错误码”这类事就很难塞进接口里。RequestContext是过滤器之间共享的“背包”可以这样定义struct RequestContext { LoginRequest request; bool valid true; std::string error_msg; std::string stage; // 记录执行到哪个环节方便定位 std::vectorstd::string log_entries; };所有过滤器都能修改ctx需要往下一层传信息就直接往ctx里写。这样各过滤器之间保持松散耦合它们甚至不需要知道彼此的存在只需要认识这个ctx。第二handle返回bool语义是“是否继续往下走”。返回true表示通过交给下一个过滤器返回false表示中断整条链停下来。这样最贴近“校验失败就短路”的自然逻辑。2.2 链怎么串起来顺序、跳转和中断有了接口再定义一个管道类来管理过滤器集合。最朴素的做法是维护一个std::vector然后逐个执行class FilterPipeline { public: void addFilter(std::shared_ptrIFilter filter) { filters_.push_back(std::move(filter)); } bool run(RequestContext ctx) const { for (auto filter : filters_) { ctx.stage filter-name(); if (!filter-handle(ctx)) { return false; } } return true; } private: std::vectorstd::shared_ptrIFilter filters_; };这里有个容易被忽略的点ctx.stage filter-name();这一行。每次执行前把当前环节的名字记下来一旦某个过滤器返回了false外层逻辑就能立刻从ctx.stage和ctx.error_msg里看到到底卡在哪一步。这个设计在问题排查时极其好用我下面会专门讲。如果你需要跳过某些过滤器可以在接口里加一个isEnabled()方法或者给IFilter增加一个bool enabled_成员在run里先检查再执行for (auto filter : filters_) { if (!filter-isEnabled()) continue; ... }2.3 写三个具体过滤器看看手感用上面的接口实现一个“非空校验过滤器”class NonEmptyFilter : public IFilter { public: std::string name() const override { return NonEmptyFilter; } bool handle(RequestContext ctx) override { if (ctx.request.username.empty() || ctx.request.password.empty()) { ctx.valid false; ctx.error_msg username or password is empty; return false; } return true; } };再实现一个“黑名单检查过滤器”class BlockListFilter : public IFilter { public: explicit BlockListFilter(std::setstd::string blocked) : blocked_(std::move(blocked)) {} std::string name() const override { return BlockListFilter; } bool handle(RequestContext ctx) override { if (blocked_.count(ctx.request.username)) { ctx.valid false; ctx.error_msg user is blocked; return false; } return true; } private: std::setstd::string blocked_; };再来一个“密码强度过滤器”class PasswordStrengthFilter : public IFilter { public: std::string name() const override { return PasswordStrengthFilter; } bool handle(RequestContext ctx) override { auto pwd ctx.request.password; bool has_digit std::any_of(pwd.begin(), pwd.end(), ::isdigit); bool has_upper std::any_of(pwd.begin(), pwd.end(), ::isupper); if (pwd.size() 8 || !has_digit || !has_upper) { ctx.valid false; ctx.error_msg password too weak; return false; } return true; } };注意我没有把原始成员函数isBlocked、checkPasswordStrength直接放进过滤器里而是把逻辑本身搬进了过滤器类。这是过滤器模式的精髓过滤器的粒度应当是一个完整的“处理动作”而不是对某个旧函数的简单包装。最后一个日志过滤器就很简单了它在handle里往ctx.log_entries追加一条记录然后返回true。组合起来使用时FilterPipeline pipeline; pipeline.addFilter(std::make_sharedNonEmptyFilter()); pipeline.addFilter(std::make_sharedBlockListFilter(blockedSet)); pipeline.addFilter(std::make_sharedPasswordStrengthFilter()); pipeline.addFilter(std::make_sharedLogFilter()); RequestContext ctx{req}; if (pipeline.run(ctx)) { // 所有校验通过执行真正的登录逻辑 login(ctx.request); } else { // 校验失败从 ctx.error_msg 拿到原因 }这样代码从“一个堆满if的大函数”变成了“一份可读的流程清单一组可独立测试的过滤器”。新增一个“IP限流过滤器”写个新类加进去就行完全不用动原有的任何一行代码。3. 现代C实现从虚函数到lambda的演进3.1 用std::function干掉继承过滤器变成普通函数只要你用的是C11之后的编译器其实不一定非要继承IFilter。可以用std::function作为过滤器类型让任何可调用对象都能充当过滤器。这样写的好处是每个过滤器不再是一个类而是一个lambda代码量更少也更灵活。using FilterFn std::functionbool(RequestContext); class FunctionPipeline { public: void add(FilterFn fn) { filters_.push_back(std::move(fn)); } bool run(RequestContext ctx) const { for (const auto fn : filters_) { if (!fn(ctx)) { return false; } } return true; } private: std::vectorFilterFn filters_; };用法变成FunctionPipeline pipeline; pipeline.add([](RequestContext ctx) { if (ctx.request.username.empty()) { ctx.error_msg username empty; return false; } return true; }); pipeline.add([blocked](RequestContext ctx) { return !blocked.count(ctx.request.username); });少写了一堆override和name()过滤器看起来就是一组策略函数。原先“校验、黑名单、密码强度”的逻辑一目了然。但std::function并非没有代价。它做了一次类型擦除会带来一次间接调用和一个小的堆分配只在构造时发生一次正常执行时主要多了一次虚函数调用级别的开销。对绝大多数业务场景这点开销完全可以忽略。如果你在一个高频的热点路径上做一个百万次/秒的过滤器链那就要注意了后面我会专门比较性能。3.2 用模板和变参实现编译期过滤器链C的模板给了过滤器模式另一种上限更高、自由度也更低的路子在编译期就把链条定死所有过滤器在编译期展开不产生虚调用也没有std::function的类型擦除开销。思路是用可变参数模板递归拼接过滤器每个过滤器只需要提供静态的process方法或者重载operator()templatetypename... Filters class StaticPipeline; template class StaticPipeline { public: static bool run(RequestContext ctx) { return true; } }; templatetypename Head, typename... Tail class StaticPipelineHead, Tail... { public: static bool run(RequestContext ctx) { if (!Head::process(ctx)) { return false; } return StaticPipelineTail...::run(ctx); } };每个过滤器写成struct或类核心逻辑放到process里struct NonEmptyProcessor { static bool process(RequestContext ctx) { return !ctx.request.username.empty() !ctx.request.password.empty(); } }; struct LogProcessor { static bool process(RequestContext ctx) { ctx.log_entries.push_back(log); return true; } };组装起来using LoginPipeline StaticPipelineNonEmptyProcessor, LogProcessor; RequestContext ctx{req}; LoginPipeline::run(ctx);这个方案优点很诱人零虚函数调用、零堆分配、编译器能把所有调用内联成一个函数。缺点也同样明显过滤器链的顺序、个数完全静态固定没法在运行期动态增删也没法按配置切换。我的看法是这种模板链比较适合嵌入式、游戏引擎这类对性能极其敏感、且链路高度固定的场景。如果你在写业务系统需要经常改规则静态链会让你每次想调顺序都要重新编译反而成了维护负担。3.3 C20 ranges换个角度看过滤器如果过滤器的目的是“对集合里的元素逐个筛选和变换”那现代C还有一个更贴近声明式的选择——std::ranges视图。#include ranges #include vector std::vectorint nums{1, 2, 3, 4, 5, 6}; auto filtered nums | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; });这里filter、transform就是过滤器的另一种表达对数据流里的每个元素做判断和加工。它天然惰性求值只有在真正遍历的时候才逐个计算适合大数据集的流式处理。但要注意ranges的过滤器是面向“批量元素”的它处理的是整个集合的变换管道而不是“单个请求穿过校验链”。业务上那种“失败即终止”的逻辑用ranges不太好表达。所以我的建议是两种过滤器并不冲突。集合级的数据清洗用ranges请求级的校验和加工用FilterPipeline各管一摊。三类实现方式的对比如下实现方式运行时开销动态增删过滤器可读性适用场景虚接口多态每次调用一次vtable跳转支持类多但清晰经典业务系统std::function类型擦除一次间接调用支持lambda写起来最简洁大多数C业务代码模板静态链编译期展开近似零开销不支持定义繁琐性能敏感、链路固定场景ranges视图惰性求值取用时执行不直接支持声明式最适合批处理集合数据清洗和转换4. 实战从头写一个参数校验过滤器管道4.1 需求定义与整体设计这里我拿一个接近真实项目的例子开发一个内部服务对外提供查询接口请求参数需要依次经过协议头校验、签名校验、频率限制、业务参数合法性校验、访问日志记录五个环节。如果某一环不通过立即返回对应错误码。我选择用std::function方式实现因为这类服务通常在运行期会根据配置启用或禁用某些校验比如内测环境不校验签名动态增减过滤器非常方便。先定义上下文和参数结构struct ApiRequest { std::string endpoint; std::string body; std::string client_id; std::string sign; std::string timestamp; }; enum class ErrorCode { OK 0, INVALID_PROTOCOL, SIGN_ERROR, RATE_LIMITED, INVALID_PARAM, UNKNOWN }; struct ApiContext { ApiRequest req; ErrorCode error_code ErrorCode::OK; std::string error_msg; std::string stage_name; int remaining_quota 0; };4.2 五个过滤器一个个实现协议头校验过滤器要求endpoint非空且以/api/v1/开头。pipeline.add([](ApiContext ctx) { const auto s ctx.req.endpoint; bool ok !s.empty() s.rfind(/api/v1/, 0) 0; if (!ok) { ctx.error_code ErrorCode::INVALID_PROTOCOL; ctx.error_msg bad endpoint; } return ok; });签名校验过滤器这个会复杂一点需要在lambda里捕获一个密钥字典。实际项目里我会用一个signFunc把body和secret算HMAC再比对这里简化成普通字符串拼接。pipeline.add([secret](ApiContext ctx) { std::string expected simpleHash(ctx.req.body secret); bool ok (expected ctx.req.sign); if (!ok) { ctx.error_code ErrorCode::SIGN_ERROR; ctx.error_msg sign mismatch; } return ok; });频率限制过滤器用一个计数器字典记录每个client_id在滑动窗口内的调用次数。为了演示简单用了固定窗口生产环境请用真正的滑动窗口或者Redis。pipeline.add([](ApiContext ctx) { auto counter accessCounter[ctx.req.client_id]; if (counter 100) { ctx.error_code ErrorCode::RATE_LIMITED; ctx.error_msg too many requests; return false; } counter; ctx.remaining_quota 100 - counter; return true; });业务参数合法性过滤器检查body里必要的字段是否齐全这里简化成调用一个解析函数后检查字段存在性。pipeline.add([](ApiContext ctx) { auto fields parseBody(ctx.req.body); bool ok fields.count(user_id) fields.count(query_type); if (!ok) { ctx.error_code ErrorCode::INVALID_PARAM; ctx.error_msg missing required fields; } return ok; });访问日志过滤器执行到这里说明前面几步都通过了记录一条经过脱敏处理的日志。pipeline.add([](ApiContext ctx) { std::string safe_body maskSensitive(ctx.req.body); writeAccessLog(ctx.req.client_id, ctx.req.endpoint, safe_body); return true; // 日志永远不阻断流程 });4.3 组装、中断与测试所有过滤器都注册到同一条管道里FilterPipelineApiContext pipeline; pipeline.add(...); // 按顺序添加 pipeline.add(...); pipeline.add(...); pipeline.add(...); pipeline.add(...);执行入口是这样一段代码ApiContext ctx{request}; if (pipeline.run(ctx)) { auto result handleQuery(ctx.req); return {ErrorCode::OK, result}; } else { return {ctx.error_code, ctx.error_msg}; }如果“协议头校验”先失败了后面几个过滤器根本不会执行频率计数也不会增加。这正是管道设计的优势短路逻辑由框架统一处理不需要在每一个过滤器里重复写if (!valid) return的判断。实际写的时候要注意过滤器注册顺序直接决定执行顺序。我习惯把“开销最小、最容易失败、且不依赖外部资源”的校验放在最前面比如协议头校验和参数非空校验。把“签名校验”这类涉及加密计算的放在靠后位置把“频率限制”这种会修改状态的放在参数校验之前。顺序影响两件事一是短路时节省的时间和资源二是前置过滤器可能修改了状态影响后置过滤器的判断依据所以一定想清楚依赖关系。5. 常见问题与排查技巧5.1 性能被虚函数拖垮了吗这是过滤器模式最常被问的问题。我的实测结论是在业务系统里过滤器的性能瓶颈几乎永远不在vtable跳转或std::function调用上而在过滤器内部的逻辑本身——比如哈希计算、字符串解析、数据库查询。一条五个过滤器的管道每个过滤器哪怕只有一次虚调用十次虚调用加起来也就几十纳秒级别你平时调用一个std::map插入都不止这个量。真遇到性能问题也别急着换模板静态链先按这个顺序排查std::function的拷贝往pipeline.add传lambda时如果lambda捕获了大对象拷贝成本可能很高。解决方法是确保过滤器的lambda捕获引用或指针比如捕获const std::shared_ptrSecretService而不是捕获一个Style的副本。上下文里的大对象拷贝ApiContext如果包含一个很大的std::string body每次run传引用没问题但如果你不小心在过滤器里写了auto local ctx就会复制整个body这是隐性性能杀手。过滤器链超过几十个这时候可以考虑合并几个相邻的、强相关的过滤器或者用ranges在数据级做批量变换减少逐条请求的循环跳转。5.2 过滤器顺序错了怎么办过滤器顺序错了是最隐蔽的bug因为代码编译和运行都不会报错只有业务结果不对。我见过最典型的错误是把“频率限制”放在“参数校验”之后。结果某些参数非法的请求也能消耗掉频率额度导致合法用户被误杀。要彻底解决这个问题最好的办法是在Pipeline里做一次注册时的顺序校验或者在过滤器上显式声明依赖。比如给过滤器加一个precondition()描述它要求前置阶段已经执行Pipeline在add时检查如果不满足就抛异常。实操上我会推荐一个更轻量的做法给每条过滤器命名然后在管道里维护一个const char* order_list[]作为预期顺序启动时逐一比对。一旦有人调错了顺序日志立刻给出明确警告for (size_t i 0; i filters_.size(); i) { auto expected expectedOrder[i]; if (filters_[i]-name() ! expected) { throw std::runtime_error(std::string(filter order broken at ) expected); } }5.3 调试过滤器管道有什么实用手段调试过滤器链第一件事是确认“到底执行到哪一步中断了”。我之前设计的ctx.stage_name就派上用场了。if (!pipeline.run(ctx)) { std::cerr pipeline stopped at: ctx.stage_name error ctx.error_msg std::endl; }如果你的界面没留这个字段现在就加上非常值得。第二个手段是“日志过滤器”。我经常在所有业务过滤器前面插入一个只记录不改状态的过滤器pipeline.add([](ApiContext ctx) { recordRequestSnapshot(ctx); return true; });这样即使后面某个过滤器失败了你也能从快照里还原出原始请求而不是看着一个已经可能被修改过的上下文发呆。第三个技巧是在过滤器之间传递的中间结果里加入版本号或时间戳。比如ApiContext里加一个int step 0每个过滤器执行时让step如果后续逻辑发现某个字段应该被前面的过滤器生成但没生成就能根据step判断出是哪一步漏了。5.4 什么时候真的不要用过滤器模式过滤器和所有设计模式一样有适用边界。以下几种情况我劝你还是老老实实写普通函数只有两个校验条件而且以后明显不会再加。这时候写管道纯属浪费。所有过滤器强依赖彼此的内部状态A过滤器的结果要用B过滤器的私有局部变量那说明你的边界划分有问题硬拆出来只会更别扭。每个过滤器执行时间差异极大比如第一个要秒级后面的要微秒级。这种不均匀的流程用管道没法做并发优化反而比手写的分段逻辑更难调优。需要跨进程/跨机器编排过滤器链。这里的“过滤器”已经演变成分布式处理节点了应该考虑专门的消息队列和流处理框架不要再硬套单进程内模式。另外还有一个非常容易被忽略的坑过滤器内部不要持有可变状态除非你清楚这是共享状态并在多线程下做了同步。我踩过的坑是一次把缓存对象直接放进了过滤器成员变量里结果多线程请求一来两个线程同时改这个缓存数据就乱了。正确做法是把这类状态放进线程安全的服务对象里过滤器只负责调用它不负责持有和修改它。6. 最后再说点实操体会过滤器模式是我做C项目时最先推荐引入的模式之一因为它改动成本低、理解成本低、收益却非常直接。一个原本两百行的校验函数拆成几个过滤器之后每个过滤器都可以单独写单元测试测试失败也能第一时间定位到具体环节。我在实际项目中用过虚接口版本也用了不少std::function版本最后的结论是业务代码优先选std::function因为少写类代码量更少而且遇到需要运行期改配的场景比如内测环境关闭某个校验动态组合要方便得多。还有一个小技巧给每个过滤器尽量起一个能自我说明的名字并且在name()里带上版本号或场景标签。比如BlockListFilter_v2。日志排查的时候你会感谢这个名字。过滤器模式很简单但真正顺手需要一个迭代过程先从小处用起来用顺手了你自然知道哪些地方适合套管道哪些地方不适合。
返回列表