资讯动态

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

发布时间:2026/10/13 13:02:33 来源:尧图企业网站定制
过滤器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.0Filter等接口在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 FilterRegistrationBeanLoginCheckFilter loginCheckFilterRegistration(LoginCheckFilter filter) { FilterRegistrationBeanLoginCheckFilter registration new FilterRegistrationBean(filter); registration.addUrlPatterns(/api/*); registration.setName(loginCheckFilter); registration.setOrder(10); return registration; } }这里通过构造函数把TokenService传给FilterFilter本身由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注册FiltersetOrder()的值就是最终顺序。如果你是Component自动注册的FilterSpring 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;charsetUTF-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/*送进这个FilterFilter内部再放行公开路径。注意返回错误响应时一定要设置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 让请求体可以重复读取的RequestWrapperHttpServletRequest的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或DeferredResultServlet会发起一次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注解建议重构一下把所有注册逻辑收拢起来。这个改动带来的维护收益比你想的大得多。

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

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

免费获取报价 →
↑