资讯动态

Java Web过滤器实战:Servlet Filter机制、FilterChain与常见问题排查

发布时间:2026/9/19 2:46:37 来源:尧图企业网站定制
做Java Web开发的人大概率都经历过这样一个阶段项目里每个Servlet都在重复同样的代码——设置编码、校验登录状态、打印访问日志甚至往请求里塞一些公共参数。我当时就是这么干的CtrlC、CtrlV用得飞起直到有一天项目里出现了一个需求给所有接口加一个统一的耗时统计我改完一个又一个Servlet改到第三十几个时突然意识到肯定有更好的做法。这时候才第一次认真研究Servlet里的过滤器Filter。过滤器这东西一句话概括就是在Servlet被调用之前拦一道在Servlet处理完之后再拦一道。它把那些和业务无关、但每个请求都必须做的横切逻辑从Servlet里抽出去集中在一个地方统一处理。日志、鉴权、字符编码、接口耗时统计、参数校验、XSS过滤都是Filter的典型应用场景。适合正在学Servlet的Java入门者也适合项目里请求处理逻辑混乱、想重构的老手参考。这篇文章不讲虚的直接从一个最简单的日志过滤器开始一步步写到多个过滤器串联、常见坑的排错最后再用一个真实排查案例收尾。你照着敲一遍基本就能把过滤器这块吃透。1. 过滤器到底解决了Servlet开发中的什么问题1.1 从一段复制粘贴的Servlet代码说起我一开始做项目时代码长这样WebServlet(/api/user/info) public class UserInfoServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 每个Servlet都要先设置编码 req.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); // 每个Servlet都要检查登录状态 if (req.getSession().getAttribute(user) null) { resp.setStatus(401); return; } // 每个Servlet都要打印日志 System.out.println(请求来了: req.getRequestURI()); // 终于到业务代码了 // ... } }这种写法最要命的地方在于这些横切逻辑和业务逻辑完全耦合在一起。今天要加一个接口就得把这几段代码再复制一遍明天要改鉴权逻辑又得把所有Servlet翻出来逐个改。一旦漏改一个线上就会出现为什么这个接口不用登录就能访问的诡异问题。过滤器的工作方式就是把这些重复代码从Servlet里拿走。它运行在Servlet之前和之后由容器负责调用业务Servlet里只剩纯业务逻辑。这个设计思路在今天看很自然但它其实是Servlet规范里非常经典的一个横切关注点解决方案比Spring AOP出现得早得多。1.2 别把Servlet过滤器和布隆过滤器混为一谈网上搜过滤器会出现一堆概念尤其是最近布隆过滤器Redis布隆过滤器这些词热度很高。这里必须澄清一下Servlet Filter和布隆过滤器Bloom Filter是两个完全不同的东西只是中文翻译撞了名字。布隆过滤器是一种数据结构用位数组加上多个哈希函数来判断元素一定不存在或可能存在主要用在解决缓存穿透、垃圾邮件过滤这类场景。它跟Java Web的Filter机制没有任何关系。如果你在学Servlet的时候搜到布隆过滤器的教程别怀疑自己直接关掉就好。另一个容易混淆的是Spring MVC里的HandlerInterceptor拦截器。很多初学者会问Filter和Interceptor都能拦请求有什么区别用一句话说**Filter是Servlet容器层的机制Interceptor是Spring MVC框架层的机制。**请求先经过Filter再进入DispatcherServlet然后才走到Interceptor。Filter能处理所有资源包括静态资源Interceptor只能处理进入Spring MVC的请求。两者能做的事有交集但Filter更底层、更通用。过滤器的适用场景列一下真实项目中我用到过的请求日志和耗时统计记录每个请求的URL、参数、耗时用于排查线上问题。登录鉴权未登录请求统一重定向到登录页或返回401。字符编码设置统一把请求和响应的编码设置为UTF-8搞定中文乱码。接口幂等性校验防止重复提交。敏感信息脱敏对响应中的手机号、身份证号做掩码处理。接口限流按IP或用户维度做简单限流。这些场景的共同特点是不只是某一个Servlet需要而是所有或大部分请求都需要。只要符合这个特征就值得用过滤器来实现。2. 一个请求日志过滤器从代码到配置的完整实现2.1 实现javax.servlet.Filter接口的三个方法先从一个最实用的场景入手——给项目加一个请求日志过滤器记录下来每个请求的路径、参数、处理耗时。这个过滤器能在请求进入Servlet之前和之后各执行一段逻辑最能直观体现Filter的能力。import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; public class LoggingFilter implements Filter { private boolean debugEnabled; Override public void init(FilterConfig filterConfig) throws ServletException { String debug filterConfig.getInitParameter(debug); this.debugEnabled true.equalsIgnoreCase(debug); System.out.println(LoggingFilter 初始化完成, debug debugEnabled); } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; long startTime System.currentTimeMillis(); System.out.println(请求前: httpRequest.getMethod() httpRequest.getRequestURI()); try { chain.doFilter(request, response); } finally { long cost System.currentTimeMillis() - startTime; System.out.println(请求后: httpRequest.getRequestURI() 耗时 cost ms); } } Override public void destroy() { System.out.println(LoggingFilter 销毁); } }三个方法的职责init(FilterConfig)容器创建Filter实例后调用只执行一次。FilterConfig可以读取web.xml里配置的初始化参数比如上面的debug开关。doFilter(...)每次请求都会经过这里。在调用chain.doFilter(request, response)之前的代码是请求前逻辑之后的代码是响应后逻辑。destroy()容器销毁Filter时调用只在应用卸载或容器关闭时执行一次用来释放资源。这里有一个特别值得注意的细节为什么doFilter里要把耗时统计放在try-finally而不是直接写在chain.doFilter下一行因为如果Servlet内部抛了异常chain.doFilter这行代码后面的语句根本不会执行直接写的话耗时统计就丢了。用finally保证无论Servlet是否正常返回耗时都能被记录。这个习惯在写任何过滤器时都通用。另外一个容易踩的版本坑如果你用的是Tomcat 10以上import javax.servlet.*要改成import jakarta.servlet.*。因为Tomcat 10开始Servlet规范从Java EE移交到Jakarta EE包名整体更换了。网上很多老教程还在用javax的包名新手拿着新环境照抄编译直接报错还以为是自己代码写错了。2.2 两种注册方式web.xml与WebFilter注解Filter写完之后需要注册才能生效。注册方式有几种早期的标准做法是在web.xml里配置filter filter-nameloggingFilter/filter-name filter-classcom.example.filter.LoggingFilter/filter-class init-param param-namedebug/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameloggingFilter/filter-name url-pattern/*/url-pattern /filter-mappingServlet 3.0之后可以使用WebFilter注解代码更简洁不用去维护XML文件import javax.servlet.annotation.WebFilter; import javax.servlet.annotation.WebInitParam; WebFilter( urlPatterns /*, initParams WebInitParam(name debug, value true) ) public class LoggingFilter implements Filter { // ... }两种方式的选择标准我个人的建议是**如果你只用注解代码量最少只要牵扯到多个过滤器的执行顺序必须用web.xml或后面会讲的Spring Boot的FilterRegistrationBean。**原因在后面顺序部分细说。另外注解方式要求Servlet容器版本支持Servlet 3.0现在主流的Tomcat 8.5、9、10都没问题如果是老项目用Tomcat 6那还是老老实实写web.xml。2.3 过滤器执行顺序和URL匹配的那些细节多个过滤器同时存在时执行顺序是有讲究的。web.xml里的规则很明确**按照filter-mapping的书写顺序执行从上到下。**比如下面这个配置filter-mapping filter-namecharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping filter-mapping filter-nameauthFilter/filter-name url-pattern/*/url-pattern /filter-mapping请求肯定会先经过characterEncodingFilter再经过authFilter。这种顺序在实际开发中很重要编码过滤器必须放在最前面否则后面过滤器里读取请求参数时中文早就乱了登录过滤器和日志过滤器的先后顺序也要想清楚日志过滤器放在最外层才能记录到所有请求包括被登录拦截器拦掉的请求。url-pattern的匹配规则也是新手重灾区。常用的写法有几种/*匹配所有请求注意/这个写法只匹配应用根路径不匹配其他路径/api/*匹配所有以/api/开头的路径*.do匹配以.do结尾的请求。一个常见误区是写/*.do这种既要路径前缀又要扩展名的别扭写法这在Servlet规范里根本就不是合法的映射格式。实际项目中我见过有人写了这种配置容器部署时没报错但过滤器就是不生效排查了半天才发现是映射格式的问题。3. FilterChain多个过滤器是如何串成一条链的3.1 用食堂打饭窗口来理解责任链FilterChain这个词翻译过来是过滤器链它是整个过滤器机制的核心。可以打个比方你去食堂打饭先要到窗口A刷卡验证有没有钱再到窗口B取餐核心服务最后到门口领一包纸巾附加处理。每个窗口只处理自己负责的事处理完就传递到下一个环节。这个流程就是一条责任链。代码层面FilterChain就是负责传递的那个对象。每个Filter里的chain.doFilter(request, response)这行代码意思是本过滤器处理完了把请求和响应交给链上下一个节点。下一个节点可能是另一个Filter也可能直接就是Servlet如果这是最后一个过滤器的话。如果某个Filter不想传递了可以不调用chain.doFilter请求就在这里被拦截住了直接向客户端返回一个响应。典型的例子就是登录校验过滤器未登录时直接resp.sendRedirect(login.html)不再往下传Servlet根本不会被执行。这是过滤器能实现鉴权的基本原理。3.2 请求与响应方向的不对称责任链模式有个特别有意思的特性请求的处理顺序和响应的处理顺序是反的。假设有三个过滤器F1、F2、F3它们依次注册。请求到达的顺序是客户端 - F1 - F2 - F3 - Servlet而响应返回的顺序是Servlet - F3 - F2 - F1 - 客户端也就是说F1虽然最先拿到请求但它要等F2、F3、Servlet全部处理完之后才能拿到最终响应。这个机制在Web开发里用处很大比如一个跨域过滤器它在请求阶段允许跨域访问在响应阶段给HttpServletResponse添加跨域头两个阶段配合才能完整实现。如果你在多个过滤器的请求前逻辑和响应后逻辑里分别打印日志你会发现一个有趣的现象请求日志按F1、F2、F3的顺序打印响应日志却是按F3、F2、F1的顺序打印。如果F1里要统计整个链路的总耗时把统计代码放在chain.doFilter之后就能把F2、F3、Servlet的耗时全部覆盖到如果放在chain.doFilter之前统计的就没有意义。3.3 init与destroy的执行时机和线程安全有过动手实践的话会发现Filter的init方法只在应用启动时被调用一次而不是每个请求都调用。这一点和Servlet类似——Filter在Servlet容器中也是单例多线程的只有一个实例但多个请求可能同时执行同一个Filter的doFilter方法。这个特性引出一个线程安全问题**不要在Filter里用普通的成员变量保存请求相关的数据。**比如public class AuthFilter implements Filter { private String currentUser; // 错误用法 public void doFilter(...) { this.currentUser httpRequest.getHeader(X-User-Name); // 多个线程同时进来这里相互覆盖 } }如果两个请求同时进来请求A设置完currentUser还没用完就被请求B覆盖了A后面读取到的是B的用户名。要避免这个问题要么用局部变量要么把数据塞进request的attribute里request.setAttribute(user, user)后者在业务Servlet中也能直接取出来用。init和destroy的使用场景也值得说说。init适合做一次性初始化比如读取配置参数、初始化资源池、加载静态数据destroy适合做资源释放比如关闭数据库连接池。我见过有人把数据库连接池放在了doFilter里每次都新建一个连接结果高并发下直接把数据库连接打满了这种问题其实在init里初始化连接池就能解决。4. 实测中反复踩过的过滤器坑4.1 用注解注册过滤器顺序经常失灵前面提到过注解方式注册过滤器时顺序是没有规范保证的。我实际测试过Tomcat 7时代注解过滤器会按照类名的字母顺序执行但到了Tomcat 9、10行为又变了。这就导致一个很致命的问题你有一个CharacterEncodingFilter和一个AuthFilter都用WebFilter注册代码里看着先注册的是编码过滤器但实际执行时AuthFilter可能先跑此时请求参数还没设置UTF-8编码拿到手就是乱码。解决这个问题有几个思路需要严格控制顺序的过滤器统一用web.xml配置。如果是Spring Boot项目用FilterRegistrationBean手动注册通过setOrder指定顺序数字越小越先执行。import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class FilterConfig { Bean public FilterRegistrationBeanCharacterEncodingFilter encodingFilter() { FilterRegistrationBeanCharacterEncodingFilter registration new FilterRegistrationBean(); registration.setFilter(new CharacterEncodingFilter()); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } Bean public FilterRegistrationBeanAuthFilter authFilter() { FilterRegistrationBeanAuthFilter registration new FilterRegistrationBean(); registration.setFilter(new AuthFilter()); registration.addUrlPatterns(/*); registration.setOrder(2); return registration; } }注意Spring Boot里必须加Configuration让这个配置类生效否则过滤器不注册。这个方法在我项目里用了很多次比web.xml灵活也不存在注解顺序不确定的问题。4.2 在过滤器中读取请求体后Servlet里拿到空数据这是一个非常隐蔽的坑。如果你想用过滤器统一记录接口的请求参数尤其是JSON格式的POST请求直接在doFilter里写request.getInputStream()去读读完后把数据打印在日志里然后调用chain.doFilter——你会发现Servlet里的request.getInputStream()读到的内容是空的或者request.getParameter()拿到的所有参数都变成了null。原因很简单**HttpServletRequest的输入流内部是一个单向流只能读一次。**你在Filter里读完一遍流的指针已经到末尾了Servlet再想读就读不到任何数据了。这有点像你把一本书从头翻到尾再想随便翻开某一页发现书页已经被粘住了翻不开。解决办法也不复杂用HttpServletRequestWrapper包装原始请求在包装类构造时把请求体完整读出来缓存到字节数组里然后重写getInputStream()和getReader()让业务代码每次读取时都从这个缓存数组里重新读import javax.servlet.ReadListener; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedBody; public CachedBodyRequestWrapper(HttpServletRequest request) throws IOException { super(request); InputStream requestStream request.getInputStream(); this.cachedBody readBytes(requestStream); } private byte[] readBytes(InputStream inputStream) throws IOException { ByteArrayOutputStream buffer new ByteArrayOutputStream(); byte[] data new byte[4096]; int bytesRead; while ((bytesRead inputStream.read(data)) ! -1) { buffer.write(data, 0, bytesRead); } return buffer.toByteArray(); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) { } Override public int read() { return byteArrayInputStream.read(); } }; } }然后在Filter里这样用public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { CachedBodyRequestWrapper wrappedRequest new CachedBodyRequestWrapper((HttpServletRequest) req); // 这里可以从 wrappedRequest 里读请求体 byte[] body wrappedRequest.getInputStream().readAllBytes(); System.out.println(请求体: new String(body, StandardCharsets.UTF_8)); // 传下去的一定要是包装后的request chain.doFilter(wrappedRequest, resp); }核心思路就一句话**读取后的数据不要丢缓存起来后续每次读取都从缓存里给。**注意传给chain的是包装后的request不是原始request否则Servlet拿到的还是那个已经读完的流。4.3 过滤器里的异常处理边界Filter的doFilter方法声明抛出了IOException和ServletException这个方法本身允许把异常继续往外抛但直接抛给容器通常不是好做法。因为容器收到异常后会走它自己的错误处理流程返回给客户端的是默认的错误页——可能是Tomcat的一整页HTML错误信息而不是你的接口约定好的JSON错误结构。我当时排查过一个问题某个接口用POST请求访问时Servlet里针对参数校验抛了一个自定义业务异常但客户端收到的响应是Tomcat默认的404错误页接口的约定返回格式完全没生效。原因就在于这个请求经过了过滤器过滤器什么都没做异常一路抛给容器处理了。正确的处理方式是在过滤器里做集中异常捕获统一返回API约定格式try { chain.doFilter(request, response); } catch (BusinessException e) { response.setStatus(200); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: e.getCode() ,\msg\:\ e.getMessage() \}); } catch (Exception e) { response.setStatus(500); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:500,\msg\:\服务器内部错误\}); }但这里有个前提要注意如果异常抛出之前Servlet已经向response里写了一些内容比如写了一半的HTML片段那么此时你用response.getWriter()再写JSON会出现响应内容混杂的问题。这种情况最好是在写入前调用response.reset()清空缓冲区前提是还没有调用response.flushBuffer()或getWriter().close()否则会抛异常。这属于比较深的边界场景实际开发中遇到再针对处理。4.4 静态资源也被过滤器拦死如果过滤器的url-pattern配置成/*意味着所有请求都会经过它包括CSS、JS、图片这些静态资源。如果是登录校验过滤器就会产生一个让人抓狂的循环用户访问登录页login.html过滤器发现未登录重定向到login.html结果login.html这个请求也被过滤器拦下来又一次重定向——无限循环。解决方式一般是两种一种是把过滤器的映射范围缩小比如只对/api/*或/pages/*这些动态接口做拦截静态资源路径不在范围内自然就不受影响另一种是在过滤器内部判断遇到静态资源路径直接放行public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; String path req.getRequestURI(); if (path.endsWith(.css) || path.endsWith(.js) || path.endsWith(.png)) { chain.doFilter(request, response); return; } // 以下是登录校验逻辑... }个人建议优先用第一种方式url-pattern能精确控制范围就不要在代码里做二次判断逻辑更清晰。只在接口路径和静态资源路径混在一起、不好通过路径区分时才用第二种。5. 一次登录校验过滤器失灵的排查链路5.1 现象该拦的请求没拦住前些时候组里有个同事遇到了一个头疼的问题项目里写了一个登录校验过滤器AuthFilterweb.xml里配置了url-pattern/*/url-pattern看起来所有请求都应该先经过登录校验。但实测下来有一个接口/api/user/info完全没有登录就返回了用户数据。按理说这个接口应该被拦截然后返回401让用户重新登录才对。这个问题的排查过程很有代表性我把完整链路复现一下大家下次遇到过滤器失效时可以照着排查。5.2 排查链路先确认过滤器到底进没进来第一步确认过滤器有没有被加载。同事检查了启动日志能看到AuthFilter的init方法打印的过滤器初始化完成日志说明过滤器类是正常加载的。第二步在doFilter方法第一行加一条日志打印当前请求的URL。重启后访问/api/user/info结果发现——这条日志根本没打印。这就很奇怪了既然过滤器加载成功了url-pattern又是/*理论上任何请求都会经过它为什么这个接口的请求没进来第三步开始怀疑请求路径映射。仔细检查web.xml里filter-mapping的配置发现里面写的是filter-mapping filter-nameauthFilter/filter-name url-pattern/*/url-pattern /filter-mapping配置本身没有错。于是进一步确认项目结构和访问路径请求地址是http://localhost:8080/api/user/info应用上下文是/api。问题出现了——**在带上下文路径的Web应用中如果context path和url-pattern有重合或者配置不当过滤器的匹配范围会被上下文路径影响。**但这个项目的问题还不在这继续排查。第四步查看项目是怎么部署的。发现这个项目是一个Spring Boot项目但同时又保留了web.xml而且项目里还有一个用WebFilter注解配置的过滤器和一个用FilterRegistrationBean注册的过滤器两者都命名为authFilter。在Spring Boot环境中WebFilter注解默认不会被自动扫描除非显式添加ServletComponentScan而FilterRegistrationBean那个实例的注册URL是/api/auth/*根本没有包含/api/user/info。这导致真正生效的过滤器是FilterRegistrationBean注册的那个它只拦截了/api/auth/*这个范围其他路径全部放行。5.3 修复方案与复盘定位到问题后修复就简单了。把AuthFilter的注册统一放到FilterRegistrationBean里明确配置拦截/*并设置order移除无用的WebFilter注解Bean public FilterRegistrationBeanAuthFilter authFilterRegistration(AuthFilter authFilter) { FilterRegistrationBeanAuthFilter registration new FilterRegistrationBean(); registration.setFilter(authFilter); registration.addUrlPatterns(/*); registration.setOrder(50); return registration; }重启后再次访问/api/user/info可以看到过滤器日志打印了未登录请求被正常拦截返回401。修复完成。这个案例给我留下的教训是**排查过滤器失效第一步永远是确认请求到底进没进过滤器通过加日志或者断点的方式而不是直接去改过滤逻辑。**很多时候问题不是出在业务代码上而是过滤器根本没有被注册、路径没匹配上、或者被其他同名过滤器覆盖了。另外Spring Boot项目中尽量不要同时混用web.xml、WebFilter和FilterRegistrationBean三种注册方式统一用一种能省掉大量隐性Bug。过滤器这个东西单独看每个知识点都不难难的是把它放到真实的请求链路里想明白什么时候会经过它、什么时候不会、多个过滤器之间怎么协作、请求体读了一次怎么办。把这些边界条件都摸清楚过滤器才会真正成为你工具箱里顺手的那件工具而不是一上线就出问题的定时炸弹。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价