ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Servlet过滤器深入解析:FilterChain原理与Spring Boot实践

Servlet过滤器深入解析:FilterChain原理与Spring Boot实践

写这篇文章的起因挺简单:最近帮朋友排查一个老项目的线上问题,现象是某些请求莫名被"吃掉"了响应头,查来查去最后发现是过滤器里多写了一行response.getWriter()导致的。说实话,SpringBoot用久了之后,很多人对Servlet这套基础组件已经有点生疏了,但过滤器这东西,恰恰是理解Java Web请求处理链路的关键一环。这篇文章就当是一次技术复盘,把过滤器从原理到实践完整地过一遍。

Servlet过滤器在Java Web体系里的位置非常特殊。它不像Servlet那样直接处理请求,也不像Listener那样只监听事件,它是夹在"请求到达Servlet之前"和"Servlet响应返回之后"这一段链路里的守门员。理解了它的定位,很多框架层面的设计(比如SpringMVC的DispatcherServlet为何能拿到干净的请求参数、权限框架为何能在Controller执行前拦截)就都能串起来了。

作为一个"重温"性质的话题,这篇文章不会只贴一段doFilter代码就完事,我会从过滤器的生命周期、FilterChain的调用机制、配置细节、常见业务场景到容易踩的坑,一层层拆开讲。对于刚接触Servlet没多久的读者,看完应该能建立起完整的过滤器知识框架;对于写过一段时间的老手,这些细节或许能帮你解决一些"用的时候没问题、出了问题却想不起来"的疑难杂症。

1.1 过滤器到底在"链"的哪个位置

要理解过滤器,最直观的方式是把它放到一次完整的HTTP请求处理链路里看。一次请求从客户端发出,经过网络到达Web容器(比如Tomcat),容器根据URL映射找到对应的Servlet之前,会先经过一系列过滤器的处理。这些过滤器在Web容器里被组织成一条"过滤器链"(FilterChain),顺序执行。

这里要特别强调一个容易混淆的点:过滤器的执行时机并不是"在Servlet之前"和"在Servlet之后"这么简单的一句话。准确地说,doFilter方法里的代码,在chain.doFilter(request, response)调用之前的部分,是在Servlet处理之前执行的;而chain.doFilter()调用之后的部分(即方法Return之后继续执行的代码),是在Servlet处理完成之后执行的。这是一种"环绕式"的执行模型,很多框架里的拦截器、中间件本质上都是这种模式。

打个比方,过滤器链就像机场安检通道。乘客(请求)进入安检通道后,要先过第一道安检(第一个过滤器),然后第二道(第二个过滤器),全部通过后才登机(Servlet处理业务)。而出机场的时候,虽然不用再过安检了,但在某些特殊通道里,你可以把行李(响应)放到传送带上做一次检查。这就是过滤器"前后环绕"的完整含义。

1.2 一个最小可用的过滤器长什么样

先写一个最基础的过滤器,建立一个完整的感知。它做什么不重要,关键是把init、doFilter、destroy这三个生命周期方法的作用看明白:

package com.example.demo.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import java.io.IOException; @WebFilter(urlPatterns = "/*") public class FirstFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { // 过滤器创建后、开始处理请求前,容器会调用这个方法 // 这里通常用来加载配置项、初始化资源 // FilterConfig 可以拿到当前过滤器在 web.xml 中的初始化参数 System.out.println("FirstFilter init, name = " + filterConfig.getFilterName()); } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { // 前置逻辑:在 Servlet 处理之前执行 System.out.println("FirstFilter 开始执行:请求进入过滤器链"); // 调用 filterChain.doFilter 将请求传递给过滤器链中的下一个过滤器 // 如果当前过滤器是链上最后一个,则将请求交给目标 Servlet filterChain.doFilter(servletRequest, servletResponse); // 后置逻辑:在 Servlet 处理完成之后执行 System.out.println("FirstFilter 执行结束:响应已经返回"); } @Override public void destroy() { // 容器卸载过滤器时调用,比如应用停止时 // 这里用来释放资源 System.out.println("FirstFilter destroy"); } }

有几点要马上说明。

doFilter方法中的servletRequest和servletResponse是ServletRequest/ServletResponse类型,也就是Servlet规范定义的通用请求和响应对象。在实际开发中,我们几乎总是使用它们的子接口HttpServletRequest和HttpServletResponse来拿到HTTP相关的能力(比如请求头、Cookie、请求URI等),所以通常会做一个强转:

HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse;

FilterChain对象则相当关键。它维护了当前过滤器链中的"下一个环节"。你可以把它想象成一个链表节点,每个过滤器持有指向下一个节点的引用。调用chain.doFilter(),就是把控制权交出去,让请求继续往下走。如果某个过滤器忘记了调用这个doFilter(),那么请求就会卡在当前过滤器,后续的过滤器、目标Servlet都不会执行,也不会给客户端返回任何响应。这是一个非常经典的"过滤器写了一半发现接口根本不通"的排查方向。

1.3 从init到destroy:过滤器的完整生命周期

过滤器的生命周期,与Servlet的生命周期思路相似,但有细节差异。

  • 实例化与初始化阶段:Web容器启动时(或者首次需要该过滤器处理请求时,取决于容器的懒加载实现),创建过滤器实例,然后调用init(FilterConfig)方法。这个方法只调用一次。FilterConfig对象可以拿到过滤器的注册名称和初始化参数(通过web.xml中的init-param配置,或者注解中的initParams属性配置)。

  • 运行阶段:每当有匹配该过滤器urlPatterns的请求进入时,容器就会调用该过滤器的doFilter方法。注意,同一个过滤器实例会被多个线程并发调用。也就是说,过滤器是单实例多线程的,这要求我们在编写doFilter时,不能在里面使用有状态的非线程安全成员变量。如果确实需要往过滤器里传数据,优先考虑放入request的attribute中,或者使用ThreadLocal但要非常谨慎地处理清空逻辑。

  • 销毁阶段:当应用停止或者容器决定卸载该过滤器时,会调用destroy()方法。destroy()同样只调用一次,用来释放init()中创建的资源(比如数据库连接池、线程池等)。这一点很多文档没有特别强调,不少人在init里开了线程或者缓存,却忘记在destroy里关闭,应用热部署几次后直接把内存耗尽。

这里有一个容易忽视的冷知识:Servlet/JSP规范中的过滤器,每种类型在整个容器中只存在一个实例(每个Filter注册项对应一个实例),而不是每个请求都实例化一个新的过滤器。这与经典的"Servlet单实例多线程"模型一致,与Spring中默认的单例Bean也类似。所以那种"在过滤器里定义一个成员变量private int count = 0并在doFilter里自增"的做法,在多线程环境下会发生并发安全问题,这在压测时是必现的。过滤器中若要存储状态,最安全的地方是请求作用域(request scope)或使用线程绑定但确保最后清理的机制。

2.1 注解配置与web.xml配置的取舍

配置过滤器,传统方式是web.xml,新一点的方式是@WebFilter注解。这两者的核心区别不只是"写起来简洁不简洁",而是直接影响过滤器链的执行顺序。

先看web.xml方式:

<filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.demo.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>authFilter</filter-name> <filter-class>com.example.demo.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/api/*</url-pattern> </filter-mapping>

这种配置的可读性很好,一个filter标签定义过滤器,一个filter-mapping标签定义它拦截哪些URL。同一URL匹配到多个过滤器时,执行顺序按照filter-mapping在web.xml中出现的先后顺序决定。

再看注解方式:

@WebFilter( filterName = "authFilter", urlPatterns = "/api/*", initParams = { @WebInitParam(name = "loginUrl", value = "/login"), @WebInitParam(name = "excludeUrl", value = "/api/login") } ) public class AuthFilter implements Filter { // ... }

注解方式虽然简洁,但有个天然的短板:多个过滤器之间的执行顺序无法在注解层面精确控制。Servlet规范对多个@WebFilter的执行顺序没有要求,容器实现可以自由决定。在实际使用中,如果在同一个应用中同时注册了多个注解过滤器,且它们之间存在依赖顺序(比如编码过滤器必须最先执行、认证过滤器要依赖编码过滤器处理过的参数),你就会发现行为不可预测——有时候对,有时候不对,换一个容器版本可能又不一样。

基于这一点,我的建议是:

  • 如果过滤器数量少(1~2个),且逻辑上彼此独立,用注解完全没问题。
  • 如果过滤器存在明确的先后依赖(如编码→安全→日志),用web.xml显式声明顺序,或者用Spring Boot中的FilterRegistrationBean来精确控制order。

在Spring Boot场景下,FilterRegistrationBean是更推荐的方式。它既能注册普通的Filter类,又能通过setOrder()指定顺序,还能设置setUrlPatterns()指定要拦截的URL。后面在实践章节,我会专门演示这种用法。

2.2 URL匹配规则全面梳理

过滤器的URL匹配规则,是必须精通的细节之一。搞不清楚/*和/的区别,写出来的过滤器拦截范围就不是你想要的。

在Servlet规范中,过滤器与Servlet使用相同的URL匹配规则,主要分三类:

精确匹配:/user/detail,只拦截这个具体路径。

路径前缀匹配:/api/*,拦截/api下的所有子路径,但不拦截/api本身(严格来说,/api/*匹配/api/xxx,不匹配/api)。这是一个非常容易踩的细节。要都拦下来,可以再用一个精确匹配/api补充。

扩展名匹配:*.do、*.jsp、*.action,匹配以指定扩展名结尾的URL。

默认匹配:/,这个要单独拿出来说。/在Servlet匹配规则中的含义是"默认Servlet",它是整个Web应用的兜底匹配,专门用来处理那些没有任何其他Servlet精确匹配或路径匹配的请求。如果用/*,则是匹配所有路径(除了一些服务器保留路径),包括那些已经有Servlet匹配的请求。

举个例子,在一个同时配置了/api/*过滤器A、*.do过滤器B、/*过滤器C的应用里,请求/api/user/add.do会同时命中过滤器A、B和C,且它们的执行顺序取决于filter-mapping的声明顺序(或者注解的不可控顺序)。别觉得这个例子极端,当系统规模变大、过滤器越加越多时,这种"多个匹配规则同时生效"的情况非常常见,理解匹配的叠加关系很关键。

首页访问场景也许更贴近日常。当你在浏览器中访问http://localhost:8080/时,实际请求的URI多数是/或/index.jsp(取决于默认欢迎页配置)。如果你把登录校验过滤器配置在/*上,在部分容器里连首页都会被拦截;如果你配置的是/,则只拦截了默认Servlet匹配的场景,/index.jsp这种带扩展名的路径就漏过去了。所以,面向全站的过滤器(比如编码过滤器、日志过滤器)用/*,面向特定模块的过滤器用精确路径或路径前缀。

DispatcherType也是一个过滤器的匹配维度。Servlet 3.0开始,过滤器可以通过dispatcherTypes属性指定它拦截哪种类型的请求分发方式,常见的有:

  • REQUEST:普通请求,默认值。
  • FORWARD:通过RequestDispatcher.forward()转发的请求。
  • INCLUDE:通过RequestDispatcher.include()包含的请求。
  • ERROR:通过错误处理机制跳转到错误页的请求。
  • ASYNC:异步请求分发。

默认情况下,过滤器只会拦截REQUEST类型。这就导致了一个经典问题:如果某个Servlet在处理中forward到另一个Servlet,而你希望过滤器对"转发后的目标Servlet"也生效,就需要把该类过滤器的dispatcherTypes设为FORWARD,或者REQUEST, FORWARD。

在web.xml里这样配置:

<filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/admin/*</url-pattern> <dispatcher>REQUEST</dispatcher> <dispatcher>FORWARD</dispatcher> </filter-mapping>

这个细节平时写代码几乎遇不到,但一旦遇到"某些页面能拦截,转发后就失效"这类诡异问题,基本上都是DispatcherType没覆盖到。

3.1 最容易被忽略的链条顺序问题

说到过滤器链,不能不提一个老生常谈但又反复有人问的问题:执行顺序是先进先出(请求方向)与后进先出(响应方向)的叠加。

假设有两个过滤器:FilterA(配置顺序在前)和FilterB(配置顺序在后),目标Servlet叫UserServlet。一次请求的完整执行顺序是:

  1. 客户端发出请求。
  2. 容器将请求交给FilterA的doFilter方法。
  3. FilterA执行前置逻辑(在chain.doFilter()之前)。
  4. FilterA调用chain.doFilter(),容器将请求传给FilterB。
  5. FilterB执行前置逻辑。
  6. FilterB调用chain.doFilter(),容器将请求传给UserServlet。
  7. UserServlet执行业务逻辑,产生响应。
  8. 控制权返回给FilterB,执行FilterB的后置逻辑(在chain.doFilter()之后)。
  9. 控制权返回给FilterA,执行FilterA的后置逻辑。
  10. 容器将最终的响应写回客户端。

这里有个非常反直觉的点:响应方向的执行顺序,与请求方向的执行顺序恰好相反。也就是说,FilterA虽然最先处理请求,但它的"后置逻辑"是最后执行的。如果你把"记录响应耗时"的逻辑放在chain.doFilter()之后,那么时间轴上最早进入、最晚离开的FilterA,它计算出来的总耗时是最精确的(因为它包住了整个调用链)。

而在实际生产系统中,这种顺序对"响应内容的操作"有重要影响。比如你想给所有响应添加一个自定义Header,放在FilterA的后置逻辑里做,因为FilterA是链条的第一个,它的后置逻辑是在所有后续过滤器、Servlet都处理完之后才执行的,此时拿到的HttpServletResponse才是真正要写回客户端的那个。如果你放到FilterB的后置逻辑,FilterA后续可能还要修改响应(比如压缩),顺序不对就会相互覆盖。

3.2 不同配置方式下的顺序控制

顺序控制在原生Servlet时代靠web.xml中filter-mapping的先后位置;在Spring Boot时代,最灵活的是FilterRegistrationBean。

直接看一个实际例子。假设项目中有三个过滤器:字符编码过滤器(EncodingFilter)、登录校验过滤器(AuthFilter)、性能日志过滤器(LogFilter)。我们希望执行顺序是:Encoding → Log → Auth。

package com.example.demo.config; import com.example.demo.filter.AuthFilter; import com.example.demo.filter.EncodingFilter; import com.example.demo.filter.LogFilter; 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 FilterRegistrationBean<EncodingFilter> encodingFilterRegistration(EncodingFilter encodingFilter) { FilterRegistrationBean<EncodingFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(encodingFilter); registration.addUrlPatterns("/*"); registration.setOrder(1); // order 越小,越先执行 return registration; } @Bean public FilterRegistrationBean<LogFilter> logFilterRegistration(LogFilter logFilter) { FilterRegistrationBean<LogFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(logFilter); registration.addUrlPatterns("/*"); registration.setOrder(2); return registration; } @Bean public FilterRegistrationBean<AuthFilter> authFilterRegistration(AuthFilter authFilter) { FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(authFilter); registration.addUrlPatterns("/api/*"); registration.setOrder(3); return registration; } }

order的值越小,执行优先级越高。不过要注意,这个order是Spring容器层面注册Bean的顺序,实际上影响的是过滤器链中各Filter的排序,而真正由Servlet容器建立的过滤器链,会按照这个排序结果来确定每个Filter在FilterChain中的位置。

这里有一个经验:如果项目既存在@WebFilter注解的过滤器,又通过FilterRegistrationBean注册了过滤器,那么注解方式的过滤器默认会被Spring Boot自动注册,其顺序往往在FilterRegistrationBean的最后(order值为最低优先级)。这会导致你预期的"编码过滤器最先执行"失效。更稳妥的做法是:为一个应用里的过滤器统一选择一种注册方式,不要混用。如果必须混用,可以在配置类里通过@ServletComponentScan配合排除不需要的自动注册类,或者将注解过滤器关闭(比如spring.boot.servlet.filter-registration-bean.enabled等参数),这在排障时可节省大量时间。

实际上我在项目里更倾向于所有过滤器都使用FilterRegistrationBean注册。理由是:顺序明确、URL模式明确、初始化参数明确,而且可以统一管理,代码可读性远超零散的@WebFilter。唯一的麻烦是每个过滤器都要写一个@Bean方法,略显繁琐,但换来的是项目后期的可维护性。

4.1 编码过滤器:没有它,中文乱码找你一晚上

编码过滤器可以说是Java Web项目里最常见的过滤器。它的职责很简单:在请求和响应进入业务代码之前,统一设置字符编码,避免中文乱码。

一个完整的编码过滤器需要考虑的点:

  1. 字符编码设置不能覆盖已有编码:如果业务代码或已有过滤器已经为请求设置了编码(比如request.setCharacterEncoding("UTF-8")),编码过滤器不应该盲目覆盖。
  2. 请求编码和响应编码分开设置:请求编码由request.setCharacterEncoding控制;响应编码可以通过response.setCharacterEncoding和response.setContentType设置。
  3. 在chain.doFilter前设置:必须在前置逻辑里设置,一旦Servlet开始获取参数,再设置就晚了。

一个典型的实现:

package com.example.demo.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class EncodingFilter implements Filter { private String encoding = "UTF-8"; @Override public void init(FilterConfig filterConfig) throws ServletException { String configEncoding = filterConfig.getInitParameter("encoding"); if (configEncoding != null && !configEncoding.trim().isEmpty()) { this.encoding = configEncoding; } } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 检查是否已有编码,避免覆盖 if (httpRequest.getCharacterEncoding() == null) { httpRequest.setCharacterEncoding(encoding); } httpResponse.setCharacterEncoding(encoding); httpResponse.setContentType("text/html;charset=" + encoding); chain.doFilter(httpRequest, httpResponse); } @Override public void destroy() { } }

关于request.setCharacterEncoding有两个容易踩的坑。

第一,这个方法的生效范围只对请求体的编码有效,对URL中的查询字符串(QueryString)编码无效。如果你的参数是通过GET请求放在URL里的,比如/search?keyword=中文,那么setCharacterEncoding无法解决问题。此时需要在服务器层面配置URI编码(比如Tomcat的URIEncoding="UTF-8"),或者在接收参数时手动解码/编码。这也是为什么很多Tomcat调优文章都会涉及URIEncoding参数的原因,其实就是为了解决GET请求的乱码问题。

第二,如果客户端在请求头Content-Type中明确带了charset(比如Content-Type: application/x-www-form-urlencoded; charset=UTF-8),此时request.getCharacterEncoding()返回的就不是null,我们的编码过滤器就会跳过设置。这时要确认客户端是否真的使用UTF-8,如果不是,你的过滤器反而是不作为的。这个情况往往出现在第三方回调通知中,对方可能发了ISO-8859-1等编码,如果业务端期望UTF-8,就需要在该过滤器中增加"强制编码"的开关配置,对这类请求做一次请求体重新解码。处理逻辑会复杂很多,但思路就是:先判断Content-Type里的charset,再决定是否覆盖。

4.2 登录校验过滤器:把"门卫"逻辑写在前面

登录校验过滤器(或者叫认证过滤器)在传统Servlet项目中极为常见。它的核心逻辑是:对受保护的URL进行检查,如果当前会话中没有登录标识,则跳转到登录页;如果已登录,放行。

写这个过滤器时,有三个演练级别的细节要处理好。

白名单机制。登录页本身、登录接口、静态资源(CSS、JS、图片)必须放行,否则用户还没登录呢,就把人家拦到了登录页,而登录页所需的CSS又被拦截,页面会非常难看。一般是通过一个初始化参数配置白名单:

@Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; // 获取用户请求的URI String requestURI = request.getRequestURI(); String contextPath = request.getContextPath(); String path = requestURI.substring(contextPath.length()); // 白名单检查 if (isExcluded(path)) { chain.doFilter(servletRequest, servletResponse); return; } // 判断是否登录(以 session 为例) Object loginUser = request.getSession().getAttribute("LOGIN_USER"); if (loginUser != null) { chain.doFilter(servletRequest, servletResponse); } else { // 没有登录 → 重定向到登录页 response.sendRedirect(request.getContextPath() + "/login"); } }

重定向死循环的隐患。如果登录页面本身也被过滤器拦截,一旦未登录用户访问登录页,就会被重定向到登录页,而重定向又会触发新的请求,又被拦截,导致无限重定向。这个坑非常经典,解决办法就是把登录页URL和登录提交接口URL加入白名单。

Ajax请求的特殊处理。很多前后端分离项目里,登录校验失败时不能简单sendRedirect,因为异步请求拿到的是一个302响应,而XMLHttpRequest在跨域或同源下对302的处理行为往往是"透明跟随",最终前端拿到的是登录页的HTML,而不是它期望的JSON错误信息。常见的做法是在过滤器中判断请求头里是否有X-Requested-With: XMLHttpRequest(jQuery等库会带上),如果有,就不重定向,而是直接返回401状态码和JSON数据:

// 伪代码 if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或会话已过期\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); }

很多资深开发者在做前后端分离的过滤器时,都会确认前端Axios或Fetch是否配置了withCredentials(携带Cookie),若没配,后端过滤器里request.getSession(false)拿到的都是空,自然就会认为未登录。这类问题的排查,往往比过滤器本身的逻辑更耗时间。

4.3 日志过滤器:把请求信息完整记下来

日志过滤器也是一个非常实用的小工具,尤其是那种需要统计接口耗时的场景。如果你用Spring Boot的拦截器(HandlerInterceptor)做耗时统计,你会发现它只能统计到进入Controller前后的时间,无法覆盖整个链路的耗时。要想精确记录从请求进入到响应出炉的全流程时间,过滤器是更合适的位置。

一个性能日志过滤器的骨架:

package com.example.demo.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; public class LogFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; long startTime = System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { long costTime = System.currentTimeMillis() - startTime; String uri = httpRequest.getRequestURI(); String method = httpRequest.getMethod(); // 日志里最好记录:请求路径、请求方法、IP、耗时 // 这里按需输出,比如:log.info("[接口耗时] method={}, uri={}, cost={}ms", method, uri, costTime); System.out.println("[接口耗时] method=" + method + ", uri=" + uri + ", cost=" + costTime + "ms"); } } }

用try-finally包裹chain.doFilter(),可以保证即使后面的过滤器或Servlet抛出了异常,耗时统计仍然能够执行,这个写法比"后置写在大括号后面"更稳妥。注意,这里的异常会向上传播,并不会被吞掉。如果你想在过滤器层面统一捕获异常(比如记录错误日志并返回统一的错误提示),那就需要try-catch,并且仔细考虑处理完异常之后是否还要继续向外抛。

日志过滤器的另一个价值,是在排查"哪个请求导致CPU飙高"或者"某个恶意IP高频访问"时,可以快速从过滤器中打印出请求的来源IP、User-Agent、请求参数,并根据需要做频率限制。不过如果流量非常大,日志本身也会成为性能瓶颈,所以生产环境中这个过滤器通常加一个采样开关,比如保留1%的请求日志。

5.1 过滤器能否修改响应内容?包装器的力量

过滤器只能"拦截"却无法修改响应内容,这是很多刚接触过滤器的人的认识。其实并不完全对。过滤器可以修改响应,只是不能直接改HttpServletResponse方法返回的PrintWriter或ServletOutputStream,而是要通过包装器(Wrapper)模式。

HttpServletResponseWrapper是Servlet规范提供的装饰器类,我们可以在过滤器中创建一个自定义的响应包装器,拦截写入操作,把数据缓存到一个字节数组/字符缓冲区中,之后再统一处理(比如压缩、替换敏感词、统计body大小)。这个模式也是很多框架实现"响应内容压缩""响应签名""HTML内容拦截"的基础。

看一个简单的"内容替换"示例:

package com.example.demo.filter; import javax.servlet.*; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; import java.io.CharArrayWriter; import java.io.IOException; import java.io.PrintWriter; public class ContentReplaceFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 只处理 HTTP 响应 if (response instanceof HttpServletResponse) { HttpServletResponse httpResponse = (HttpServletResponse) response; CharResponseWrapper wrappedResponse = new CharResponseWrapper(httpResponse); chain.doFilter(request, wrappedResponse); // 从 wrapper 中获取全部内容 String content = wrappedResponse.getContent(); // 修改内容:比如把所有 email 替换成脱敏形式 String replaced = content.replaceAll("(\\w+)@(\\w+)", "***@$2"); // 重新写入原始响应 httpResponse.setContentLength(replaced.getBytes("UTF-8").length); httpResponse.getWriter().write(replaced); } else { chain.doFilter(request, response); } } // 自定义响应包装器 private static class CharResponseWrapper extends HttpServletResponseWrapper { private final CharArrayWriter writer = new CharArrayWriter(); public CharResponseWrapper(HttpServletResponse response) { super(response); } @Override public PrintWriter getWriter() { return new PrintWriter(writer); } public String getContent() { return writer.toString(); } } }

这里有几个代价要说明:

  • 包装器模式会将整个响应体缓存在内存中。如果接口返回的是大文件(比如几十MB的下载),这种写法会把内存撑爆。所以,这类过滤器只适合处理小体量的响应(JSON字符串、HTML页面),不要用于文件流。
  • 包装器会拦截getWriter(),但如果业务代码使用了getOutputStream()(比如下载二进制),这套包装就失效了。严格的做法是同时包装getOutputStream和getWriter,但这会显著增加代码复杂度。
  • getContentLength()变成手动设置后,若服务端开启了压缩或者分块传输(chunked),长度信息可能无效,需要结合Content-Length头或Transfer-Encoding头来判断是否必须设置。

这个内容替换思路,在很多场景里都有用,比如统一为返回HTML添加静态资源版本号、隐藏某些敏感关键词、排除某些页面不能缓存的响应头等。理解了响应包装器,过滤器的能力边界一下子就拓宽了不少。

5.2 过滤器与Spring到底是个什么关系

聊到这儿,有必要讲一下过滤器与Spring的集成方式。因为很多人在Spring Boot项目里会混写Filter和HandlerInterceptor,搞不清楚两者各自处在Spring的哪一层。

在传统的SSH/SSM项目中,过滤器在Servlet容器中运行,它先于Spring的DispatcherServlet执行。Spring的DispatcherServlet本身也是一个Servlet,但它是SpringMVC框架的处理入口。因此,过滤器并不感知"Spring容器里有哪些Controller、有哪些Service",它只是"在Servlet层面"工作。过滤器里如果想使用Spring管理的Bean,必须拿到ApplicationContext,常见的做法是:

  • 将过滤器类交给Spring管理,并用@Autowired注入依赖(前提是过滤器本身也是由Spring容器实例化的,而不是由容器new出来的)。
  • 通过WebApplicationContextUtils.getWebApplicationContext(request.getServletContext())获取Spring容器,再手动获取Bean。

在Spring Boot中,由于一切都被自动配置了,过滤器即使由Servlet容器创建,也可以通过注册FilterRegistrationBean时将其作为Spring Bean注入,从而让Filter类中可以使用@Autowired:

@Component public class AuthFilter implements Filter { @Autowired private TokenService tokenService; // ... }

Spring Boot会自动扫描到所有实现Filter接口的Bean并注册到容器中。如果你嫌它的注册不可控,再手动写一个FilterRegistrationBean并显式指定顺序。

与之对比,HandlerInterceptor(SpringMVC拦截器)的运行阶段是在DispatcherServlet内部。它是在SpringMVC定位到具体Handler(Controller方法)之前,以及之后、视图渲染之前执行。它的能力边界包括:可以访问Handler对象(不只是一个URI)、可以拿ModelAndView、参与视图渲染后的回调(afterCompletion)。相对过滤器而言,它更"Spring化",但它的作用范围局限于DispatcherServlet,无法拦截那些不经过DispatcherServlet的请求(比如静态资源默认由容器处理时,除非配置了放行或重定向到Spring)。

选型时可以这样判断:

  • 与具体业务无关、跨SpringMVC边界的通用需求(编码、协议、压缩、全局请求日志、API防刷)→ 用过滤器。
  • 与Controller/HandlerMethod强相关的业务需求(权限校验要拿到权限注解、操作审计需要知道是哪个方法被调用)→ 用拦截器。
  • 需要与第三方框架无缝协作、且不关心是否为Spring动作的通用链路干预 → 过滤器更合适。

6.1 写在Filter里的状态导致的并发问题

开篇提过,过滤器是单实例多线程的。这个教训我印象特别深。有一回同事在过滤器里写了一个成员变量private Map<String, Long> ipAccessMap = new HashMap<>();用来做接口防刷,结果压测时问题不断:明明是A用户访问了一次,却被当成高频访问拦截了。原因很简单,多线程同时访问Map,没有同步,更没有清理过期数据。那个过滤器使用时间一长,Map里堆积了大量IP,内存也随之攀升。

正确做法是把这种数据放到外部存储(Redis)上,或者使用并发且带过期策略的容器,比如Caffeine缓存。即使要用本地内存,也应该保证线程安全。过滤器中原则上不应存放与单个请求绑定、但又是成员字段的数据,这个原则我在很多文章里反复强调过。

6.2 过滤器里的IO操作导致的响应体重复写入

另一个常见的坑有点隐蔽。某些过滤器中为了记录请求体内容,会先读取一遍request.getReader()或request.getInputStream(),然后不经过任何包装直接放行。这在Servlet容器中会出问题,因为InputStream/Reader一旦被读取,后续Servlet再读就会拿到"EOF",参数就全丢了。

解决方式同样是通过包装器,提供一个HttpServletRequestWrapper,将请求流缓存一份,每次调用getInputStream()或getReader()时都返回缓存数据的流。

不过这类"可重复读请求体"的需求非常常见(比如在过滤器中做验签,验签后Servlet还要继续读参数),所以现在很多框架、工具库都提供了现成的ContentCachingRequestWrapper(Spring的org.springframework.web.util.ContentCachingRequestWrapper就是干这个的)。但注意,它也不是完美万能:默认情况下,它会缓存请求体的字节,但受限于容器读取时机,有些场景仍需手动包装。使用第三方封装时,先看它的源码怎么处理"边写边读"的顺序,这能省下很多调试时间。

6.3 静态资源也要过过滤器?小心性能损耗与漏网之鱼

过滤器配置的URL模式可以涵盖静态资源(JS、CSS、图片等)。如果写了/*,这些资源也会被过滤器链处理。这在业务上不一定是错的(比如某些响应头需要加到所有资源上),但对性能有影响。尤其高并发页面,静态资源请求数量往往远多于接口请求,每个请求都走一遍过滤器,会白白增加CPU和IO消耗。

一个务实的方案是,用更精确的URL模式来约束过滤器的范围,而不是总是写/*。例如登录校验过滤器只拦/api/*、/admin/*、/user/*;性能日志过滤器可以拦/api/*、/page/*,但排除/assets/*等静态目录。除此之外,在web.xml(或FilterRegistrationBean.setUrlPatterns)里维护"排除列表",确保静态资源路径匹配后直接放行。

很多团队把过滤器当成"全站管道"用,什么逻辑都塞进去,最后过滤器链越来越长。这既是性能问题,也是维护性问题:链条上任何一个Filter抛出异常,整个接口都受影响。对静态资源的请求,尽量让它们绕过过滤器链中的重量级环节,这是大型Web应用的常见优化思路。

6.4 过滤器中的异常处理边界:谁来响应客户端

过滤器中的异常,有些会传递到容器,由容器的错误处理机制响应;有些则会被吞掉。一个常见需求是:"统一异常处理"。很多团队会在Filter中try-catch所有异常,然后返回一个固定的JSON错误。但这个设计要小心:如果框架已经存在统一的@ControllerAdvice异常处理器,那么过滤器里再包一层,可能会把原本能够精确处理的异常(比如某些特殊业务异常)变成笼统的"系统异常",掩盖了真实问题。

准确的原则是:

  • 过滤器适合捕获"大环节"的异常,比如整个链路超时、网络中断、容器级别的错误。
  • Controller/Service层的业务异常,尽量交给SpringMVC的@ExceptionHandler或@ControllerAdvice处理,这样能保留异常类型、业务码、参数校验信息。
  • 在过滤器中统一设置响应编码(如response.setCharacterEncoding("UTF-8"))后,若发生chain.doFilter()传入的异常,为了不让用户看到一大堆类型的错误堆栈,可以先记录日志,再根据异常类型和Header信息决定返回什么内容,最后决定要不要继续往上传给容器。

从容器层面来看,web.xml中的<error-page>配置可以定义特定异常状态码对应的页面。如果过滤器只是把异常吞掉并自行输出响应,那<error-page>的配置就失效了。这个"谁的锅谁背"的分工,在设计统一异常处理时需要先商量好。

写到这里,过滤器那些经常被一笔带过的细节,可以说已经摊开得差不多了。回头看看,过滤器的知识体系其实不复杂,但多年不见的"老技术"里,暗藏的都是涉及线程安全、容器行为、框架边界的基础功。与其在Spring Boot里到处复制别人的过滤器代码,不如先把FilterChain的运行机制和配置语义摸透,这样遇到莫名其妙的问题时,你至少知道该往哪一层去翻。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进