Servlet Filter从入门到避坑:执行顺序、注册方式和拦截器对比

如果你在搜索引擎里敲下“Servlet Filter”,大概率会同时搜出 JS 的 Array.filter、图像处理里的卷积滤波、模拟器音频面板的 filter level,甚至 Windows 事件过滤的报错。这些虽然都叫 filter,但和本文要聊的完全是两码事。我要写的是 Java Web 世界里那个能在 Servlet 动手之前先截胡的 Filter,它是 Servlet 规范的一部分,Tomcat、Jetty、Undertow 都认它。

很多人一开始会想:我直接在 Servlet 里写 if 判断不也一样吗?这个问题等你写到十几个 Servlet 都要做同一件事的时候,答案就出来了——如果没有 Filter,那段公共逻辑你会复制粘贴到手软,漏掉一个 Servlet 就是线上事故。Filter 就是把这些“几乎每个请求都要处理”的公共事(编码、登录校验、日志、跨域)从业务 Servlet 里剥离出来,交给容器在 Servlet 执行前后统一调度。

这篇文章适合刚接触 Java Web 被各种教程绕晕的新手,也适合工作了两三年但一直没系统梳理过 Filter 的开发者。我会从一次请求的流转讲起,给出完整可运行的 web.xml 版本示例,对比注解和 Spring Boot 注册方式,最后把我在真实项目里踩过的坑全部倒出来。你可以把它当成一篇“Filter 从入门到避坑”的参考,也欢迎看到最后和我讨论你的项目里是怎么组织 Filter 链的。

1. 一次HTTP请求进到Tomcat后,Filter站在哪里?

1.1 从Servlet规范的角度看Filter的定位

很多教程讲 Servlet 生命周期,都会说:浏览器发请求,Tomcat 匹配到对应的 Servlet,然后调 service()。这个模型其实少了 Filter 这一环,所以你可能会纳闷:为什么别人项目里所有请求进来,日志里先打印的是过滤器里的内容,而不是我的 Servlet。

Servlet 规范把 Filter 定义为一种可以“插”在请求和 Servlet 之间的组件。容器收到请求之后,不会直接扔给 Servlet,而是先走一遍已经注册的 Filter 链,等所有 Filter 决定放行之后,请求才真正进入 Servlet。你可以把它想成机场安检:从入口到登机口,中间要过证件核验、行李检查,全过了才能登机,Servlet 就是那个登机闸门。

Filter 能干三件事:第一,在请求到达 Servlet 之前做检查、拦截、加工;第二,调用 FilterChain 的 doFilter 方法放行,或者干脆不放行直接返回响应;第三,在 Servlet 处理完之后,对响应做后处理,比如统一加响应头、压缩输出内容。

这里有一个初学者常忽略的点:Filter 不依赖 Spring。哪怕你的项目还是最原始的 JSP+Servlet,只要跑在 Servlet 容器里,Filter 就能正常工作。它是 Servlet 规范自带的能力,不是 Spring MVC 的附属品。

1.2 FilterChain:一条由容器管理的责任链

Filter 通常不止一个,多个 Filter 会按照注册顺序串成一条链。请求从链头进来,经过每一个 Filter 的 doFilter,只要某个 Filter 不放行,链条就断了。

文字版流转顺序是这样的:

客户端请求 → Filter1 前置 → Filter2 前置 → Filter3 前置 → Servlet 业务 → Filter3 后置 → Filter2 后置 → Filter1 后置 → 返回给客户端

这里最关键的细节是:chain.doFilter(request, response) 这行代码之前的部分属于“请求前逻辑”,之后的部分属于“响应后逻辑”。理解了这一点,你就能读懂为什么有的 Filter 在进入 Servlet 前要改登录态,为什么有的 Filter 在 Servlet 处理完还要给响应头补一个安全标识。

我建议你亲手跑一遍验证:写两个简单 Filter,每个都在 doFilter 前后各打一条日志,Servlet 里再打一条,体会一下实际输出顺序。这个顺序会直接影响你排查线上问题时的思路,比如全链路日志的打印顺序、耗时统计的起止点,都和它挂钩。

需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。

2. 用web.xml注册Filter:最规矩也最稳妥的写法

2.1 一个完整的Filter代码骨架

先给一个最通用的 Filter 代码结构。不管之后用注解还是注册器,核心方法永远是这三个:init()、doFilter()、destroy()。

init(FilterConfig config) 在容器启动时执行一次,用来读取初始化参数,比如编码名称、需要放行的 URL 列表。doFilter() 是每个请求都会进来的方法,干实际的活。destroy() 在容器销毁 Filter 实例时调用,一般用来释放资源。

我写一个登录校验 Filter,这是最常见的需求,而且能直接在 web.xml 项目里跑。

java复制package com.example.filter;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class LoginFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 可以在这里读取 filterConfig.getInitParameter("xxx")
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;

        String uri = req.getRequestURI();

        // 放行登录接口和静态资源
        if (uri.endsWith("/login") || uri.contains("/static/")) {
            chain.doFilter(request, response);
            return;
        }

        Object user = req.getSession().getAttribute("loginUser");
        if (user == null) {
            // 未登录,重定向到登录页
            resp.sendRedirect(req.getContextPath() + "/login");
            return;
        }

        // 已登录,放行
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        // 释放资源
    }
}

注意,把放行逻辑写在代码里只能算“演示级”做法。实际项目中,哪些 URL 不需要登录应该配置化,否则每次改放行列表都得重新编译部署,很折腾。

2.2 web.xml配置详解:filter 和 filter-mapping 的属性与执行顺序

有了 Filter 类,还需要在 web.xml 里注册,否则容器不认。

xml复制<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <filter>
        <filter-name>loginFilter</filter-name>
        <filter-class>com.example.filter.LoginFilter</filter-class>
        <init-param>
            <param-name>excludedUrls</param-name>
            <param-value>/login,/static/*</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>loginFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

filter-mapping 里的 url-pattern 有三种写法:精确路径 /login、目录匹配 /admin/*、后缀匹配 .do。/ 表示拦截所有请求。

执行顺序是:多个 filter-mapping 在 web.xml 中出现的先后顺序,就是 Filter 的执行顺序。这个顺序不是按类名,也不是按字母,就是字面上的书写顺序。我曾经看人把 filter-name 改了导致顺序全乱,折腾半天才意识到不是代码问题,是 XML 里顺序被调整了。

2.3 老项目迁移时的兼容性问题

现在 Spring Boot 很普及,但企业里仍然有大量 SSH、SSM 老项目跑在 web.xml 时代。这类项目有一个容易踩的细节:XML 头部的 schema 和 version 必须匹配容器版本。Java EE 7 对应 Servlet 3.1,Java EE 8 对应 Servlet 4.0,你拿 Servlet 4.0 的 schema 扔到老 Tomcat 7 上,启动阶段就可能报解析故障。

还有一个更隐蔽的问题:javax.servlet 和 jakarta.servlet 的命名空间切换。Servlet 5.0 以后,Java EE 移交给 Eclipse 基金会,包名从 javax.servlet.* 变成了 jakarta.servlet.*。Spring Boot 3.x 必须用 jakarta 命名空间的 Filter,如果你从 Spring Boot 2.x 直接升级,所有 import 为 javax.servlet 的 Filter 代码会全部编译不过。这个坑在项目升级时几乎必踩,而且不是看一眼文档就能记住的。

3. 不想写XML?注解和编程式注册的取舍

3.1 @WebFilter注解方式与初始化参数

从 Servlet 3.0 开始,Filter 可以直接用注解标记,省去 web.xml 的一大段配置。

java复制@WebFilter(
        filterName = "characterEncodingFilter",
        urlPatterns = "/*",
        initParams = {
            @WebInitParam(name = "encoding", value = "UTF-8")
        }
)
public class CharacterEncodingFilter implements Filter {

    private String encoding = "UTF-8";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String enc = filterConfig.getInitParameter("encoding");
        if (enc != null && !enc.trim().isEmpty()) {
            this.encoding = enc;
        }
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        request.setCharacterEncoding(encoding);
        response.setCharacterEncoding(encoding);
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }
}

注解方式最方便,但也最容易翻车:你无法精确控制多个 @WebFilter 之间的顺序。注解里虽然有 urlPatterns、dispatcherTypes 这些属性,但多个 Filter 谁先谁后由容器的扫描顺序决定,换一个 Tomcat 版本顺序可能就不一样。如果你的项目有多个 Filter 且顺序敏感,注解方式不适合。

3.2 Spring Boot下的FilterRegistrationBean:细粒度控制

如果你用的是 Spring Boot,我建议直接用 FilterRegistrationBean。它把 Filter 变成一个普通 Spring Bean,同时又能精确控制拦截路径和 order 值。

java复制@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<LoginFilter> loginFilterRegistration() {
        FilterRegistrationBean<LoginFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new LoginFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(1);
        registration.setName("loginFilter");
        return registration;
    }

    @Bean
    public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
        FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new XssFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(2);
        return registration;
    }
}

order 值越小越先执行。我习惯把编码、XSS 清理这类“请求预处理”放前面,把登录、权限这类“业务状态校验”放后面。顺序怎么排其实没有绝对标准,但一定要写进团队规范,否则后来的人随便往链上插一个 Filter,线上就多出一层莫名其妙的拦截或跳过,查起来非常费劲。

3.3 三种方式混用时的优先级规则

如果同一个项目里 web.xml、@WebFilter、FilterRegistrationBean 同时出现,容易出大问题。

优先级的粗略规则是:web.xml 里的 filter-mapping 顺序优先;没有 web.xml 时,Spring Boot 下按 FilterRegistrationBean 的 order 排序;@WebFilter 注解方式下面隐藏的注册顺序最不可控。

我遇到过最诡异的 CASE:一个 Filter 既加了 @WebFilter 注解,又被 @Component 扫进 Spring 容器,结果同一个请求里 Filter 被执行了两遍,日志打双份,接口响应体被包装了两层。解决办法很简单,只保留一种注册方式,要么注解,要么注册 Bean,不要双管齐下。

4. Filter 最值钱的几个应用场景

4.1 统一编码处理:别再每个Servlet里重复setCharacterEncoding

Java Web 最经典的问题就是乱码。Filter 的价值在于,你把编码设置写一次,全站的所有 Servlet 都受益。

java复制public class EncodingFilter implements Filter {

    private String encoding = "UTF-8";

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        request.setCharacterEncoding(encoding);
        response.setCharacterEncoding(encoding);
        chain.doFilter(request, response);
    }
}

有一个重要细节:request.setCharacterEncoding() 只对 POST 请求体里的参数生效,对 GET 查询参数没有作用。GET 参数乱码通常要在 Tomcat 的 server.xml 里配置 URIEncoding="UTF-8",或者在 Nginx 层统一处理。Tomcat 8 之后默认 URIEncoding 才是 UTF-8,Tomcat 7 及更早版本默认是 ISO-8859-1,遇到 GET 中文乱码不要只盯着 Filter 改,还要看容器配置。

4.2 登录态前置校验:把“有没有资格访问”提前做掉

登录校验是 Filter 最常见的应用场景。没有 Filter 时,你得在每个 Servlet 开头写 session 判断;有了 Filter,业务 Servlet 只关心业务逻辑,不关心“当前用户是谁”。

还可以做得更细:很多系统页面和接口共用登录态,但期望的行为不同。页面请求未登录时跳转登录页,接口请求未登录时返回 401 和 JSON。Filter 里可以通过请求路径前缀、请求头 X-Requested-With 或后缀来区分这两种请求,分别处理。这个需求用拦截器也能做,但 Filter 做的好处是路径范围覆盖更彻底,比如那些没走 Spring MVC 的接口,它也能管到。

网上还有一种叫“用户信息注入”的写法:Filter 从 session 取出用户后,放到 ThreadLocal 或 request attribute 里,后面业务代码直接取用。这样做能少写很多重复代码,但要注意线程复用时的清理问题,否则高并发下用户 A 看到用户 B 的信息,那是重大事故。

4.3 日志与耗时统计:最轻量级的全链路观测入口

如果你不想为了看接口耗时引入重量级 APM,Filter 里打请求日志就是最快的方案。

java复制public class AccessLogFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(request, response);
        } finally {
            long cost = System.currentTimeMillis() - start;
            System.out.printf("%s %s cost %d ms%n", req.getMethod(), req.getRequestURI(), cost);
        }
    }
}

必须要提醒:如果你想在 Filter 里记录 POST 请求体,不要直接 request.getInputStream() 读一次完事,因为请求体是一次性流,读完之后后面的业务代码就拿到空 body。正确做法是用 HttpServletRequestWrapper 包装请求,把输入流内容缓存下来,之后所有 getInputStream()、getReader() 都从缓存读。这个包装器写起来有点绕,但属于 Filter 进阶必须掌握的技巧。

4.4 安全与响应头处理:CORS、XSS清洗、全局响应头

CORS 跨域是 Filter 的另一大用武之地。给所有接口响应统一加 Access-Control-Allow-Origin、Access-Control-Allow-Methods 等头,比在每个 Controller 里手动加注解干净得多。注意 Spring MVC 的 @CrossOrigin 只对 Handler 生效,Filter 加头是对所有经过容器的资源生效,包括静态文件。

XSS 清洗也可以放 Filter:拿到请求参数后,把