☰
Java Web Filter详解:从登录拦截到请求处理链路
2026/10/12 22:54:10 网站建设 项目流程

1. 从一个登录拦截场景说起:为什么需要Filter

很多刚接触Java Web开发的朋友,第一次听到"过滤器"这个词的时候,脑子里浮现的可能是净水器里那层滤芯——脏水进去,干净水出来。这个直觉其实相当准确。Filter在Java EE规范里的定位,就是在请求到达目标资源之前、以及响应返回客户端之前,插入一段可复用的处理逻辑。它不改变请求的本质,但可以对请求和响应做"预处理"和"后处理"。

我最初理解Filter,是在做一个后台管理系统的时候。当时的需求很朴素:除了登录页面本身,其他所有页面都必须登录后才能访问。最笨的办法是在每个Servlet的doGet/doPost开头都写一段"检查session里有没有用户信息,没有就跳转到登录页"。写了三个Servlet之后我就受不了了——重复代码太多,而且一旦登录校验逻辑要改(比如增加token校验),得改几十个地方。这时候Filter就派上用场了:写一个登录校验过滤器,配置它拦截所有请求,在doFilter方法里统一判断,放行登录相关的路径即可。

这个场景揭示了Filter存在的核心价值:横切关注点的集中处理。所谓横切,就是那些散落在多个模块中、跟业务逻辑本身关系不大的通用逻辑,比如编码处理、登录校验、日志记录、敏感词过滤、跨域处理、请求参数清洗等等。这些逻辑如果混在业务代码里,会让代码变得臃肿且难以维护。Filter提供了一种机制,把这些逻辑抽出来,统一在请求链路的某个位置执行。

从技术层面看,Filter是Servlet规范的一部分,不是Spring发明的,也不是某个框架特有的。只要你的应用跑在Servlet容器里(Tomcat、Jetty、Undertow等),Filter就能工作。这一点很重要,意味着你理解了Filter,就理解了Java Web请求处理链路的一个基础环节,后面学Spring的Interceptor、AOP,都能找到对应的参照。

这篇文章我会从Filter的基本概念讲起,然后带你写一个能跑起来的最小示例,再深入讲配置方式、执行顺序、生命周期,最后分享几个我实际踩过的坑。目标很明确:看完之后你能独立写出可用的Filter,并且知道它为什么这么写。

2. Filter的核心概念拆解:三个方法撑起一套机制

2.1 Filter接口的三个生命周期方法

Filter本质上就是一个接口,位于javax.servlet包下(如果你用的是Jakarta EE 9+,则是jakarta.servlet包)。这个接口定义了三个方法,分别对应过滤器的初始化、执行和销毁三个阶段。

public interface Filter { default void init(FilterConfig filterConfig) throws ServletException {} void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; default void destroy() {} }

init方法在过滤器实例被创建后调用一次,容器会传入一个FilterConfig对象,通过它可以读取在web.xml中配置的初始化参数。这个方法适合做一些一次性的准备工作,比如加载配置文件、初始化连接池引用等。注意它只会执行一次,不是每个请求都执行。

doFilter是核心方法,每次请求匹配到这个过滤器时都会调用。它接收三个参数:ServletRequest、ServletResponse和FilterChain。前两个是请求和响应对象,第三个是过滤器链,这是理解Filter机制的关键。

destroy方法在过滤器被销毁前调用一次,用于释放资源,比如关闭文件流、清理缓存等。通常Web应用停止时容器会调用它。

这里有个细节值得注意:init和destroy在接口里是default方法(Java 8之后),意味着你可以只实现doFilter。但在早期的Servlet规范中,这三个方法都是抽象方法,必须全部实现。如果你看一些老教程,会发现它们把三个方法都写出来了,这不是啰嗦,是历史原因。

2.2 FilterChain:过滤器链的串联逻辑

FilterChain是理解Filter执行流程的核心。你可以把它想象成一条流水线,每个过滤器是流水线上的一个工位。请求从第一个工位进入,经过处理后传递给下一个工位,直到最后一个工位(也就是目标Servlet或JSP)。

关键代码就一行:

chain.doFilter(request, response);

这行代码的含义是"把请求交给链上的下一个环节"。如果你在doFilter方法里调用了它,请求就会继续往下走;如果你不调用,请求就会在这里被"截断",后面的过滤器和目标资源都不会执行。这正是实现拦截功能的基础——登录校验不通过时,直接return,不调用chain.doFilter,请求就到此为止了。

但这里有个容易混淆的点:chain.doFilter这行代码之前的逻辑,是在请求到达目标资源之前执行的;而这行代码之后的逻辑,是在目标资源处理完、响应返回时执行的。因为方法调用是嵌套的,像洋葱一样一层层剥开再一层层合上。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 请求到达目标资源之前的处理 System.out.println("before: 请求进入"); chain.doFilter(request, response); // 放行,交给下一个环节 // 目标资源处理完、响应返回时的处理 System.out.println("after: 响应返回"); }

这个"洋葱模型"是Filter最精妙的地方。它让你可以在同一个方法里,同时处理请求前置逻辑和响应后置逻辑,而不需要写两个方法。比如统计请求耗时,就可以在chain.doFilter之前记录开始时间,之后记录结束时间,两者相减即可。

2.3 Filter与Servlet、Interceptor的区别

初学者经常搞混Filter和Servlet,以及后来Spring MVC里的Interceptor。我用一个表格来对比,这样更直观。

维度FilterServletInterceptor(Spring MVC)
规范归属Servlet规范Servlet规范Spring框架
作用位置请求进入容器后,到达Servlet前请求的最终处理者在DispatcherServlet内部,Controller之前
能否获取Spring Bean默认不能(需特殊处理)默认不能能,天然在Spring容器中
执行顺序按配置顺序,链式由URL映射决定按配置顺序,链式
典型用途编码、登录校验、日志、跨域处理具体业务请求权限、日志、参数校验

简单说,Filter更底层,工作在Servlet容器层面,能拦截几乎所有请求(包括静态资源);Interceptor更上层,工作在Spring MVC层面,只能拦截进入DispatcherServlet的请求,但能方便地使用Spring的依赖注入。实际项目中两者经常配合使用:Filter做全局的编码和粗粒度拦截,Interceptor做细粒度的权限和业务相关处理。

3. 手写第一个Filter:从零到跑通的完整步骤

3.1 环境准备与项目结构

我假设你用的是Maven项目,Servlet容器是Tomcat 9(对应Servlet 4.0,包名还是javax.servlet)。如果你用的是Tomcat 10+,包名变成了jakarta.servlet,代码里的import要相应调整,这是很多人在升级时踩的第一个坑。

pom.xml里需要引入Servlet API,注意scope是provided,因为Tomcat本身已经提供了这个jar包,打包时不需要带进去:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>

项目结构大致如下:

src/main/java/com/example/filter/ EncodingFilter.java LoginFilter.java src/main/webapp/ login.jsp index.jsp WEB-INF/web.xml

3.2 编写一个编码过滤器

中文乱码是Java Web开发中最经典的问题之一。请求参数里的中文如果编码不对,到了后端就变成问号或者乱码。最省事的解决办法就是写一个编码过滤器,统一设置请求和响应的字符集。

package com.example.filter; import javax.servlet.*; import java.io.IOException; public class EncodingFilter implements Filter { private String encoding = "UTF-8"; @Override public void init(FilterConfig filterConfig) throws ServletException { // 从配置中读取编码参数,如果没有配置则用默认的UTF-8 String param = filterConfig.getInitParameter("encoding"); if (param != null && !param.trim().isEmpty()) { this.encoding = param; } System.out.println("EncodingFilter 初始化,使用编码:" + encoding); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 设置请求体的编码,解决POST请求中文乱码 request.setCharacterEncoding(encoding); // 设置响应的编码和内容类型,解决响应中文乱码 response.setCharacterEncoding(encoding); response.setContentType("text/html;charset=" + encoding); // 放行,交给下一个过滤器或目标资源 chain.doFilter(request, response); } @Override public void destroy() { System.out.println("EncodingFilter 销毁"); } }

这段代码有几个点需要解释。request.setCharacterEncoding只对POST请求的请求体有效,对GET请求的URL参数无效。GET请求的中文乱码需要在Tomcat的server.xml里配置URIEncoding,或者用其他方式处理。这是很多人不知道的细节,以为设了编码过滤器就万事大吉,结果GET请求还是乱码。

response.setContentType同时设置了内容类型和字符集,这比单独调setCharacterEncoding更保险,因为有些浏览器会优先看Content-Type头里的charset。

3.3 编写一个登录校验过滤器

接下来写一个更实用的登录过滤器,拦截未登录的访问。

package com.example.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.Arrays; import java.util.List; public class LoginFilter implements Filter { // 不需要登录就能访问的路径 private List<String> excludePaths; @Override public void init(FilterConfig filterConfig) throws ServletException { String excludes = filterConfig.getInitParameter("excludePaths"); if (excludes != null) { excludePaths = Arrays.asList(excludes.split(",")); } } @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String contextPath = request.getContextPath(); // 去掉上下文路径,得到相对路径 String path = uri.substring(contextPath.length()); // 白名单路径直接放行 if (excludePaths != null) { for (String exclude : excludePaths) { if (path.startsWith(exclude.trim())) { chain.doFilter(request, response); return; } } } // 检查session中是否有登录标记 HttpSession session = request.getSession(false); boolean loggedIn = (session != null && session.getAttribute("user") != null); if (loggedIn) { chain.doFilter(request, response); } else { // 未登录,重定向到登录页 response.sendRedirect(contextPath + "/login.jsp"); } } @Override public void destroy() { excludePaths = null; } }

这里有个细节:request.getSession(false)传入false表示"如果session不存在就返回null,不要新建"。如果用getSession()无参版本,它会自动创建session,这样每个未登录请求都会创建一个新session,浪费内存。这个坑我在早期项目里踩过,后来看服务器内存监控才发现session数量异常增长。

3.4 在web.xml中注册过滤器

Filter写好了不会自动生效,需要在web.xml里注册,或者用注解。先看web.xml的方式:

<filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.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>loginFilter</filter-name> <filter-class>com.example.filter.LoginFilter</filter-class> <init-param> <param-name>excludePaths</param-name> <param-value>/login.jsp,/login,/static/</param-value> </init-param> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

url-pattern的写法有几种:/*匹配所有请求,/admin/*匹配admin下的所有路径,*.jsp匹配所有jsp文件。注意/*和/的区别,前者匹配所有包括子路径,后者只匹配根路径,这个容易写错。

过滤器的执行顺序由filter-mapping在web.xml中出现的顺序决定,而不是filter元素的顺序。所以上面配置中,encodingFilter先执行,loginFilter后执行。这个顺序很重要:编码过滤器必须排在前面,否则后面的过滤器读取参数时可能已经乱码了。

3.5 用注解方式注册过滤器

Servlet 3.0之后支持注解,可以省去web.xml配置:

@WebFilter( filterName = "encodingFilter", urlPatterns = "/*", initParams = { @WebInitParam(name = "encoding", value = "UTF-8") } ) public class EncodingFilter implements Filter { // ... }

注解方式更简洁,但有个限制:注解方式无法指定过滤器的执行顺序。多个注解过滤器之间的顺序是不确定的,取决于容器扫描类的顺序。如果顺序很重要(比如编码过滤器必须最先执行),还是得用web.xml,或者用@WebFilter配合编程式注册。这是注解方式的一个硬伤,很多人用了注解之后发现编码过滤器偶尔不生效,就是因为顺序问题。

4. Filter的进阶用法与执行顺序实战

4.1 多个过滤器的执行顺序验证

光看文档说"按配置顺序执行"不够直观,我写了个小实验来验证。定义三个过滤器A、B、C,每个都在chain.doFilter前后打印日志。

public class FilterA implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { System.out.println("A - before"); chain.doFilter(req, resp); System.out.println("A - after"); } } // FilterB、FilterC类似

在web.xml中按A、B、C的顺序配置,访问任意页面,控制台输出是:

A - before B - before C - before (目标资源处理) C - after B - after A - after

这个输出完美印证了"洋葱模型":请求阶段按配置顺序依次进入,响应阶段按相反顺序依次返回。理解了这个,你就能解释为什么编码过滤器要放最前面——它在请求阶段最先执行,能保证后续所有环节拿到的都是正确编码的数据。

4.2 过滤器的四种拦截方式配置

filter-mapping里有个dispatcher元素,用来指定过滤器拦截哪种请求分发方式。默认是REQUEST,也就是客户端直接发起的请求。还有三种:

  • FORWARD:通过RequestDispatcher.forward()转发的请求
  • INCLUDE:通过RequestDispatcher.include()包含的请求
  • ERROR:通过错误页面机制分发的请求

这个配置在实际项目中很有用。比如登录过滤器如果只配了REQUEST,那么当某个Servlet内部forward到另一个页面时,过滤器不会再次执行。这通常是符合预期的,但如果你希望forward也走过滤器,就得显式配置。

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

我遇到过一个案例:某项目用forward做页面跳转,登录过滤器只配了REQUEST,结果用户未登录时直接访问某个会forward的URL,过滤器拦住了第一次请求,但forward之后的页面没有被再次校验,导致可以绕过登录。加上FORWARD配置后就正常了。这个坑比较隐蔽,因为表面上看过滤器是生效的,只是没覆盖到forward场景。

4.3 FilterConfig读取初始化参数的细节

FilterConfig除了getInitParameter,还有几个方法值得了解:

  • getFilterName():返回过滤器的名称
  • getServletContext():返回ServletContext,可以访问应用级别的属性
  • getInitParameterNames():返回所有初始化参数名的枚举

初始化参数在init方法中读取一次即可,不要在doFilter里反复读取,因为FilterConfig在doFilter阶段是拿不到的(除非你把它存成成员变量)。我见过有人在doFilter里想读配置,结果发现没有FilterConfig参数,就是因为没理解生命周期。

4.4 过滤器中的请求包装:HttpServletRequestWrapper

Filter的一个高级用法是包装请求对象,修改请求参数。比如你想统一给所有请求参数做trim处理,或者过滤敏感词,直接改HttpServletRequest是做不到的(它的getParameter方法没有setter)。这时候需要继承HttpServletRequestWrapper。

public class TrimRequestWrapper extends HttpServletRequestWrapper { public TrimRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return value == null ? null : value.trim(); } }

然后在过滤器中:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { chain.doFilter(new TrimRequestWrapper((HttpServletRequest) request), response); } else { chain.doFilter(request, response); } }

这个技巧在处理表单提交时特别有用,用户输入的前后空格自动去掉,省得在每个业务方法里手动trim。但要注意,包装后的request传给chain.doFilter,后续的过滤器和Servlet拿到的都是包装对象,所以包装逻辑要放在合适的位置。

5. 那些年我踩过的Filter坑

5.1 过滤器不生效的排查思路

过滤器配了但没反应,这是新手最常见的问题。我总结了一个排查清单,按顺序检查:

第一,检查url-pattern是否匹配。/*匹配所有,/只匹配根路径,*.do匹配特定后缀。如果你写的是/admin,那只有访问/admin这个精确路径才会触发,/admin/user不会触发。要匹配子路径得写/admin/*。

第二,检查web.xml的版本声明。如果web.xml的version属性是2.3或更低,注解方式不生效,而且某些配置行为也不一样。建议用3.0以上。

第三,检查过滤器类是否被正确编译和部署。有时候改了代码没重新编译,或者class文件没更新到WEB-INF/classes下,过滤器用的还是旧版本。

第四,检查是否有多个过滤器互相影响。比如前面的过滤器没有调用chain.doFilter,后面的过滤器就永远不会执行。

第五,检查dispatcher配置。如果请求是通过forward到达的,而过滤器只配了REQUEST,就不会触发。

5.2 在过滤器中注入Spring Bean的正确姿势

Filter由Servlet容器管理,不在Spring容器里,所以直接用@Autowired注入Bean会失败(得到null)。这个问题困扰过很多人。解决办法有几种:

第一种,用WebApplicationContextUtils从ServletContext中获取Spring上下文:

public void init(FilterConfig filterConfig) throws ServletException { ServletContext context = filterConfig.getServletContext(); WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(context); this.userService = ctx.getBean(UserService.class); }

第二种,用Spring的DelegatingFilterProxy,把过滤器配置成Spring Bean,然后在web.xml中代理。这种方式更优雅,但配置稍复杂。

第三种,如果用的是Spring Boot,直接把Filter注册为@Bean,Spring Boot会自动处理依赖注入和注册。

我个人的建议是:如果项目用Spring Boot,就用@Bean方式;如果是传统SSM项目,用第一种方式最直接。

5.3 过滤器中的异常处理

doFilter方法抛出的异常会直接传播到容器,容器会显示错误页面。如果你希望在过滤器里捕获异常并做统一处理(比如返回JSON错误信息),需要自己try-catch。

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } catch (Exception e) { // 统一异常处理 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":500,\"msg\":\"服务器内部错误\"}"); } }

但要注意,如果响应已经开始输出(比如Servlet已经写了一部分内容),再往response里写数据会报错。所以这种统一异常处理更适合在请求早期阶段使用,或者配合response.isCommitted()判断。

5.4 过滤器与静态资源的关系

默认情况下,/*会拦截所有请求,包括CSS、JS、图片等静态资源。如果登录过滤器拦截了静态资源,未登录用户连登录页的样式都加载不出来,页面会很难看。所以白名单里一定要加上静态资源路径,比如/static/、/css/、/js/、/images/。

另一个思路是在url-pattern里排除静态资源,但Servlet的url-pattern不支持排除语法,只能通过白名单在代码里判断。这是Filter配置的一个不便之处。

6. 从Filter到请求处理链路:一个完整的认知框架

6.1 一次请求在容器中的完整旅程

理解了Filter,最好再把整个请求处理链路串起来看。当浏览器发起一个请求,大致经历这些环节:

  1. 连接器接收请求,解析HTTP报文
  2. 根据URL找到对应的Host和Context(Web应用)
  3. 在Context中匹配Filter链,依次执行每个Filter的请求前置逻辑
  4. 匹配到目标Servlet,调用其service方法
  5. Servlet处理业务,可能forward或include其他资源
  6. 响应返回,依次执行Filter链的响应后置逻辑
  7. 连接器把响应写回客户端

Filter在这个链路中处于第3步和第6步,是请求进入业务逻辑前的最后一道关卡,也是响应返回客户端前的最后一道处理。这个位置决定了它适合做全局性的、与业务无关的处理。

6.2 Filter在典型项目中的配置清单

根据我的经验,一个中等规模的Java Web项目,通常会配置这些Filter:

过滤器作用执行顺序
CharacterEncodingFilter统一编码最前
CorsFilter跨域处理靠前
LogFilter请求日志记录靠前
LoginFilter登录校验中间
XssFilterXSS防护中间
CompressionFilter响应压缩靠后

顺序的原则是:越基础的、越全局的越靠前;越贴近业务的越靠后。编码和跨域是基础设置,必须最先;登录校验依赖前面的编码正确;压缩是响应阶段的处理,放最后。

6.3 什么时候不该用Filter

Filter虽然好用,但也不是万能的。以下几种情况要慎重:

需要访问Spring Bean且逻辑复杂时,优先考虑Interceptor,因为Interceptor天然在Spring容器中,依赖注入更方便。

需要精确控制到方法级别的拦截时,Filter做不到,它只能基于URL模式匹配。方法级别的拦截要用AOP。

需要修改请求参数时,Filter需要包装request,比较繁琐,而Spring MVC的@ControllerAdvice配合RequestBodyAdvice可能更合适。

需要异步处理时,Filter的同步模型可能成为瓶颈,要考虑用异步Filter或者响应式方案。

我个人的经验是:Filter最适合做那些"跟业务无关、跟请求本身有关"的事情,比如编码、日志、跨域、压缩。一旦逻辑开始涉及业务规则,就该考虑往上层走。

7. 写在最后的一点个人体会

Filter这个知识点,看起来简单,三个方法而已,但真正用好需要理解它的生命周期、执行顺序、与容器的关系。我见过不少工作两三年的开发者,写Filter还是靠复制粘贴,遇到不生效就到处试,根本原因是没有理解FilterChain的洋葱模型和url-pattern的匹配规则。

如果你刚开始学,我的建议是:不要只看不写。自己动手写一个编码过滤器、一个登录过滤器,故意配错url-pattern看看什么效果,故意不调chain.doFilter看看请求怎么被截断,故意配两个过滤器看看执行顺序。这些实验花不了多少时间,但能让你对Filter的理解从"知道"变成"掌握"。

另外,Servlet规范从3.0到4.0再到Jakarta EE 9的包名变更,是很多人升级项目时的痛点。如果你维护的是老项目,注意javax.servlet和jakarta.servlet的区别,迁移时import要全改,web.xml的schema也要更新。这个坑我在一次项目升级中踩过,改了几十个文件才搞定。

最后分享一个实用技巧:调试Filter时,在doFilter里打印请求的URI和当前过滤器的名字,能快速定位是哪个过滤器拦截了请求、执行顺序是否符合预期。这个简单的日志,在排查"请求为什么没到Controller"这类问题时,比任何调试工具都直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询