过滤器模式实战:用过滤链取代if-else,手写通用框架与内容审核Demo
1. 过滤器模式是个什么东西先别急着看定义我想先请你回忆一个常见的场景。你写了个用户注册接口前端把表单数据POST过来。后端第一件事儿是什么校验参数。用户名不能为空、邮箱格式对不对、密码长度够不够、手机号是不是11位、用户名有没有被注册过、IP有没有进黑名单……这些逻辑你打算怎么写我见过太多人直接在Service里这样写public void register(User user) { if (user.getName() null || user.getName().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } if (user.getEmail() null || !user.getEmail().matches(...)) { throw new IllegalArgumentException(邮箱格式不正确); } if (user.getPassword() null || user.getPassword().length() 6) { throw new IllegalArgumentException(密码长度不能少于6位); } // 再查重、再校验IP、再... // 一百行过去了还没开始干正事 userDao.save(user); }这还只是一个接口。等接口多了校验逻辑开始重复你慢慢复制粘贴了几十处再后来新需求来了要在所有接口上增加黑名单校验你两眼一黑开始全局搜索在哪复制过……过滤器模式Filter Pattern就是用来解决这类问题的。它的核心思路非常朴素把数据处理的逻辑拆成一串独立的“关卡”每个关卡只做一件事数据依次穿过这些关卡被关卡层层处理或拦截。这个名字叫“过滤器”但你完全可以把它理解成一张安检流水线乘客过安检先验身份证再过X光机再查随身物品每一道手续互不干扰任何一个环节出了问题人都进不了站。过滤器模式是一种结构型设计模式在Gof那本经典书里并没有被单独立项但它却是日常开发里使用频率极高的一种组织代码的方式——凡是那些“请求进来、响应出去”的系统几乎都离不开它。Java世界里大名鼎鼎的Servlet Filter、Spring MVC的Interceptor、中间件Apache Shiro的过滤链、甚至Node.js里Express的中间件机制本质都是过滤器模式的具体实现。这篇文章不从枯燥的UML类图讲起我想直接带你从问题出发拆解过滤器模式的设计思路然后手写一个足够生产使用的通用过滤链框架再以一个文本内容审核系统为例串起完整的落地过程。最后我把自己踩过的坑和排查经验也一并整理了方便你直接拿去参考。适合谁来读后端开发、全栈工程师、架构设计爱好者以及所有写代码写到被if-else淹没的人。你不需要有任何设计模式基础我会一步步讲清楚。2. 为什么需要过滤器模式一场与if-else的持久战2.1 一个一个写if-else不行吗行。当然行。程序嘛怎么都能跑。但“能跑”和“好维护”是两回事。我见过一个支付回调的处理代码里面依次要验签名、验订单状态、验金额、验重复回调、验商户状态、验渠道IP、风控规则……十几个校验写在一个方法里层层嵌套最外层if包着内层if中间还有几个try-catch一个方法干到三四百行。后来一次线上事故要在所有校验之前加一个商户白名单判断当时那个维护的同事找这个切入点找了半小时改完又花了两小时回归测试一边改一边骂。这就是if-else堆叠带来的真实成本。当你所有的校验逻辑平铺在业务方法里时改一个需求可能需要动整段代码牵一发而动全身。而且校验逻辑还往往会在不同接口里重复出现你今天在A接口加了一段手机号校验过两天B接口也要用你复制过去改了两行正则从此两段代码各走各的命出了bug只修一处——这是典型的“散弹式修改”改起来最痛苦。过滤器模式就是要解决两个问题解耦和复用。把每一个校验提炼成独立的过滤器过滤器之间互不感知数据顺着一条链依次流过。你新增一种校验就是往链上多挂一个节点你调整校验顺序就是调整链上节点的排列顺序你别处需要同样的校验把那个过滤器拿过来复用即可。整个系统变成了一根水管你可以自由地往里面插滤芯。2.2 过滤器模式的核心角色拆解在动手写代码之前先把过滤器模式的三个核心角色搞明白。搞明白这三个角色你就能看懂所有过滤器框架的源码。过滤器接口Filter。这是整个模式的抽象核心它定义了一个过滤行为应该长什么样。通常一个过滤器会有一个doFilter或者叫handle、execute方法这个方法接收待处理的数据对象以及一个“链条引用”。为什么要把链条传进去因为一个过滤器不能处理完就不管了它得在合适的时候把数据交给下一个过滤器。链条引用就是干这个用的。过滤器链FilterChain。这是过滤器们的容器和组织者。它维护着一个过滤器列表并按顺序依次调用。调用到最后一个过滤器时进入真正的业务处理逻辑。在Servlet里这个“最后一个环节”就是你写的Servlet在我们自己设计的系统里这个环节可以是你的业务Service方法。数据对象与上下文Context。这是顺着链条流动的“水”。它可以是简单的请求对象也可以是一个复杂的上下文容器里面既装着原始数据也装着各过滤器处理过程中的中间产物。设计得好的上下文各过滤器之间通过它交换信息——比如过滤器A解析出用户ID放进去过滤器B从里面取出来做权限校验。我用一个生活类比再强化一遍过滤器就是机场安检的各个独立岗位过滤器链就是那条把旅客数据带过所有岗位的通道而旅客手里的行李箱上下文则在每个岗位被检查、被标记最终跟着旅客一起登机。2.3 它和朋友亲戚的区别责任链、管道、装饰器学设计模式的人很容易把过滤器模式和责任链模式、管道模式弄混这里我用最直白的话把它们的边界划清楚。责任链模式的核心特征是“有一个Handler能处理就短路不再往后传”。比如报销审批组长能批就不找经理经理能批就不找总监。过滤器模式不是这样它默认所有过滤器都会执行每个过滤器都有机会处理数据。管道模式Pipeline和过滤器模式长得最像数据流经一个个阶段每个阶段做一次变换。区别在语义上管道模式重在“变换”数据经过每个阶段后形态会变化像流水线加工零件从铁矿石到钢材到螺丝过滤器模式重在“检测与筛选”数据形态不一定变化可能只是被打上标记或直接被拦截掉。实际工程里两者经常混用没必要过于纠结边界。装饰器模式倒是容易区分得多装饰器是“包裹”同一个对象层层增强功能调用入口始终是同一个方法过滤器是“串联”数据流数据本身在节点间传递。你给一个咖啡对象包装上牛奶、糖浆最后得到的还是“一杯咖啡”只是能力变强了过滤器嘛一档一档地过过不了就销毁了东西还是不是原来的东西都难说。2.4 什么时候该用、什么时候别硬用过滤器模式好归好但你不可能在项目里什么地方都上。根据我的经验以下场景非常适合使用过滤器模式一是请求处理流程有大量的前置校验、鉴权、日志记录、参数预处理需求。这是最典型的场景Web框架的拦截器就是这么干的。二是数据处理管道需要对一批数据做多阶段、顺序敏感的清洗和转换。比如从外部抓取的文章内容要先去掉HTML标签、再过滤敏感词、再做文本规范化、再统计字数……顺序虽然固定但每一个环节都值得独立成模块方便增删。三是稳定不变的流程骨架 频繁变动的处理策略。比如内容审核流程永远是“内容进入 - 一道道策略检查 - 给出结论”但具体的策略广告检测、违禁词检测、低俗内容检测是经常要新增的。用过滤器模式新增策略就是新增一个过滤器挂到链上。反过来下面这些情况就别硬套了业务逻辑本身就是一个不可分割的顺序流程各步骤强依赖前一步的结果且耦合极深硬拆成过滤器只会增加参数传递和上下文管理的复杂度或者你的过滤器总共就一两个未来也看不出扩张的迹象——那直接用普通方法调用就完了设计模式是服务开发的不是用来写进简历炫技的。有个判断标准我觉得特别好用当你在两个以上的业务方法里发现了重复的校验或预处理逻辑而且你还得小心翼翼地复制粘贴时那就该上过滤器模式了。如果只是某一个方法内部的少量判断强行抽出来反而制造割裂感。3. 核心细节解析与设计要点3.1 先从接口定义讲起过滤器模式的各种实现差别主要就在接口的签名设计上。我拿生产里最常用的两种写法对比着讲。第一种经典Servlet式。这个大家只要写过Java Web就见过接口长这样public interface Filter { void doFilter(Request request, Response response, FilterChain chain); } public interface FilterChain { void doFilter(Request request, Response response); }过滤器拿到请求和响应自己处理一部分逻辑然后调用chain.doFilter()把请求放行给下一个过滤器。你不用操心当前是第几个过滤器链条自己维护下标。这种设计的精髓在于“放行”这个动作过滤器可以选择放行也可以选择不放行——不放行时直接返回响应链上后面的过滤器就不再执行了。这天然支持“校验失败就中断”的场景。第二种SDK式。把上下文对象统一封装起来过滤器之间的交互不依赖具体的请求响应类型public interface FilterT { void doFilter(T context, FilterChainT chain); } public interface FilterChainT { void doFilter(T context); }这种写法更通用泛型T可以是任何你自定义的上下文对象。我后面要写的完整示例就是基于这种风格的因为它不绑定Web框架你可以在自己的工具类、定时任务、消息处理管道里随便用。这两个签名看起来没差多少但有几个细节你得注意。第一个是所有过滤器共用一个上下文还是每个过滤器新建一个。大多数情况下我们共用一个上下文过滤器A写入的信息过滤器B能看到这是过滤器之间通信的主要手段。但也有特殊情况需要隔离比如并行执行多个过滤器时每个过滤器得操作自己的副本不能互相污染。第二个是放行和不放行的语义。有的框架用chain.doFilter()调用来放行有的用返回值布尔值来表示是否继续。都行但doFilter式更灵活因为过滤器可以在放行之后还执行一段后置逻辑比如记录耗时——这就是很多网关日志拦截器的实现原理。第三个是过滤链执行完之后的“回程”。递归调用doFilter时最后一个过滤器执行完业务后会一层层往回返回每一层后面的代码都可以在回程时执行。这个特性用在日志和耗时统计上非常香但新手往往意识不到后面我会专门演示。3.2 过滤器与过滤器链的设计权衡设计一个过滤器链有几个点需要你提前想清楚不然等写完了发现自己被自己的设计卡住就很尴尬。列表还是链表。大多数实现用ArrayList顺序遍历简单高效适合过滤器数量少几十个以内的场景。理论上也可以用链表把过滤器串起来好处是动态增删节点方便但考虑到过滤器的数量级通常不会很大ArrayList的遍历开销完全可以忽略。我建议无脑ArrayList你维护起来省心性能也不会成为瓶颈。顺序敏感。过滤器链是有顺序的。同一个过滤器放在不同的位置行为可能完全不一样。比如日志过滤器放在最前面可以把所有请求都记到日志放在鉴权过滤器后面就只能记录通过鉴权的请求。所以设计的时候一定要明确每个过滤器的优先级并且把这个优先级固化到配置里让别人改的时候有据可依。我的做法是为每个过滤器定义一个getOrder()方法返回int值小的排前面和Spring的Order注解一个思路。支持动态增删。一个完善的过滤器链最好能支持在运行时调整过滤器列表。比如灰度发布时某个新过滤器只对部分流量生效那就不能把过滤器写死在代码里得能按条件动态决定是否加入。我见过一个方案是把过滤器列表放到配置中心改配置就能即时变更过滤链运维起来是真的方便。中断与非中断。有些过滤器只是记录一下日志不该阻断主流程有些过滤器是硬校验不通过就得把请求挡回去。所以过滤器接口里的抛异常行为需要明确约定抛异常中断且失败不抛且正常放行继续返回特定标记可能走了分支。我在实际的过滤链框架里一般会约定过滤器要么正常调用chain.doFilter()要么直接抛出FilterException。这样业务代码只需要try-catch一层就能统一处理。3.3 注意看过滤器顺序为何如此重要顺序这个东西讲道理没用得看例子。假设你现在要给一套支付系统加两个过滤器一个是“签名校验过滤器”一个是“风险控制过滤器”。请问谁在前答案是签名校验在前。为什么因为风险控制需要信任请求来源的合法性。如果连签名都没校验通过一个伪造的请求你还去跑风控纯属浪费计算资源。更重要的是风控系统可能会有拦截名单、计数统计如果被一个伪造请求白白标记了一次还可能造成后面正常用户的误伤。再来一个例子。图片上传服务一个“文件大小限制过滤器”一个“文件类型校验过滤器”一个“内容审核过滤器”。大小限制应该放在最前面因为文件太大你连读都没必要读完直接拒绝节省IO带宽。然后是类型校验最后才是内容审核因为内容审核最耗时、成本最高前面挡掉不合规的它只需要处理真正有效的数据。你用这个思路去回看自己项目里的拦截器配置会发现很多顺序可能都是错的。顺序不是拍脑袋定的它的基本原则是成本越低的校验越靠前越可能导致中断的越靠前越是基础的通用能力越靠前。日志、耗时统计这种通用能力在最前签名鉴权其次业务校验再往后业务处理在最后。4. 实操手写一个通用过滤链框架跑一个内容审核Demo4.1 框架代码骨架空谈误国实干兴邦。我现在带你从头写一个极简但完整的过滤链框架然后用一个“文本内容审核”的例子把它跑起来。这个例子的业务场景很常见用户发帖之前后台要对文本依次做——敏感词过滤、垃圾广告检测、内容长度校验、审核日志记录。四条过滤器依次挂上链任何一个不通过帖子就发不出去。先定义核心接口。为了方便演示我用Java写你用别的语言也能照搬思路。// 过滤器接口 public interface FilterT { void doFilter(T context, FilterChainT chain) throws FilterException; } // 过滤器链接口 public interface FilterChainT { void doFilter(T context) throws FilterException; }然后写过滤器链的标准实现。核心是一个ArrayList保存过滤器列表用一个游标记录当前执行到第几个。每次调用doFilter先判断游标是否越界越界说明所有过滤器都执行完了这时就该执行真正的业务方法了——这个“真正的业务方法”我们通过一个接口回调传进来。public class DefaultFilterChainT implements FilterChainT { private final ListFilterT filters; private final int currentIndex; private final FilterInvokerT finalInvoker; public DefaultFilterChain(ListFilterT filters, FilterInvokerT finalInvoker) { this.filters filters; this.currentIndex 0; this.finalInvoker finalInvoker; } private DefaultFilterChain(ListFilterT filters, int currentIndex, FilterInvokerT finalInvoker) { this.filters filters; this.currentIndex currentIndex; this.finalInvoker finalInvoker; } Override public void doFilter(T context) throws FilterException { if (currentIndex filters.size()) { finalInvoker.invoke(context); return; } FilterT currentFilter filters.get(currentIndex); DefaultFilterChainT nextChain new DefaultFilterChain(filters, currentIndex 1, finalInvoker); currentFilter.doFilter(context, nextChain); } public interface FilterInvokerT { void invoke(T context) throws FilterException; } }这个实现大家都看得懂但注意一个关键设计细节nextChain每次都是新创建的对象而不是直接修改游标后复用同一个链条对象。为什么因为链条可能被多个线程并发调用如果共用同一个游标必然出现线程安全问题。每次递归都创建一个只比当前多一格的链条对象相当于把“当前执行到哪了”这个状态绑定在了每个调用链自己的对象上天然线程安全。这个点面试里也经常问记住了能加分。再看这段代码currentIndex filters.size()这个判断是每个过滤器递归到终点的终止条件它保证了数据一定会穿过所有过滤器最终执行到真正的业务逻辑。4.2 过滤器的具体实现框架搭好了接下来写内容审核的具体过滤器。我首先定义一个审核上下文对象。它不是一个简单的文本字符串因为它需要承载的内容太多原始文本、清洗后的文本、审核是否通过、拦截原因、审核耗时、批注信息等等。public class AuditContext { private String content; // 待审核的原始内容 private String cleanedContent; // 经过前面过滤器处理后的内容 private boolean allowed true; // 是否放行默认放行 private String rejectReason; // 拦截原因 private long startTime; // 开始时间用于统计 // getter/setter 省略... }然后是四个过滤器。第一个是敏感词过滤器这是整个链的守卫public class SensitiveWordFilter implements FilterAuditContext { private final ListString sensitiveWords; public SensitiveWordFilter(ListString sensitiveWords) { this.sensitiveWords sensitiveWords; } Override public void doFilter(AuditContext context, FilterChainAuditContext chain) throws FilterException { for (String word : sensitiveWords) { if (context.getContent().contains(word)) { context.setAllowed(false); context.setRejectReason(内容包含敏感词: word); return; // 不放行直接中断 } } chain.doFilter(context); } }第二个是垃圾广告过滤器用于检测类似“加微信xxxx”“点击链接领取”这类广告文案。为了演示效果我用正则简单模拟public class AdFilter implements FilterAuditContext { private static final Pattern AD_PATTERN Pattern.compile(加微信|点击链接|优惠领取|特惠秒杀); Override public void doFilter(AuditContext context, FilterChainAuditContext chain) throws FilterException { String cleaned context.getCleanedContent() null ? context.getContent() : context.getCleanedContent(); if (AD_PATTERN.matcher(cleaned).find()) { context.setAllowed(false); context.setRejectReason(内容疑似包含广告营销信息); return; } chain.doFilter(context); } }第三个是内容长度校验。这个过滤器放在后面是有意为之因为敏感词和广告已经挡掉了大部分非法内容长度校验只需要处理真正要发布的内容public class LengthCheckFilter implements FilterAuditContext { private final int maxLength; public LengthCheckFilter(int maxLength) { this.maxLength maxLength; } Override public void doFilter(AuditContext context, FilterChainAuditContext chain) throws FilterException { String content context.getCleanedContent() null ? context.getContent() : context.getCleanedContent(); if (content.length() maxLength) { context.setAllowed(false); context.setRejectReason(内容长度超过上限 maxLength); return; } chain.doFilter(context); } }第四个是审核日志过滤器。这个过滤器比较特殊它不拦截任何数据只是负责在审核前记录时间、在审核后记录结果。看见了吗同一个过滤器里chain.doFilter(context)调用之前的代码是前置逻辑之后的代码就是后置逻辑public class AuditLogFilter implements FilterAuditContext { Override public void doFilter(AuditContext context, FilterChainAuditContext chain) throws FilterException { context.setStartTime(System.currentTimeMillis()); System.out.println([审核开始] Thread.currentThread().getName()); chain.doFilter(context); long cost System.currentTimeMillis() - context.getStartTime(); System.out.println([审核结束] 结果 (context.isAllowed() ? 通过 : 拦截) , 原因 context.getRejectReason() , 耗时 cost ms); } }有一个逻辑点要注意如果前面的过滤器return了不调用chain.doFilter()那后面的过滤器就都不会执行了。但是日志过滤器不会受影响因为它的代码是围绕着chain.doFilter()展开的——不管链条走到哪一步只要当前的过滤器活着它return之前总会走完自己的方法体。等等不对上面的代码里如果敏感词过滤器把请求拦截了chain.doFilter(context)是不会被调用的那日志过滤器里chain.doFilter(context)之后的那行打印也不会执行。这就引出一个设计细节你希望日志过滤器能在过滤器被拦截时依然记录结果你就得把后置逻辑放到finally块里。我来改一下这个日志过滤器让它更健壮public class AuditLogFilter implements FilterAuditContext { Override public void doFilter(AuditContext context, FilterChainAuditContext chain) throws FilterException { context.setStartTime(System.currentTimeMillis()); System.out.println([审核开始] Thread.currentThread().getName()); try { chain.doFilter(context); } finally { long cost System.currentTimeMillis() - context.getStartTime(); System.out.println([审核结束] 结果 (context.isAllowed() ? 通过 : 拦截) , 原因 context.getRejectReason() , 耗时 cost ms); } } }这个过滤器放的位置也有讲究。我把日志过滤器放在链的第一位这样它记录的耗时是最接近整体耗时的放在最后一位也行但那样的话拦截发生在它之前的过滤器时finally块依然能记录但记录的耗时就不完整了。我一般习惯放第一位。4.3 组装过滤链并执行测试过滤器已经写好了接下来就是组装。这里我模拟一个完整的主程序把链条跑起来public class AuditDemo { public static void main(String[] args) { // 1. 构建待审核内容 AuditContext context1 new AuditContext(); context1.setContent(这是一个正常的帖子今天天气真不错适合出去走走。); AuditContext context2 new AuditContext(); context2.setContent(加微信xxxx点击链接领取优惠券。); // 2. 构建过滤器列表注意顺序日志 - 敏感词 - 广告 - 长度 ListFilterAuditContext filters new ArrayList(); filters.add(new AuditLogFilter()); filters.add(new SensitiveWordFilter(Arrays.asList(赌博, 代开发票))); filters.add(new AdFilter()); filters.add(new LengthCheckFilter(200)); // 3. 构建“最终执行业务”的包装器 DefaultFilterChain.FilterInvokerAuditContext finalInvoker ctx - { // 所有过滤器都通过后执行真正的发布逻辑 System.out.println( 帖子发布成功内容摘要: ctx.getContent().substring(0, Math.min(ctx.getContent().length(), 10)) ...); }; // 4. 构建链条并执行 DefaultFilterChainAuditContext chain new DefaultFilterChain(filters, finalInvoker); System.out.println(---------- 测试1正常内容 ----------); chain.doFilter(context1); System.out.println(最终结果: (context1.isAllowed() ? 允许发布 : 已拦截 context1.getRejectReason())); System.out.println(); System.out.println(---------- 测试2包含广告内容 ----------); chain.doFilter(context2); System.out.println(最终结果: (context2.isAllowed() ? 允许发布 : 已拦截 context2.getRejectReason())); } }运行这段代码输出应该是下面这样的---------- 测试1正常内容 ---------- [审核开始] main 帖子发布成功内容摘要: 这是一个正常的帖子... [审核结束] 结果通过, 原因null, 耗时1ms 最终结果: 允许发布 ---------- 测试2包含广告内容 ---------- [审核开始] main [审核结束] 结果拦截, 原因内容疑似包含广告营销信息, 耗时0ms 最终结果: 已拦截内容疑似包含广告营销信息看到没有第二个测试里 帖子发布成功这行没打印出来因为广告过滤器已经拦截了。而日志过滤器因为有finally照样打印了拦截的结果和耗时。到这里一个完整的过滤链就通了。整个流程你捋一下数据对象AuditContext进入链条 - 日志过滤器记录开始 - 传给敏感词过滤器 - 通过 - 传给广告过滤器 - 命中拦截 - 停止传递 - 日志过滤器记录结果 - 结束。敏感词、广告、长度、日志四个过滤器都可以单独拆出来复用。以后新增一种审核规则写一个过滤器加进列表就行一行代码都不用改原有的过滤器。这就是过滤链模式的核心价值。如果你用Spring还可以把这些过滤器声明成Spring Bean用Order注解控制顺序通过Autowired自动注入到链里。代码更加优雅。核心思路不变这里就不展开了。5. 高级用法与性能优化实录5.1 过滤器里的状态传递与上下文设计过滤器之间怎么传数据无非就是通过上下文对象。但上下文对象设计得好不好直接决定了你过滤器代码的优雅程度。我见过有人让过滤器之间通过参数逐个传递一个过滤器算出的中间结果只能用局部变量返回给上一个调用方传给下一个过滤器时还要拼进上下文。这种做法在过滤器数量少时勉强能跑过滤器一多就乱成一锅粥到处是强转、判空。我的建议是上下文对象的设计遵循三个原则第一字段语义要清晰。不要什么字段都往里面塞一个上下文对象装了几十个字段看起来像个杂货铺。应该根据业务阶段拆分成几个小组件塞进去。比如审核上下文里可以有OriginalContent、ProcessedContent、AuditResult、TraceInfo这几个内部类或者子对象各过滤器的关注点相对分散。第二传递方向要明确。过滤链是顺序执行的一般只允许前面的过滤器写入、后面的过滤器读取。如果后面反写、前面再读链式的可预测性就被打破了出了问题你很难定位是哪个过滤器改的状态。如果你确实需要双向交互建议用事件机制或者显式标注清楚。第三避免共享可变全局状态。过滤器本身应该是无状态的、可复用的。所有变化的状态都应该放在上下文对象里而不是过滤器的成员变量里。你想想如果同一个过滤器实例被两个线程并发调用过滤器成员变量被一边改一边读那结果必然错乱。所以过滤器接口的实现类最好设计成无状态Bean或者保有的状态都是只读的。5.2 并发场景下多个过滤链并行执行有些业务场景下过滤器之间没有先后依赖并行执行比串行执行效率高得多。比如一条审核链上敏感词检测、图片识别、语音转文字审核三者独立完全可以并发跑。实现上你把过滤链的执行改成并行策略用一个线程池提交所有可并行的过滤器任务主线程等待所有任务完成。每个过滤器操作各自的上下文副本最后合并结果。注意合并规则要事先定义清楚有一个失败就算失败还是只要有一个通过就算通过这直接影响你怎么合并。不过我得泼一盆冷水默认情况下过滤器链最好是顺序的。并行执行会引入线程调度、资源竞争、结果合并等一系列复杂性除非你确实有非常明确的耗时瓶颈否则不要为了并行而并行。我见过一个项目把整条链路改成了CompletableFuture异步并行调试bug时在线程切换之间焦头烂额最后性能提升却不到10%纯粹给自己找罪受。如果真的并行我会建议你把“可并行的”和“必须串行的”分成两个阶段的链。第一阶段并行跑第二阶段把所有结果汇总后顺序跑。这样既利用了并发能力又把链条的语义控制在可以理解的范围内。5.3 性能优化避免重复计算与合理的失败快速返回过滤链模式本身是一个O(n)的线性扫描每个过滤器执行一遍n是过滤器数量。真正影响性能的不是链条框架而是过滤器内部的计算量。第一个优化点是重复计算。如果多个过滤器都要解析同一个文本、同一个JSON你应该把解析结果缓存到上下文对象里。比如广告检测要分词敏感词检测也要分词两个过滤器各自分词一次就浪费了。改进办法是把分词结果放在上下文里后面的过滤器直接取用。第二个优化点是失败快速返回。什么叫快速返回一旦某个过滤器命中拦截立即停止链的执行不要再往后跑了。我们前面的例子已经体现了这一点广告过滤器拦截之后长度校验过滤器就不会执行了。在某些框架里这个特性默认是缺失的——所有过滤器都会执行完再汇总结果。这种设计对耗时敏感的系统来说非常致命白白浪费了大量计算。我建议你在设计过滤器接口时就把“只有chain.doFilter被调用时才继续”这个语义固化下来。第三个优化点是过滤器实例复用与线程安全。过滤器链框架本身是线程安全的我们创建新链条对象来隔离游标状态过滤器实例最好也做成线程安全的。最简单的办法是过滤器实例用单例内部不要有可变的成员变量。如果一定要有状态用ThreadLocal按线程隔离但用完后记得清理否则线程池复用时数据会串。5.4 错误处理过滤器抛异常时怎么办先说一个实际的教训。有一回我在线上把一个鉴权过滤器写错了里面调一个远程服务超时直接抛出了RuntimeException又没有全局异常处理兜底结果所有请求都返回500整站雪崩。过滤器链里的异常处理非常重要你不能假设每个过滤器都不出错。我的设计原则是过滤器抛出的异常应该有统一的封装和出口。FilterException作为统一包装内部可以携带错误码和提示信息链的最外层用try-catch统一捕获转换成用户友好的错误响应。同时还要区分两类异常——一类是可预期的业务校验失败比如“密码不正确”“没有权限”。这类不应该当作异常抛出来而是正常返回一个结果标记到上下文里。因为异常的成本比正常返回高得多网上有说法异常比正常返回慢几十倍业务校验失败本来就是高概率事件用它抛出异常不划算。另一类是不可预期的系统异常比如数据库挂了、远程服务连不上。这种必须抛出并向上传递让全局异常处理器记录日志、发出告警。当然如果你的过滤链框架设计得完善应该对这两种情况都做好约定。我的约定非常朴素过滤器如果需要中断流程就抛FilterException业务校验不通过往上下文写入失败状态然后return即可不抛异常。系统异常直接让它继续往上抛。6. 常见问题与排查技巧实录6.1 排查顺序问题为什么过滤结果和预期不一致过滤器顺序搞错是最常见的bug而且往往不是编译期能发现的只能靠运行结果去逆推。一次排查经历我记得很清楚一个支付创建订单的接口要校验用户状态是否被禁用、校验商户状态是否允许发起支付、校验订单参数金额是否合法。同事把用户状态校验放在了订单参数校验的后面结果出现了一个匪夷所思的场景一个被禁用的用户只要订单参数不合法收到的是“订单参数错误”一旦订单参数合法了反而收到“用户已被禁用”。用户就非常困惑“我都收到参数错误了怎么又变成我被禁用了”这就是顺序问题。排查思路很简单从链路入口开始逐个过滤器的结果debug看是哪个过滤器先拦截的。如果你有日志体系可以让每个过滤器在start和end时打出当前上下文的关键字段。没有日志的话临时在每个过滤器里打一条System.out也行定位到具体节点后再看这个过滤器的判定条件为什么成立。我习惯在过滤器里做这样的日志输出System.out.println([过滤器] 敏感词过滤 开始, content context.getContent()); // 执行逻辑 System.out.println([过滤器] 敏感词过滤 结束, allowed context.isAllowed());日志能直观地看到数据在哪一个节点被拦截再对照上下文状态去检查条件判断。6.2 排查过滤器未生效问题过滤器写了、加了但就是不执行。这类问题多半出在“过滤器没有加入到链中”。你查的时候按照这个顺序逐一排查过滤器类是否被Spring容器扫描到并注册如果没有Component注解或者扫描包路径不对过滤器根本不会出现在链上。过滤器的getOrder()返回值是否和被覆盖了优先级一样时顺序是不稳定的你挂上去的过滤器可能被排到了后面前面的拦截器已经把请求拦了。是否有别的过滤器直接短路了有些过滤器可以跳过chain.doFilter()如果它先执行且没放行你在后面的过滤器永远也不会执行。上下文对象是否被复制错了某些设计为了并行会给每个过滤器传副本主链上的上下文没被修改你看到的校验结果当然没有变化。我见过最离谱的一次是同事把新过滤器加进去了但犯了一个极其低级的错误filters.add(filter)写在了一个从未被调用的初始化方法里。代码不出错功能不生效排查了一下午才发现。所以加完过滤器一定要先跑通最简单的用例别急着上复杂场景。6.3 常见问题速查表问题现象可能原因排查/解决方案新加的过滤器不执行过滤器未注册到链中检查Component注解、包扫描范围、组装代码是否执行过滤器执行顺序不对getOrder()返回了相同的值给每个过滤器分配唯一的优先级建议用整百/整千间隔便于后续插入线程并发下数据混乱过滤器实例内部有可变成员变量把可变状态移到Context中过滤器实例保持无状态一个过滤器拦截后后面的日志没有记录后置逻辑没写在finally块里用try-finally包住chain.doFilter确保无论是否拦截都能记录过滤器抛异常导致接口响应不友好异常没有统一处理定义FilterException在链的最外层统一捕获并转换为错误响应同一请求重复执行过滤器逻辑过滤器被重复注册检查组装逻辑是否重复add或者在注册前做去重判断只改了一个过滤器影响到了别的接口过滤器是全局的对所有请求生效根据请求特征URL、方法、参数在过滤器内做条件判断跳过无关请求6.4 梳理一下我踩过的三个最有代表性的坑第一个坑在过滤器里改了Context的浅拷贝字段结果污染了其他请求。一次跑批任务要把一批文本逐个过审我图省事直接用BeanUtils.copyProperties复制了上一个Context作为下一个的开始结果上个请求的审核结果残留到了下个请求里一批数据全被误判为通过。搞了很久才定位到。后来我养成了习惯新的请求进来一定new一个全新的Context对象绝不复用旧的。第二个坑过滤器里的循环依赖。我把敏感词过滤器依赖的敏感词库服务注入进去了结果那个服务又间接依赖了过滤器链的装配类Spring启动时直接报循环依赖。排查之后发现问题的根源是我把“过滤链的装配”也搞成了一个Spring Bean并且被业务服务依赖了这属于设计不当。过滤器应该只依赖独立的领域服务过滤链的装配应该放在启动阶段一次性完成不要和业务逻辑互相引用。第三个坑并行执行过滤器时把同一个上下文传给了多个线程出现ConcurrentModificationException和结果覆盖。原因很清楚但当时就是没注意。后来我吸取教训并行过滤器操作各自的副本最后用流水号合并结果到主上下文。6.5 什么时候你应该考虑换个方案过滤器模式的确很强大但它不是银弹。说句实在话我见过不少项目把过滤器模式用过头了——整条业务链路拆了几十个过滤器每个过滤器只做了三五行事导致业务逻辑被打散到各个零散的过滤器里新人接手根本看不出来一个完整请求是如何被处理的。如果你发现自己陷入下面几种情况我建议你停下来想想是不是该换个方案——过滤器列表膨胀到了难以管理的程度。几十个过滤器组成一条超长链优先级随时在变你看一眼就头皮发麻。这时候应该把相关的过滤器合并成阶段或者用策略模式把同一类规则的多个过滤器包成一个整体。数据流在过滤器之间来回跳。过滤器A要处理的结果依赖过滤器D的输出但D排在A后面。这说明你的业务本质上不是线性的你用链式结构在硬套。这种复杂依赖用工作流引擎或者状态机来表达更合适。大量过滤器只服务于单一业务场景。过滤器模式最大的好处是复用和灵活如果某个过滤器一辈子只被一个场景使用它的解耦价值就被浪费了还不如直接在业务流程里写清楚。我的通用判断标准是过滤器数量稳定在3~10个之间、顺序相对稳定、每个过滤器职责清晰可复用那用过滤器模式再舒服不过。超过这个量级优先考虑重构链的组织方式。7. 过滤器模式在其他技术栈里的落地以及和框架内置机制的对照聊了这么多手写实现你会发现各个Web框架里其实早就有这种设计成型的东西了。理解过滤器模式再去看这些框架的中间件机制会觉得豁然开朗。Java的Servlet Filter是最标准的过滤器模式。请求到达Servlet容器后会按照你配置的映射路径顺序经过一系列Filter。Spring的HandlerInterceptor也是类似思路但粒度更细围绕Controller的调用前后设立了preHandle、postHandle、afterCompletion三个时机回调本质上还是过滤器链。Node.js里的Express/Koa中间件机制大家应该也很熟悉。Express的app.use()挂载的函数就是一个个过滤器通过next()放行。Koa更进一步把洋葱模型用到了极致中间件执行遵循“先进后出”的回程顺序。你用我之前讲的“递归调用的回程特性”去理解它就能明白那个著名的洋葱模型是怎么形成的了。Go语言里做HTTP中间件也非常顺手func(next http.Handler) http.Handler这种函数式中间件风格一长串Handler嵌在一起同样是在搭建过滤链。.NET的管道模型、Python装饰器里的某些用法底层逻辑全是同一个套路。你只要真正吃透了过滤器模式的设计思想换语言换框架只是语法层面的事核心全是“把处理拆成独立节点串成链逐级传递数据”。所以我自己在选型时有个实践心得优先使用框架自带的过滤/拦截机制自己写的过滤链只用在框架覆盖不到的业务场景里。比如你要处理一批MQ消息消息进来之后要做一堆校验再走业务逻辑MQ消费端没有现成的拦截器这时候自己写的过滤链就派上用场了。框架已有的机制能不用重复造轮子就不用重复造。我个人的体会是过滤器模式是一个学的时候觉得简单用得好的人却不多的小模式。难点从来不在怎么写过滤器而在你怎么判断该不该拆、拆几个、按什么顺序放、拆完了怎么管理它们的协作关系。这些东西靠背概念学不来全得靠实际操作中一点点积累。我建议你从小场景练起找一条你项目里写满了if-else的请求处理方法试着把校验逻辑拆成过滤器感受一下数据顺着链流动的那种清爽感然后再慢慢扩大用法。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →