ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot过滤器实战:定义、注册、执行顺序与常见坑

Spring Boot过滤器实战:定义、注册、执行顺序与常见坑

过滤器(Filter)是Servlet规范里最“老派”却又实用的组件,但Spring Boot项目里真正会用的人比想象中少。我日常帮同事review代码时,最常见的反应是:要么把Filter当作Spring MVC拦截器用,要么干脆用拦截器绕开它,结果很多请求还没进Controller就该处理的逻辑,反而放错了地方。这篇东西不是文档复读,我想用实际项目里踩过的坑讲清楚Spring Boot Filter的定义、注册、执行顺序、典型场景和排查方法,顺便说说为什么它值得被认真对待。适合刚接触Spring Boot的初学者,也适合那些知道Filter但没系统整理过细节的中级开发者。

1. 过滤器到底是什么,请求进来之后它做了什么

先搞清楚Filter在Spring Boot里的准确位置。Filter不是Spring独有的东西,它是Servlet规范里定义的一种组件。你写一个类实现Filter接口,重写doFilter方法,容器收到HTTP请求后,会先把请求交给匹配的Filter链,Filter链处理完之后才轮到真正的Servlet。Spring MVC里的DispatcherServlet本质上就是个Servlet,所以Filter的执行时机天然比任何Controller、拦截器、切面都早。

1.1 一次请求从客户端到Controller要过几道门

当你发一个/api/user/info请求过来,内嵌的Servlet容器拿到原始请求后,会按注册顺序把所有匹配的过滤器串成一条链。每个Filter在doFilter里决定两件事:要不要放行(调用chain.doFilter),或者直接在这里把请求拦住返回结果。放行之后才会到达Spring MVC入口DispatcherServlet,再根据路径、方法参数、消息转换器等机制分发到某个Controller方法。

我在项目里喜欢把Filter理解成“高速公路入口的收费站”:你可以在赛道上设好几个收费站,每个收费站决定放行、拦截、拍照、记录日志。请求只有过了全部收费站才能进入真正的业务道路。因为Filter发生在Spring容器“已经启动完成但还没决定路由”这个阶段,它非常适合做那些和具体业务无关的横切面逻辑。

1.2 Filter和Spring MVC拦截器的分界线

很多人纠结Filter和HandlerInterceptor选哪个,其实它们根本不在一层。Interceptor是Spring MVC框架内部的组件,它默认只拦截经过DispatcherServlet的请求,而且能和Controller方法、Model、HandlerMapping紧密结合。Filter则活在更外面,它连Spring MVC的门都还没进。

对比维度Servlet FilterSpring MVC Interceptor
所属规范Servlet规范Spring MVC框架
执行时机进入Servlet之前进入Controller之前
作用范围匹配的URL,包括Servlet、静态资源等匹配的Handler,一般只作用于Controller
是否能直接注入Spring Bean需要注册方式配合本身是Spring管理的Bean
适合场景通用鉴权、日志、编码、跨域登录校验、权限校验、Handler预处理

我的选择逻辑很简单:只要是“不管Controller存不存在、不管静态资源还是API都要做的通用事情”,比如安全Header、请求日志、统一的租户上下文,优先考虑Filter;如果和某个Controller或Handler强相关,比如方法级权限,优先考虑Interceptor。两者不是替代关系,我经常在一个项目里同时用,Filter先做最粗粒度的拦截,Interceptor再做精细化的控制。

2. 定义一个Filter的几种姿势,别再傻傻只用@Component

定义Filter本身不难,难的是注册方式五花八门,每种的坑还不一样。我见过不少同学先implements Filter,再随手加个@Component,结果发现所有请求都被过滤了,想配置路径又没地方配,最后被迫用shouldNotFilter硬写排除逻辑。其实Spring Boot给了你更完整的控制面。

2.1 从javax到jakarta,你的Spring Boot版本决定写哪个包

先回答一个最基础的问题:import到底该写javax.servlet还是jakarta.servlet。

Spring Boot 2.x基于Servlet 4.0,Filter等接口在javax.servlet包下。从Spring Boot 3.0开始,整个体系迁移到了Jakarta EE 9,接口变成了jakarta.servlet。如果你在Spring Boot 3项目里还写javax.servlet.Filter,编译可能不报错,但运行时大概率遇到ClassNotFoundException或no such method之类的诡异问题。

// Spring Boot 3.x 写法 import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse;
// Spring Boot 2.x 写法 import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse;

这个坑我踩过一次。把老项目从Boot 2升到Boot 3,全局搜索替换时漏了几个import,结果某个过滤器一直不生效。后来排查才发现是包名没换干净,期初不起眼,线上却会造成鉴权漏洞。建议升级时把搜索范围覆盖到所有javax.servlet引用,一个都不能漏。

2.2 三种注册方式对比

Spring Boot里注册Filter常见的有三种方式:直接用@Component、用@WebFilter加@ServletComponentScan、用FilterRegistrationBean。

先说说@Component。这种方式最省事,Filter类上标注@Component后,Spring Boot会自动把它注册进Servlet容器。缺点是默认匹配所有请求(/*),而且你没法在这里显式配置urlPatterns和initParams。如果你只想过滤/api/*,靠@Component做不到,只能自己在doFilter开头判断路径,属于“用代码弥补配置缺失”,不推荐。

再说@WebFilter。这是Servlet原生注解,配合启动类上的@ServletComponentScan可以扫描注册。它能直接写urlPatterns,比起@Component进步了一点。但它有个问题:Filter实例由Servlet容器直接创建,Spring容器管不到它,你在这个类里写@Autowired注入Service经常拿到null。而且它的顺序行为和Spring Boot的自动注册机制有时不一致,混着用容易出乱子。

最后是FilterRegistrationBean。这是Spring Boot提供的注册包装类,可以精确控制URL匹配、执行顺序、过滤器名称、初始化参数、DispatcherType。我在实际项目里最推荐的就是它,特别是对顺序有要求的场景。一个典型写法是这样的:

@Configuration public class FilterConfig { @Bean public LoginCheckFilter loginCheckFilter(TokenService tokenService) { return new LoginCheckFilter(tokenService); } @Bean public FilterRegistrationBean<LoginCheckFilter> loginCheckFilterRegistration(LoginCheckFilter filter) { FilterRegistrationBean<LoginCheckFilter> registration = new FilterRegistrationBean<>(filter); registration.addUrlPatterns("/api/*"); registration.setName("loginCheckFilter"); registration.setOrder(10); return registration; } }

这里通过构造函数把TokenService传给Filter,Filter本身由Spring管理,依赖注入是通畅的。setOrder(10)直接决定它在过滤链里的位置,数字越小越先执行。

2.3 为什么Filter接口不写全也能运行

很多教程里的Filter类不仅实现了doFilter,还顺手写了init和destroy。这两个方法不是必须的。Servlet容器在Filter生命周期中会调用它们,但Spring Boot把Filter实例交给容器时,默认已经完成了初始化,写不写看你有没有需要在拿到FilterConfig时读取初始化参数。如果不需要,init和destroy完全可以不实现,接口里提供了默认方法。把代码写少一点,反而更容易看懂。

3. 顺序、路径和过滤链的底层逻辑

Filter链的编排规则对线上影响很大。顺序错了,可能日志没记上、鉴权被绕开、甚至过滤器自己抛异常导致业务中断。这个章节把顺序和路径这块讲透。

3.1 setOrder和@Order,到底哪个说了算

顺序的规则其实很简单:越小越靠前。Spring Edge用Ordered接口和@Order注解表达优先级,Ordered.HIGHEST_PRECEDENCE是最大的负数,等于最先执行;Ordered.LOWEST_PRECEDENCE是最大的正整数,等于最后执行。

当你用FilterRegistrationBean注册Filter,setOrder()的值就是最终顺序。如果你是@Component自动注册的Filter,Spring Boot也会读取它上面的@Order注解来排先后。问题最容易出现在混用的时候:一个Filter用@Order(1),另一个Filter用FilterRegistrationBean.setOrder(1),两套体系的语义有时候会让人懵。我建议整个项目统一用一种控制方式,最简单的是:所有自定义Filter都用FilterRegistrationBean,配置类里按业务阶段从小到大编好号,一眼就能看出执行顺序。

有一点要注意:Spring Boot自己会注册几个内置Filter,比如字符编码、表单内容、请求上下文这些,它们的order普遍非常靠前。你自定义Filter的order是10,并不会排在内置Filter前面。如果你确实需要在最最前面执行,可以考虑设置一个很小的负数,但正常情况下没必要,提前干预内置组件反而容易出问题。

3.2 doFilter前后的pre和post分别是什么

很多第一次写Filter的人会误以为chain.doFilter调用完之后,就拿到了Controller方法的返回值。不是的。chain.doFilter是“放行给链的下一个环节”的意思,它返回后,响应可能已经被Controller甚至后续Filter写完了。

我把这个机制类比成排队过安检:站到安检员面前是doFilter之前,安检员放你进去是chain.doFilter,你走出安检口后是doFilter之后。你能在“之前”拦截行李,在“之后”检查已通过的行李,但安检口里面发生了什么,Filter本身是看不到的。

所以记住:chain.doFilter之前的逻辑叫pre,适合做鉴权、前置校验、设置请求属性;chain.doFilter之后的逻辑叫post,适合统计耗时、给响应补Header、记录响应状态。post里拿不到Controller的返回值,如果想改响应体,得用ResponseWrapper把响应内容缓存下来再重写。

3.3 URL匹配和DispatcherType,比你想的更细

URL匹配规则沿用Servlet的路径匹配语法,最常用的是/*匹配所有路径,/api/*匹配以/api/开头的路径,还可以写精确路径如/login。FilterRegistrationBean上调用addUrlPatterns("/api/*")时,不要写成/api/**,Servlet规范这里用的是Ant风格的*,不是Spring MVC里那套**。写错的话过滤器就是不执行,不报错也不提示。

还有个容易被忽略的参数叫DispatcherType。默认情况下,Filter只拦截REQUEST类型的分发,也就是客户端直接发起的请求。如果Controller里做了forward转发,或者系统发生错误后进入了错误处理流程,对应的FORWARD、ERROR分发可能不会触发你的Filter。想让Filter也覆盖错误页或转发路径,需要这样设置:

registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.ERROR);

我遇到过一个诡异问题:接口抛异常后,错误响应里的自定义Header消失了。排查了半天,发现错误处理走的是ERROR分发,我的Filter默认没参与,所以在错误场景下Header没被加上来。设置ERROR分发类型后问题才解决。

4. 三个可以直接抄的Filter场景实操

理论讲再多,不如给几个能直接改改就用的例子。下面这三个Filter是我项目里最常用的,覆盖了登录校验、请求日志、请求体缓存。

4.1 登录态校验过滤器

现在很多前后端分离项目用Token做鉴权。Token校验放在Filter里很合适,因为Controller还没开始执行的时候就应该拦截掉非法请求。我这里用OncePerRequestFilter,它比直接实现Filter更好用,核心优势是保证一次请求只执行一次,即使后续有转发、异步分发也不会重复跑。

public class LoginCheckFilter extends OncePerRequestFilter { private final TokenService tokenService; public LoginCheckFilter(TokenService tokenService) { this.tokenService = tokenService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = request.getHeader("token"); if (tokenService.check(token)) { chain.doFilter(request, response); return; } response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"login required\"}"); } @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); return uri.startsWith("/api/login") || uri.startsWith("/api/public"); } }

用shouldNotFilter排除登录接口和公开接口,比在doFilter里写一长串if更优雅。注册时再用addUrlPatterns("/api/*")限定范围,等于做两道闸门:容器层只把/api/*送进这个Filter,Filter内部再放行公开路径。注意,返回错误响应时一定要设置Content-Type,否则前端拿到的可能是纯文本或者乱码。

4.2 请求耗时和基础信息日志过滤器

排查线上性能问题,请求日志很有用。Spring Boot自带CommonsRequestLoggingFilter,但不够灵活,我更倾向于自己写一个轻量的,只记方法、路径、耗时和状态码,不记请求体,避免把用户密码或身份证号打进日志。

public class RequestLogFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(RequestLogFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { long cost = System.currentTimeMillis() - start; log.info("method={}, uri={}, status={}, cost={}ms", request.getMethod(), request.getRequestURI(), response.getStatus(), cost); } } }

这里有个细节:chain.doFilter放在try里,finally里记录日志。这样即使后续Controller抛了异常,这条请求的耗时记录也不会丢。如果直接写在chain.doFilter后面,一旦异常抛出,日志就漏了。很多同学第一次写日志Filter会犯这个错。

4.3 让请求体可以重复读取的RequestWrapper

HttpServletRequest的InputStream只能读一次。Filter想读取请求体做校验、签名、审计,Controller又想正常接收参数,两者就冲突了。解决办法是做一个缓存Wrapper,在Filter里把请求体读成字节数组,之后任何代码都能重新获取。

public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private final byte[] body; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); this.body = request.getInputStream().readAllBytes(); } @Override public ServletInputStream getInputStream() { ByteArrayInputStream buffer = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public boolean isFinished() { return buffer.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { } @Override public int read() { return buffer.read(); } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }

使用方式是在Filter里先判断请求是否带body,如果带了就包一层再放行:

CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request); chain.doFilter(wrappedRequest, response);

这样Controller的@RequestBody参数、后续Filter读取body、甚至日志里打印body都能正常工作。要注意的是,readAllBytes()会把请求体完整读入内存,对超大上传文件不合适。如果场景是文件上传,不要用这个Wrapper,应直接用MultipartResolver处理。

5. 常见问题排查:我踩过的那些Filter大坑

写Filter的代码不难,难的是出了问题怎么定位。下面这些坑是我和团队在真实项目里踩过的,整理成一个速查清单。

5.1 Filter里注入的Service是null

这个坑多数是因为Filter实例并不是Spring容器创建的。比如你直接在FilterRegistrationBean的构造函数里new LoginCheckFilter(),而这个Filter类内部用@Autowired去注入Service,字段永远是null。Spring的自动注入只会发生在容器管理的Bean上,你用new造出来的对象和容器没关系。

解决办法有两个方向:一是让Filter本身变成Spring Bean,再用setFilter把它塞进注册器;二是放弃字段注入,用构造函数传入依赖。我目前项目里基本都用构造函数注入,清晰且不容易出“注入失效”的隐藏问题。

5.2 过滤器执行了两次

过滤器重复执行,最常见的原因是同一个Filter被注册了多次。典型组合是:Filter类上既写了@Component,又在某个配置类里用FilterRegistrationBean注册了一次。Spring Boot会把它当作两个不同的Filter实例放进链里,每次请求日志打两遍,操作执行两遍。

排查时先把所有注册点找出来:类上的注解、FilterRegistrationBean、@WebFilter。一个Filter只保留一种注册方式。如果你确实需要用配置类统一管顺序,可以去掉@Component,把Filter定义成@Bean再注册。另外,如果你在Controller内部forward请求,且DispatcherType里加了FORWARD,也可能造成重复执行,这种情况建议使用OncePerRequestFilter来兜底。

5.3 order怎么调都不对

如果你同时用@Order和FilterRegistrationBean控制不同Filter的顺序,很容易出现“我以为先执行A,实际却是B先执行”。因为@Order在不同的Spring组件里语义不完全一致,而Servlet容器的Filter链本身不认Spring这套。

我的建议是把顺序控制收敛到一个地方。项目里我建了一个FilterConfig配置类,所有自定义Filter都在里面注册,用setOrder(10)、setOrder(20)这种方式排好序。哪个Filter夹在中间,翻开配置类一目了然。不要在Filter类上到处贴@Order,维护到后面你会疯掉的。

5.4 请求体读不到、响应乱码和异步分发的坑

请求体问题上面已经说了,用RequestWrapper。响应乱码的问题通常是你在Filter里response.getWriter().write()之前没有设置Content-Type和编码,或者你已经用response.getOutputStream()写了一遍,又有人用getWriter()写,两种流不能混用。尽量统一用一种方式写响应。

异步请求的问题是另一类经典。如果Controller返回Callable或DeferredResult,Servlet会发起一次ASYNC分发,如果你的Filter没有把ASYNC加入DispatcherType,异步执行阶段Filter不会参与。那Filter里统计耗时可能就不包含了真正的异步业务时间。需要精确统计时,把DispatcherType.ASYNC也加进去,但要小心OncePerRequestFilter避免重复执行。

5.5 过滤器真出问题时,怎么快速定位

我排查Filter问题时有个固定套路:先看日志里Filter有没有被打印“before”,确认它是否执行;再看控制台的启动日志里有没有FilterRegistrationBean相关的注册信息;最后检查URL pattern和dispatcherType。如果Filter压根没跑,优先怀疑URL pattern写错或者@WebFilter没有纳入扫描范围;如果跑了但顺序不对,查注册方式是否混用。

下面这个表格可以作为速查参考:

现象可能原因处理办法
Filter完全不执行url pattern写错、类别没有被扫描到、DispatcherType不匹配检查addUrlPatterns、@ServletComponentScan、setDispatcherTypes
执行两次@Component和FilterRegistrationBean重复注册、FORWARD触发两次统一注册方式,或用OncePerRequestFilter
注入Service为nullFilter用new创建,未被Spring管理构造函数注入或让Filter成为Spring Bean
顺序不对@Order和setOrder混用收敛到一个配置类统一控制
请求体读不到InputStream被提前消费用CachedBodyHttpServletRequest包装

6. 项目落地时的性能注意点与最佳实践

Filter是个灵活位置,但在性能上也容易踩雷。因为它在Spring MVC入口之前,任何一个阻塞操作都会挡住大量请求。

6.1 Filter里别做重活

Filter适合轻量级检查,不适合做重量级IO。比如在Filter里同步调远程接口验Token,每次请求都要等网络往返,并发一高,Servlet工作线程很容易被占满。我见过一个项目把用户信息查询、菜单权限查询全部放进Filter,结果接口耗时从50ms涨到800ms,压测直接打崩。

正确姿势是能缓存就缓存,能异步就异步。Token验签本身是CPU计算,很快;查数据库拿完整用户信息就不适合放在Filter同步链路里。可以只在Filter里解析出userId放到请求属性,Controller需要完整信息时再查。

6.2 日志和上下文记得及时清理

如果用Filter往ThreadLocal里放了用户上下文,chain.doFilter之后一定要在finally里清除。线程池会复用线程,ThreadLocal不清空,下一个请求可能拿到上一个用户的身份,这是很严重的越权隐患。我习惯把try-finally作为Filter的标准骨架,不管业务逻辑怎么变,清理逻辑绝不丢。

6.3 依赖注入用构造函数,别在Filter里乱new

很多老教程教你Filter里直接new Service(),这在Spring Boot项目里是反面教材。Filter需要什么依赖就声明在构造函数里,由Spring注入。这样做的好处是单元测试也能轻松构造Filter对象,传个Mock依赖进去就完事。

6.4 最后一点个人体会

Filter这类组件看起来基础,但基础不代表简单。它最大的价值是提供了一个“在框架入口之前统一拦截”的机会,用好了,鉴权、日志、安全Header、字符编码这些事情都能收敛到一处,代码不会散落到每个Controller里。用不好,则是重复注册、顺序混乱、依赖注入失效这些隐蔽问题的温床。

我接手的老项目里有一条铁律:过滤器的注册配置必须集中在同一个配置类,Filter类本身不负责决定自己注册到哪个路径、排什么顺序,注册规则全部外置。这样新人接手时,只需要看一个文件就能了解系统的过滤链路。如果你现在还在各个类上到处贴Filter注解,建议重构一下,把所有注册逻辑收拢起来。这个改动带来的维护收益,比你想的大得多。

RELATED READING

延伸阅读

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