做过Spring Web项目的后端几乎都遇到过这样一个现象过滤器逻辑明明写得挺正常跑起来却发现同一个请求被触发了两次日志重复打、统计重复加、请求体明明读过一次再进Controller就空了。这种场景下Spring提供的OncePerRequestFilter是一个绕不开的基类它专门解决“同一个请求被过滤器多次执行”的问题也是Spring Security很多认证过滤器的父类。这篇文章从我自己的使用经验和踩坑记录出发把OncePerRequestFilter的原理、接入方式、典型实战和常见问题都梳理一遍适合刚接触Servlet过滤器的初级后端也适合想在Spring Boot里精细控制过滤链的执行顺序、异步请求和请求体缓存的中级开发。1. 为什么需要OncePerRequestFilter普通过滤器为何会重复执行1.1 一次请求被过滤器执行多次的正常场景很多初学者有一个错觉一次HTTP请求进来Servlet容器调用过滤器链一个过滤器从头到尾只执行一次。这个说法在“最简单的情况下”成立但Servlet规范里请求的流转远比这复杂。一个请求从进入容器到最终返回中间可能经历多次dispatch。最常见的是服务端内部跳转forward和包含include比如Servlet跳转到JSP、Spring MVC抛出异常后转发到错误页。每次dispatch容器都认为这是一个新的“请求处理阶段”它会重新进入过滤链。换句话说普通Filter面对一次用户请求可能有两次甚至多次执行机会。另一个典型场景是异步处理。Servlet 3.0引入异步支持后真正耗时的业务逻辑被放到异步线程里执行主线程先返回容器容器在异步任务完成后再次执行dispatch。这个过程同样会再次触发过滤链。还有错误处理dispatch比如404页面被转发到某个ErrorController时过滤链也会重新走一遍。如果这些场景下用了普通Filter日志统计、耗时记录、认证检查这类比较“敏感”的逻辑就会出问题。你写一个计数器想统计请求数用户刷新一次页面计数可能加了三次数据完全不靠谱。1.2 普通Filter在这些场景下的真实表现我最初做前后端分离项目时写过这样一个过滤器记录每个请求的URI、方法和耗时打一条访问日志。听起来很常规但我发现每当业务方法里发生了一次内部forward日志就多出一条重复记录而且耗时经常算得很小。因为第二次进入过滤器时只是转发后的资源加载过程真正耗时的业务逻辑已经跑完了。认证和权限校验类过滤器更严重。如果过滤逻辑里有一句“从Token解析用户身份并放入上下文”普通Filter在重复执行时第二次执行会覆盖第一次的上下文内容如果上下文没有清理又容易造成用户身份串号。Spring Security在设计时就意识到这一点所以它内部大量过滤器都继承了OncePerRequestFilter目的就是在一个请求的整个生命周期里核心处理逻辑只跑一次。1.3 OncePerRequestFilter的解决方案一次请求一套标识OncePerRequestFilter的做法很朴素在请求对象上做一个标记。它第一次进入过滤器时把标记放到当前的HttpServletRequest上后续无论这个请求经过多少次dispatch再次进入过滤器时先检查标记发现标记已经存在就把当前请求直接放行给FilterChain不再执行核心处理逻辑。这里的标记不是放在内存静态变量里而是挂在request的attribute上。因为同一个HTTP请求实例在每次dispatch时都会被传递所以标记天然跟着请求走线程安全也不会成为问题。类名里的OncePerRequest准确理解应该是“每个请求的每次进入过滤链只执行一次核心处理”而不是“整个请求生命周期只在最外层执行一次”。2. 源码层面拆解OncePerRequestFilter到底怎么工作2.1 核心机制标记名与请求属性OncePerRequestFilter在Spring源码里是一个抽象类关键代码并不长。它维护了一个标记属性名默认由过滤器自身名字加上固定后缀组成public static final String ALREADY_FILTERED_SUFFIX .FILTERED; private String getAlreadyFilteredAttributeName() { String name getFilterName(); if (name null) { name getClass().getName(); } return name ALREADY_FILTERED_SUFFIX; }如果你定义了一个叫requestLogFilter的Bean那么它的标记名默认就是requestLogFilter.FILTERED。在调试日志里看到这个attribute可以很快定位是哪个过滤器设置的。这个命名设计也提醒了我们同一个过滤器类如果被注册成不同名字它们会拥有不同的标记也就无法互相“防重”。2.2 doFilter和doFilterInternal的分工OncePerRequestFilter实现了Filter接口的doFilter方法并且把它定义成final方法子类不能直接覆写。方法里先判断请求上是否已经有标记有就直接放行没有标记就设置标记再调用子类实现的doFilterInternal方法执行核心逻辑最后在finally块中移除标记。Override public final void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String alreadyFilteredAttributeName getAlreadyFilteredAttributeName(); boolean hasAlreadyFilteredAttribute request.getAttribute(alreadyFilteredAttributeName) ! null; if (hasAlreadyFilteredAttribute) { chain.doFilter(request, response); return; } request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE); try { doFilterInternal(httpRequest, httpResponse, chain); } finally { request.removeAttribute(alreadyFilteredAttributeName); } }这是一段非常典型的模板方法模式代码。父类固定了“防重复执行”的流程子类只需要写核心业务逻辑。理解这个结构很重要因为finally里会移除标记意味着对于一个真正独立的后续dispatch过滤器还是可能再跑的。比如异步请求处理完成后的重新dispatch如果不想让它再执行核心逻辑就需要看下个小节的控制方法。2.3 可重写的关键方法OncePerRequestFilter提供了几个protected方法供子类按需覆写我挑出实际项目里最常用的几个方法作用默认行为doFilterInternal子类核心过滤逻辑抽象方法必须实现shouldNotFilter(HttpServletRequest)判断当前请求是否跳过过滤默认返回false全部过滤shouldNotFilterAsyncDispatch()异步dispatch时是否跳过默认false异步dispatch仍会执行shouldNotFilterErrorDispatch()错误页dispatch时是否跳过默认false错误页分发仍会执行getAlreadyFilteredAttributeName自定义防重标记名默认过滤器名加后缀shouldNotFilter非常实用。比如健康检查、静态资源、明文放行的登录接口都可以在这个方法里判断并返回true让请求直接跳过。Spring Security里的许多过滤器也是通过重写这个方法来控制哪些URL不需要走认证流程的。shouldNotFilterAsyncDispatch和shouldNotFilterErrorDispatch属于进阶用法。如果你在异步任务完成后的dispatch里只是想继续放行请求不希望认证、日志统计逻辑再跑一遍就可以把对应方法覆写为true。3. 三种接入方式从web.xml到Spring Boot自动装配3.1 web.xml时代的FilterRegistration在Spring Boot普及之前Servlet项目里注册过滤器主要靠web.xml的filter和filter-mapping配置。Filter类本身只是一个普通Servlet组件Spring容器里的Bean需要额外获取当时写起来比较笨重。现在新建Spring Boot项目基本不会再用这种方式但如果你接手老项目看到web.xml里有类似配置要注意它注册的Filter不受Spring容器管理Autowired注入的服务可能为null需要借助DelegatingFilterProxy做桥接。3.2 在Spring Boot中声明为BeanSpring Boot环境下最简单的接入方式是把自定义Filter声明成一个Spring Bean最极端直接的做法是在类上标注ComponentComponent public class RequestLogFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { long start System.currentTimeMillis(); try { filterChain.doFilter(request, response); } finally { long cost System.currentTimeMillis() - start; System.out.println(请求 request.getMethod() request.getRequestURI() 耗时 cost ms); } } }Spring Boot的自动配置会把容器里所有实现Filter接口的Bean自动注册到Servlet容器过滤链上。这种方案最省事项目一启动过滤器就生效适合快速做一些全局监控、日志记录。但这种方式有两个问题一是过滤路径默认是/*所有请求都会进来包括静态资源二是过滤器执行顺序需要靠Order注解控制不够直观。如果项目里自定义过滤器比较少就还好一旦过滤器数量多、顺序要求复杂我更推荐用FilterRegistrationBean。3.3 FilterRegistrationBean精准控制用FilterRegistrationBean可以在注册阶段显式指定过滤器的名字、路径匹配方式和顺序代码写在Configuration类里Configuration public class FilterConfig { Bean public FilterRegistrationBeanRequestLogFilter requestLogFilterRegistration(RequestLogFilter filter) { FilterRegistrationBeanRequestLogFilter registration new FilterRegistrationBean(filter); registration.setName(requestLogFilter); registration.addUrlPatterns(/api/*); registration.setOrder(1); return registration; } }注意这里RequestLogFilter类本身不再加Component而是在配置类里通过Bean方法交给Spring管理。如果加了两遍实际效果就是过滤器被注册两次同一个请求会执行两次核心逻辑这个问题我后面还会展开说。addUrlPatterns指定了路径规则setOrder控制了顺序。Spring Boot会把所有FilterRegistrationBean和实现Filter的Bean统一收集起来按order排序后注册进容器order数值越小越靠前执行。4. 实战基于OncePerRequestFilter做一个请求体缓存与日志过滤器4.1 需求拆解与职责划分很多业务系统都有这样一个需求记录每次请求的入参、出参和耗时方便排查线上问题。但如果只用最简单的doFilterInternal直接打印很容易踩两个坑一是请求体只能读一次过滤器里如果把InputStream读完了后面Controller用RequestBody反序列化时拿不到数据二是响应流如果不做包装写出去的内容也没法捕获回来。我这里把需求拆成两层第一层是请求体缓存过滤器负责把原始的body字节缓存下来第二层是日志过滤器负责记录处理状态和耗时。两个过滤器都继承OncePerRequestFilter职责单一后续修改互不影响。4.2 实现请求体缓存包装器解决请求体多次读取问题核心是自己实现一个HttpServletRequestWrapper在包装的时候把body读出来存到内存字节数组里之后每次getInputStream都从字节数组重新创建流。代码大概长这样public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private final byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); this.cachedBody StreamUtils.copyToByteArray(request.getInputStream()); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public boolean isFinished() { return byteStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 同步读取场景下不需要实现 } Override public int read() { return byteStream.read(); } }; } Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), getCharacterEncoding())); } }StreamUtils.copyToByteArray(request.getInputStream())这段代码会消耗掉原始请求的输入流但没关系因为我们从包装器里取数据时用的是cachedBody数组原始流是否消耗不再影响后续读取。Controller中通过RequestBody读取的就是包装器重新创建的流。4.3 把两个过滤器组合起来请求体缓存过滤器先执行它把原始request换成CachedBodyHttpServletRequest然后继续放行。因为OncePerRequestFilter的核心逻辑只在首次dispatch执行内部嵌套的dispatch拿到的依然是同一个包装器后续无论是Controller还是其他Filter读取的都是缓存数组里的字节。日志过滤器在请求体缓存之后执行用来记录接口路径、耗时和响应大小Component public class ApiLogFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingResponseWrapper responseWrapper new ContentCachingResponseWrapper(response); long start System.nanoTime(); try { filterChain.doFilter(request, responseWrapper); } finally { long costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); byte[] responseBody responseWrapper.getContentAsByteArray(); System.out.println(接口 request.getRequestURI() 耗时 costMs ms响应 responseBody.length 字节); responseWrapper.copyBodyToResponse(); } } Override protected boolean shouldNotFilter(HttpServletRequest request) { String path request.getRequestURI(); return path.startsWith(/health) || path.startsWith(/metrics) || path.endsWith(.css) || path.endsWith(.js); } }这里有一个非常关键的操作responseWrapper.copyBodyToResponse()。ContentCachingResponseWrapper会把Controller写入的数据先缓存到它内部如果不调用copyBodyToResponse()把缓存内容复制回真正的响应流客户端的浏览器会收到一个空响应。第一次用这个包装器的时候十个人里至少有五个会在这上面翻车。另外System.nanoTime()比System.currentTimeMillis()更适合计算耗时因为它不受系统时钟调整影响。毫秒级别打日志用currentTimeMillis没问题但做精细耗时分析多用nanoTime。shouldNotFilter方法在这里起到“白名单”作用。健康检查、指标采集这些接口没必要记录业务日志直接跳过避免监控系统刷新一次就刷出一堆噪声日志。4.4 使用FilterRegistrationBean注册并指定顺序因为我希望请求体缓存过滤器先执行、日志过滤器后执行所以注册时应该让缓存过滤器的order更小Configuration public class WebFilterConfig { Bean public FilterRegistrationBeanRequestBodyCachingFilter requestBodyCachingFilter() { FilterRegistrationBeanRequestBodyCachingFilter registration new FilterRegistrationBean(new RequestBodyCachingFilter()); registration.setName(requestBodyCachingFilter); registration.addUrlPatterns(/api/*); registration.setOrder(10); return registration; } Bean public FilterRegistrationBeanApiLogFilter apiLogFilter() { FilterRegistrationBeanApiLogFilter registration new FilterRegistrationBean(new ApiLogFilter()); registration.setName(apiLogFilter); registration.addUrlPatterns(/api/*); registration.setOrder(20); return registration; } }两个过滤器类不要加Component统一交给FilterRegistrationBean管理这样顺序一目了然也不会出现重复注册的情况。5. 执行顺序与配置优先级过滤器顺序问题详解5.1 order数值与执行顺序Spring Boot中多个Filter的执行顺序完全由order决定数值越小越先执行。setOrder的数值可以是正数也可以是负数不同过滤器之间做一个简单的排序即可。这里有个容易误解的点order大的过滤器并不是“更晚处理”而是“更靠近业务Controller”。order10 的缓存过滤器 - order20 的日志过滤器 - Spring Security过滤链如果有 - DispatcherServlet - Controller我在项目里经常见到的错误是两个自定义过滤器都用了Component加上Order但其中一个没有生效。这是因为单纯用Order注解控制Spring Boot自动注册Filter的有效范围和FilterRegistrationBean的order属性不完全一致。多个Filter的注册顺序、实例顺序、排序规则在不同Spring Boot版本里行为有细微差异。稳妥的方案还是把关键顺序的过滤器统一放到FilterRegistrationBean里管理。5.2 与Spring Security过滤链的先后关系Spring Boot集成Spring Security后容器里会注册一个名为springSecurityFilterChain的过滤器它在Spring Boot的默认排序里位置比较靠前order值通常是-100。如果你的自定义过滤器希望在不经过安全认证的情况下也能执行比如记录所有404请求的日志那就必须把自定义过滤器的order设在-100之前也就是小于-100。反过来如果你希望请求先经过认证再进入自定义过滤器就要把自定义过滤器的order设得比-100大。这个优先级设计容易坑到刚接手项目的人。我曾在一个网关类服务里写了一个请求日志过滤器启动后发现登录接口的请求日志完全没打出来排查了半天才意识到登录接口请求在Spring Security认证阶段就被拦下了自定义日志过滤器排在安全链后面压根没机会执行。后来把日志过滤器order改成-200让它最先进入问题才解决。5.3 重复注册是最大的坑这里必须重点提醒同一个过滤器类如果既在类上加了Component又在FilterRegistrationBean里注册就相当于注册了两个相同逻辑的Filter。因为OncePerRequestFilter只挡“同一个请求的重复dispatch”挡不住“两个独立注册实例”所以你会看到一个请求执行两次完整逻辑而且两次执行之间可能还会互相覆盖数据。排查这类问题有一个快速方法在日志里加上过滤器名称比如“requestLogFilter开始执行”同一个请求如果出现两次同名日志先去看FilterRegistrationBean的setName是否和Bean名字重复再看类上有没有Component或WebFilter。Spring Boot应用启动时打印的Filter注册列表里同一个名字出现多次基本就是重复注册了。6. 常见问题速查表与排查心得6.1 过滤器执行两次甚至多次可能原因之一是重复注册这个上面已经说了。还有一个隐蔽原因是用WebFilter注册的同时又被ServletComponentScan扫描到同时Spring Boot又把Filter类型的Bean自动注册了一次。解决方式很简单一个过滤器只保留一种注册途径。原因之二是请求发生了多次dispatch。比如Spring MVC内抛出异常后转发了错误页过滤器核心逻辑就会再次执行。如果你用了OncePerRequestFilter但仍然看到重复执行先检查是不是两个不同实例或者你是不是重写了shouldNotFilterAsyncDispatch和shouldNotFilterErrorDispatch导致标记没生效。6.2 请求体在过滤器里读不到最典型的表现是过滤器里用request.getInputStream()读了一下body然后Controller用RequestBody接收参数时报错或者接收到的对象全是null。原因就是Servlet标准里输入流只能读取一次。解决办法就是我在4.2节里写的CachedBodyHttpServletRequest过滤器在读取body后把字节数组存起来后面所有读取都从缓存数组重新生成流。这里顺便提一个细节自定义包装器里的getReader()和getInputStream()不能混着乱调用。Servlet容器要求这两个方法只能二选一调用过getInputStream()后再调用getReader()会抛IllegalStateException。我的习惯是统一在getInputStream()基础上做实现getReader()内部也调用getInputStream()避免两个方法各自维护状态。6.3 修改响应却看不到效果很多时候你只是想给响应加一个统一响应头、把response状态码改成200、或者把所有响应体包一层加密。直接在过滤器里拿到HttpServletResponse后调用setHeader、setStatus有时会不生效。原因多半是这些操作发生在filterChain.doFilter()之后而响应已经提交给客户端了修改已经来不及。正确的做法是在调用doFilter之前修改响应头如果要做内容加密或统一包装必须使用HttpServletResponseWrapper在Controller写入之前拦截输出。还是那句话包装器用完之后记得调用copyBodyToResponse()否则客户端拿到空body。6.4 异步请求的耗时统计不准记录耗时的过滤器如果用同步思维在doFilterInternal里用System.currentTimeMillis()前后相减遇到Async接口或者Servlet异步处理时统计到的只是“请求被提交到异步线程之前”的时间真正的业务耗时根本没算进去。遇到这种情况我通常会把耗时统计放到异步任务完成后的dispatch阶段或者在异步任务里显式记录业务执行时间比如使用AsyncContext监听器或Spring的WebAsyncManager回调。如果只是粗略统计接口耗时用异步线程内的时间戳更可靠。6.5 过滤器内异常导致响应异常或日志缺失如果doFilterInternal里业务代码抛了异常却没有对应的try-finally处理很可能出现两种情况一是后续的响应包装没执行完客户端拿到不完整的响应二是finally里的日志记录代码没跑线上问题又查不到日志。我写过滤器的习惯是所有后置操作全部放进finally块核心业务代码的异常不吞掉但一定要保证清理和日志逻辑执行。try { filterChain.doFilter(request, responseWrapper); } finally { responseWrapper.copyBodyToResponse(); // 记录日志 }这样即使Controller里抛了异常响应流和日志记录依然能走到排查问题的时候信息不会丢失。6.6 一个值得养成的习惯让过滤器职责单一OncePerRequestFilter给我的最大启发是“模板方法”思路流程骨架统一业务细节子类实现。有些同事喜欢在一个过滤器里同时做登录校验、参数解密、日志记录、数据打点看似省了类数量实际上每次改动都可能影响其他逻辑而且多个过滤器的顺序一旦发生变化行为就不可预期。我现在做项目能拆成多个过滤器的就拆成多个每个过滤器只做一件事。比如“body缓存过滤器”不负责日志“Token校验过滤器”不负责统计“日志过滤器”不负责业务处理。虽然类文件多了但每个过滤器都好测、好维护。调试的时候看到OncePerRequestFilter的标记名也能一眼看出当前执行到哪一层。最后再分享一个小技巧。OncePerRequestFilter的过滤范围是按路径控制的但路径判断逻辑如果写得太长放在每个过滤器里会很冗余。我一般会写一个公共方法把需要放行的路径整理成一组AntPathMatcher规则在shouldNotFilter里统一判断。这样新增一个白名单路径只需要改一处配置所有继承OncePerRequestFilter的过滤器同步生效。保持代码整洁比单纯追求“少写几个类”重要得多。
阅读完成 · 觉得有帮助?