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 Filter<T> { void doFilter(T context, FilterChain<T> chain); } public interface FilterChain<T> { 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. 实操:手写一个通用过滤链框架,跑一个内容审核Demo
4.1 框架代码骨架
空谈误国,实干兴邦。我现在带你从头写一个极简但完整的过滤链框架,然后用一个“文本内容审核”的例子把它跑起来。这个例子的业务场景很常见:用户发帖之前,后台要对文本依次做——敏感词过滤、垃圾广告检测、内容长度校验、审核日志记录。四条过滤器依次挂上链,任何一个不通过,帖子就发不出去。
先定义核心接口。为了方便演示我用Java写,你用别的语言也能照搬思路。
// 过滤器接口 public interface Filter<T> { void doFilter(T context, FilterChain<T> chain) throws FilterException; } // 过滤器链接口 public interface FilterChain<T> { void doFilter(T context) throws FilterException; }然后写过滤器链的标准实现。核心是一个ArrayList保存过滤器列表,用一个游标记录当前执行到第几个。每次调用doFilter,先判断游标是否越界,越界说明所有过滤器都执行完了,这时就该执行真正的业务方法了——这个“真正的业务方法”我们通过一个接口回调传进来。
public class DefaultFilterChain<T> implements FilterChain<T> { private final List<Filter<T>> filters; private final int currentIndex; private final FilterInvoker<T> finalInvoker; public DefaultFilterChain(List<Filter<T>> filters, FilterInvoker<T> finalInvoker) { this.filters = filters; this.currentIndex = 0; this.finalInvoker = finalInvoker; } private DefaultFilterChain(List<Filter<T>> filters, int currentIndex, FilterInvoker<T> 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; } Filter<T> currentFilter = filters.get(currentIndex); DefaultFilterChain<T> nextChain = new DefaultFilterChain<>(filters, currentIndex + 1, finalInvoker); currentFilter.doFilter(context, nextChain); } public interface FilterInvoker<T> { 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 Filter<AuditContext> { private final List<String> sensitiveWords; public SensitiveWordFilter(List<String> sensitiveWords) { this.sensitiveWords = sensitiveWords; } @Override public void doFilter(AuditContext context, FilterChain<AuditContext> 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 Filter<AuditContext> { private static final Pattern AD_PATTERN = Pattern.compile("加微信|点击链接|优惠领取|特惠秒杀"); @Override public void doFilter(AuditContext context, FilterChain<AuditContext> 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 Filter<AuditContext> { private final int maxLength; public LengthCheckFilter(int maxLength) { this.maxLength = maxLength; } @Override public void doFilter(AuditContext context, FilterChain<AuditContext> 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 Filter<AuditContext> { @Override public void doFilter(AuditContext context, FilterChain<AuditContext> 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 Filter<AuditContext> { @Override public void doFilter(AuditContext context, FilterChain<AuditContext> 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. 构建过滤器列表,注意顺序:日志 -> 敏感词 -> 广告 -> 长度 List<Filter<AuditContext>> filters = new ArrayList<>(); filters.add(new AuditLogFilter()); filters.add(new SensitiveWordFilter(Arrays.asList("赌博", "代开发票"))); filters.add(new AdFilter()); filters.add(new LengthCheckFilter(200)); // 3. 构建“最终执行业务”的包装器 DefaultFilterChain.FilterInvoker<AuditContext> finalInvoker = ctx -> { // 所有过滤器都通过后,执行真正的发布逻辑 System.out.println(">>> 帖子发布成功,内容摘要: " + ctx.getContent().substring(0, Math.min(ctx.getContent().length(), 10)) + "..."); }; // 4. 构建链条并执行 DefaultFilterChain<AuditContext> 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的请求处理方法,试着把校验逻辑拆成过滤器,感受一下数据顺着链流动的那种清爽感,然后再慢慢扩大用法。