
写业务代码写久了基本都会遇到这种场景一个HTTP请求进来要经过登录校验、权限判断、参数清洗、日志记录最后才轮到真正的业务逻辑或者一条用户评论发过来要先去掉首尾空白、过滤敏感词、转义HTML然后才能入库展示。如果这些步骤全塞进同一个函数里代码很快就变成一锅粥改一处怕碰坏另一处每次加需求都像在雷区里走钢丝。我后来在C项目里引入了过滤器模式Filter Pattern把每个处理步骤拆成独立的过滤器再串成一条链让数据按顺序流过每个节点每级只做一件小事。这篇文章我会从设计思路讲到完整实现再把实际工程里踩过的坑和性能优化经验一并整理出来。适合已经熟悉C基础语法、想在项目里真正落地设计模式的同学参考。1. 过滤器模式到底是什么C里为什么要专门讨论它1.1 从“一个函数塞满处理逻辑”说起先看一个反面例子。假设我们有一个处理评论的函数原来的实现大概是这样的std::string processComment(std::string input) { trim(input); escapeHtml(input); filterSensitiveWord(input, bad); toUpper(input); return input; }如果只有四步这样写勉强能看。但业务一旦复杂起来比如增加了“图片防盗链检查”“用户黑名单判断”“长度限制”“关键词高亮”这个函数会越拉越长。更难受的是每一步之间可能有隐式依赖必须先过滤敏感词再转义HTML否则转义后尖括号会被处理成实体敏感词检测找不到原文必须先转大写再统一比较还是先比较再转大写这些顺序逻辑一旦分散在业务代码里很容易出错。过滤器模式解决的就是这类问题把处理流程拆成若干“过滤器”每个过滤器只处理一个维度然后像管道一样串联起来。数据和上下文在管道中依次流动每级过滤器既可以对数据进行加工也可以决定流程是否继续。1.2 过滤器模式的定义与核心构成严格来说GoF的经典23个设计模式里并没有“过滤器模式”我们通常讨论的是Pipes and Filters管道-过滤器架构模式或者Java Servlet规范中的Intercepting Filter拦截过滤器。但在C里大家更习惯直接叫它过滤器模式本质上就是三个角色过滤器Filter处理数据或请求的最小单元暴露一个统一的处理接口。管道/链条Pipeline/Chain持有并管理一组过滤器按顺序调用它们。上下文/数据Context/Data在过滤器之间传递的对象可能是字符串、结构体也可能是完整的请求封装。打个比方过滤器链就是一套净水器。水流数据从进水口进入先后经过PP棉滤芯去大颗粒、活性炭滤芯去异味、反渗透膜滤芯去重金属每根滤芯只负责一件事哪根脏了换哪根。企业里的数据流水线、请求拦截器跟这个道理一模一样。1.3 与责任链模式、装饰器模式的区别初看过滤器模式很容易把它和责任链模式Chain of Responsibility混在一起。两者的代码结构确实很像但使用意图有明显区别对比项过滤器模式责任链模式装饰器模式核心目的按顺序加工数据/请求找到能处理该请求的处理者给对象动态增加能力是否默认继续通常所有过滤器依次执行可主动中断默认找到处理器即停止链上只有一个节点真正处理从上到下逐层包装后统一调用数据流传入数据结果仍传给下一个过滤器请求原始信息不变只是转交增强后的结果返回上一层常见场景日志、鉴权、参数清洗、数据转换报销审批、异常分发加缓存、加日志、加监控C里没有内置的过滤器框架所以实现方式非常灵活但也因为太灵活设计阶段想清楚几个关键选择后面能省很多返工。2. 想在C里落地过滤器模式先想清楚这3件事2.1 过滤器接口怎么定返回bool还是直接修改数据这是最先要拍板的设计。常见的接口有两种第一种是bool process(Context ctx)返回true表示继续返回false表示中断流程。这种设计适合拦截类逻辑比如登录校验失败、用户被拉黑、参数不合法一旦不通过后面的过滤器就不用执行了。第二种是void process(Context ctx)只对上下文做修改不决定流程是否中断。这种设计适合清洗类逻辑比如去空白、转大小写、脱敏所有过滤器都必须执行完。我在实际项目中更推荐第一种返回bool是程序员的“显式意图”它在接口层面就告诉你这个过滤器可能中断链路。如果全部用void后面想在链路中间加一个“参数校验失败直接返回”的逻辑就只能抛异常或者设置状态位语义不够清晰。当然如果确定所有过滤器都不能中断用void也确实更简洁。接口的处理对象也值得斟酌。最简单的是直接操作一个std::string写Demo很直观。但真实项目里我建议定义独立的Context结构体把所有需要传递的数据放进去比如请求头、用户ID、时间戳、原始数据、处理结果、错误码。这样过滤器之间传参不用每个过滤器都改签名后续加字段也只改Context不影响接口。2.2 链条用什么容器存vector还是list过滤器的容器选择很多人觉得无所谓但碰上高频调用就会不一样。我默认首选std::vectorstd::unique_ptrFilter。原因有三个对过滤器链来说绝大多数操作是“按顺序从头到尾遍历”vector的连续内存对缓存极其友好。过滤器链通常构造一次、长期使用很少在中间插入删除节点。vector在这类场景下的劣势完全体现不出来。unique_ptr把所有权表达得很清晰链条拥有这些过滤器链析构时自动回收不需要外面再手动管理。可以考虑给FilterChain提供一个reserve()方法让外部在添加过滤器前预分配容量。比如已知会有8个过滤器先chain.reserve(8)避免边添加边扩容。std::list只有在“过滤器链运行时经常增删节点”时才有优势但链中间插入节点本身会带来维护成本又容易导致顺序错误。至于shared_ptr我更建议在多个链需要共享同一个无状态过滤器时再用否则还是unique_ptr干净。2.3 动态增删过滤器Builder还是直接操作容器过滤器链的构造方式直接影响代码的可读性。最简单的做法是暴露add方法然后在main或者配置模块里一路add到底。这种方式适合写死规则的小项目。项目规模上来后我会给链加一个Builder。大概是这样class FilterChainBuilder { public: FilterChainBuilder add(std::unique_ptrFilter filter) { filters_.push_back(std::move(filter)); return *this; } FilterChain build() { return FilterChain(std::move(filters_)); } private: std::vectorstd::unique_ptrFilter filters_; };Builder的好处是能让调用方一眼看出“我正在组装一条链”组装和使用的职责分开。如果你还需要支持“根据配置动态创建过滤器”可以在Builder里加一个addByName(sensitive)之类的工厂逻辑按配置项生成对应过滤器。这里要提醒一句动态增删过滤器是能力不是默认需求。能编译期写死顺序就尽量写死用一个由配置驱动的“万能链”会让排错变得很痛苦。我在老项目里见过一份配置驱动的过滤器链线上行为要看配置文件和数据库两张表才能推出来某个过滤器被谁删了完全不可追踪。尽量保持链条“创建后基本不变”才便于理解和优化。3. 手把手实现一个文本处理过滤器链附完整代码3.1 场景把一条脏文本变成干净、安全的展示文本为了看得见摸得着我们做一个文本处理过滤器链。场景是用户提交的评论进入系统后系统要依次执行——去首尾空白、转义HTML标签、过滤敏感词、转大写。这个场景有明确的顺序要求必须先转义HTML再过滤敏感词。否则原始文本里的bad会先被转义成lt;badgt;敏感词过滤器如果用find(bad)仍然能找到看起来问题不大但如果先过滤敏感词把bad替换成***之后***再被转义如果敏感词本身含尖括号处理顺序就会影响最终结果。总之顺序这种隐式约束要让代码结构清晰可见这也是过滤器模式的价值之一。3.2 定义Filter接口与FilterChain链类我们使用bool apply(std::string data)作为过滤器的统一接口返回true表示继续false表示中断。#include iostream #include string #include memory #include vector // 过滤器抽象返回 true 表示继续false 表示中断 class Filter { public: virtual ~Filter() default; virtual bool apply(std::string data) 0; }; // 过滤器链按添加顺序逐个调用过滤器 class FilterChain { public: void add(std::unique_ptrFilter filter) { filters_.push_back(std::move(filter)); } bool process(std::string data) { for (const auto filter : filters_) { if (!filter-apply(data)) { return false; // 某个过滤器决定中断 } } return true; } private: std::vectorstd::unique_ptrFilter filters_; };这里有两个默认值需要解释一下。基类析构函数必须写成virtual否则通过基类指针删除派生类对象时属于未定义行为常见表现是析构不完整那些在派生类里申请的资源全部泄漏。apply返回bool我选择设计成在每一个过滤器里都有一份把什么传给下一个的责任——返回false链就停了。3.3 实现具体过滤器Trim、EscapeHtml、SensitiveWord、UpperCase四个过滤器分别做一件事。其中TrimFilter去掉首尾空白HtmlEscapeFilter把、、、转成HTML实体SensitiveWordFilter做简单的敏感词替换UpperCaseFilter把字母全部转大写。#include algorithm #include cctype // 1. 去首尾空白 class TrimFilter : public Filter { public: bool apply(std::string data) override { auto is_not_space [](unsigned char ch) { return !std::isspace(ch); }; // 去掉头部空白 data.erase(data.begin(), std::find_if(data.begin(), data.end(), is_not_space)); // 去掉尾部空白 data.erase(std::find_if(data.rbegin(), data.rend(), is_not_space).base(), data.end()); return true; } }; // 2. HTML 转义 class HtmlEscapeFilter : public Filter { public: bool apply(std::string data) override { std::string result; result.reserve(data.size()); for (char ch : data) { switch (ch) { case : result lt;; break; case : result gt;; break; case : result amp;; break; case : result quot;; break; default: result ch; } } data std::move(result); return true; } }; // 3. 敏感词过滤这里简化为把指定词替换为 *** class SensitiveWordFilter : public Filter { public: explicit SensitiveWordFilter(std::string word) : word_(std::move(word)) {} bool apply(std::string data) override { auto pos data.find(word_); while (pos ! std::string::npos) { data.replace(pos, word_.size(), ***); pos data.find(word_, pos 3); } return true; } private: std::string word_; }; // 4. 转大写 class UpperCaseFilter : public Filter { public: bool apply(std::string data) override { for (char ch : data) { ch static_castchar(std::toupper(static_castunsigned char(ch))); } return true; } };这里有几个实现细节值得注意。std::isspace和std::toupper的参数要转成unsigned char再调用否则传入负数非ASCII字符的高位属于未定义行为。HtmlEscapeFilter先reserve结果容量避免字符串反复扩容。SensitiveWordFilter在替换后使用pos 3跳过了新插入的***防止对已经处理过的占位符再做匹配。3.4 编译运行与验证结果把它们组装起来int main() { FilterChain chain; chain.add(std::make_uniqueTrimFilter()); chain.add(std::make_uniqueHtmlEscapeFilter()); chain.add(std::make_uniqueSensitiveWordFilter(bad)); chain.add(std::make_uniqueUpperCaseFilter()); std::string input hello bad world ; bool ok chain.process(input); std::cout ok ok \n; std::cout result input \n; return 0; }编译命令g -stdc17 -O2 filter_demo.cpp -o filter_demo ./filter_demo输出结果ok 1 result HELLO lt;***gt; WORLD整个处理过程可以这样追踪 hello bad world 先去空格变成hello bad world然后转义尖括号变成hello lt;badgt; world接着敏感词过滤把bad替换成***最后转成大写就是上面的结果。注意一个细节如果你把UpperCaseFilter放到SensitiveWordFilter前面原始文本里的BAD就不会被匹配到因为敏感词词典里存的是小写bad。这不是代码错了而是过滤顺序需要设计最好在创建链的地方用注释写明顺序原因。4. 过滤器模式实战中必然会踩的坑以及性能调优经验4.1 最容易犯的5个错误第一基类析构函数漏写virtual。这个问题在C面试里是老生常谈但实际写的时候太容易忘了。一旦漏掉过滤器链里的unique_ptrFilter析构时不会调用派生类析构函数静态分析工具不一定报错但内存泄漏和资源泄漏是肯定的。第二返回bool的逻辑自相矛盾。有人定义“返回false表示成功结束”有人在过滤器里忘了取反。我建议在接口注释里写死true继续、false终止并且所有实现统一遵守。代码评审的时候专门对着这个检查一遍不要靠记忆。第三过滤器持有可变状态却多线程共享。如果链里某个过滤器内部维护了一个计数器或者缓存多个线程同时调用process就会产生数据竞争。要让链“无状态”或者给有状态的过滤器加锁。前者更推荐后者次数多了会影响性能。第四链对象在遍历过程中被修改。process内部用了for循环遍历filters_如果你在某个过滤器里回调外部代码外部又把新的过滤器add进来vector重新分配内存后迭代器失效直接崩溃。解决方法是过滤器的apply里不要去操作所属链对象保持单向依赖。第五忽略顺序影响。本文的文本处理链就是一个例子先转大写还是先过滤敏感词、先转义还是先替换都会得到不同的结果。创建链的时候我习惯在添加过滤器的位置写一行注释说明为什么是这个顺序。4.2 性能开销虚函数、std::function与编译期过滤链用virtual函数实现过滤器模式胜在简单清晰但也需要知道性能边界。每次apply调用是一次虚函数调用现代CPU的分支预测器通常能处理得不错。但如果你每一秒要处理百万条短信或请求链上又有七八个过滤器虚函数开销会被放大成可观测的CPU时间。如果性能敏感先用-O2编译再看profiler数据。绝大多数时候瓶颈不是虚函数而是过滤器内部的动作——字符串拷贝、正则匹配、加锁、系统调用。HtmlEscapeFilter里反复拼接std::string都比虚函数代价高。另一个常见选择是用std::function作为过滤器类型。好处是lambda、普通函数、函数对象都能塞进去类型擦除很灵活。代价是std::function本身有额外开销如果存储的小对象超过SBO阈值会触发一次堆分配。所以能用自定义接口时不一定非要用std::function。如果链在编译期就完全确定可以彻底绕开虚函数。C17的折叠表达式可以这样玩struct TrimFilter2 { bool process(std::string data) { // 去空白逻辑 return true; } }; struct UpperCaseFilter2 { bool process(std::string data) { // 转大写逻辑 return true; } }; template typename... Filters bool run_chain(std::string data, Filters... filters) { return (filters.process(data) ...); } // 使用 TrimFilter2 trim; UpperCaseFilter2 upper; std::string data hello ; run_chain(data, trim, upper);这种写法让编译期直接生成一连串的普通函数调用没有虚表没有堆分配也没有动态容器遍历。代价是链上的过滤器集合写死在代码里不能运行时增删。适合算法固定的场景比如渲染管线、音频处理链。4.3 异常、线程与并发过滤器链怎么处理过滤器内部抛出异常时链的中断逻辑会失效。因为是for循环异常会直接向上抛给process调用者。要不要在process里捕获我倾向于不捕获直接让异常继续上抛由全局的异常处理器兜底。原因是如果你捕获后返回false调用方分不清是“业务校验失败”还是“系统异常”只能统一当成失败处理真实的异常日志容易丢。但如果你需要一个统一错误码的过滤器链可以给Context加一个std::error_code字段每个过滤器内部捕获自己的异常并设置错误码然后返回false。这样做的好处是链路可以用返回值快速中断坏处是每个过滤器都要写try-catch代码会变啰嗦。关于线程并发有两种使用方式。第一种同一链实例被多个线程只读共享前提是所有过滤器都无状态。这是效率最高的方式。第二种每个线程持有自己的链实例各跑各的互不干扰。后者更稳妥适合过滤器里有缓冲区等不可共享状态的场景。如果数据量大且过滤器之间没有前后依赖理论上可以用流水线并行把多个过滤器分配给不同线程但那是另一个话题了。这里要提醒过滤器链通常有前后依赖“并行”很容易破坏顺序语义数据清洗、鉴权这类场景天然是串行的别为了并发而并发。5. 我的一些经验总结与后续可以扩展的方向5.1 我个人在实际项目里的使用心得用了几年过滤器模式我的体会是它最舒服的场景是请求处理链和数据清洗管道。比如Web服务里的中间件每个中间件就是一个过滤器搜索服务里的文档预处理每个清洗步骤是一个过滤器。在这些场景里过滤器的数量通常会超过四个而且会不断调整顺序和增删。但不要一上来就套过滤器模式。如果只有两三个步骤而且需求非常稳定直接写函数调用反而更直观。抽象是有成本的——多了一层接口、一组类阅读代码的人还要多学你的链是怎么工作的。过滤器模式的价值在“变化”中体现当你要频繁增删步骤、调整顺序、复用某一级处理逻辑时它的优势才真正显现。另外接口命名一定要清晰。我见过有人把Filter里的方法叫handle结果跟责任链还不一样读者完全懵。要么叫process要么叫apply并且在文档里标明“处理当前数据并传给下一级返回false则中断”。一份好的接口文档比一百行注释都管用。5.2 还能怎么扩展从过滤器链到插件系统过滤器模式继续往前走能演进出不少更高级的东西。第一个方向是在FilterChain里引入“分隔点”比如beforeFilter和afterFilter让某些过滤器只钩在链首或链尾。这其实就是AOP风格的扩展很适合统一加时间统计、日志、埋点。第二个方向是把过滤器做成动态加载的插件。C里可以用dlopen在运行时从共享库加载过滤器实例然后动态注册到链上。这样业务的处理管道可以像乐高积木一样拼装新增一个插件只需要编译那个插件本身不用重新编译整个服务。第三个方向是让Filter处理更丰富的上下文。当前示例只处理std::string实际项目可以把上下文定义成struct RequestContext里面包含原始请求体、解析好的字段、用户会话信息、处理结果等。过滤器之间不再是“改完字符串传给下一个”而是“互相协作完善同一个上下文”。最后再分享一个小技巧在设计过滤器接口时一定把“返回false代表中断”这个语义写进类的注释里。我曾经因为没写清楚两个团队对apply的返回值理解完全相反一个在登录校验失败时返回true继续另一个在鉴权失败时返回false中断结果线上服务浪费大把时间处理未登录请求。设计模式本身不复杂复杂的是让团队对同一套规则达成一致。过滤器模式的美好恰恰在于它逼着你把“每步做什么、什么时候停、数据怎么传”都明明白白摆出来——这就是好代码该有的样子。