
前几天帮人排查一个迁移过来的老项目对方在 Spring Boot 里加了个全局请求耗时统计Filter 里直接Autowired一个业务 Service编译能过、启动不报错一跑就是空指针后来改成实现FilterRegistrationBean的方式注入又冒出来“同一个请求被统计两次”的新问题。这两个坑的根子都在同一个地方Spring Boot 里的 Servlet 三大组件Servlet、Filter、Listener怎么注册、由谁实例化、生命周期归谁管和以前在web.xml里写几行配置完全不是一回事。这篇就顺着“SpringBoot web开发 - 注册 Servlet 三大组件”这个主题把内嵌容器时代组件注册的两条路线拆开讲透一条是RegistrationBean的编程式注册一条是ServletComponentScan配三个注解的声明式注册。我会把每一步“为什么要这么写”讲明白也把自己和身边人实测中踩到的顺序失控、重复注册、注入失败这些具体问题摆出来。适合已经能跑通 Spring Boot Web 项目、但一碰到“要挂个原生 Servlet/Filter”就发怵的同学也适合正准备把老系统往 Spring Boot 上搬、需要保留原有 Filter 逻辑的开发者。1. 内嵌容器上位之后组件注册的入口到底变了什么1.1 从 web.xml 到 ServletContextInitializer 的交接在传统 Servlet 容器时代注册三大组件是一件非常“声明式”的事web.xml里写servlet、filter、listener三个标签容器启动时读文件、反射实例化、按filter-mapping顺序拼成链、按load-on-startup决定初始化时机。整套规则由容器实现开发者只负责填表。Spring Boot 选择了另一条路把 Tomcat、Jetty、Undertow 内嵌进应用进程容器不再由外部启动而是由 Spring 容器在refresh()过程中主动拉起。这一步的交接点就是ServletContextInitializer接口——内嵌容器创建ServletContext之后会回调注册进来的所有ServletContextInitializer由它们向ServletContext里塞组件。Spring Boot 自己内置了一堆实现比如DispatcherServletRegistrationBean负责注册 Spring MVC 的前端控制器FilterRegistrationBean的父类AbstractFilterRegistrationBean负责注册编码过滤器等等。理解这一点非常关键你在 Spring Boot 里注册的每一个组件最终都是通过某个ServletContextInitializer写进ServletContext的。RegistrationBean家族本质上就是官方给你留的“标准填表口”而WebServlet、WebFilter、WebListener那套注解则是 Spring Boot 额外提供的一个后处理器替你把这些注解翻译成ServletContextInitializer。两条路线殊途同归只是中间多绕了一层。所以很多时候你会看到两种写法都能跑通但它们的实例化主体、注册时机、可配置粒度完全不同。选择哪条路取决于你要不要精细控制顺序、要不要让组件参与 Spring 的依赖注入。1.2 两条注册路线的能力边界对照先把两条路线的差异摊开后面所有细节都是围着这张表展开的。对比维度RegistrationBean 编程式ServletComponentScan 注解式组件实例由谁创建你自己new或从 Spring 容器取 Bean 传入容器通过反射创建不受 Spring 管理能否注入 Spring Bean传入的是 Spring Bean 时可以不可以Autowired会得到 nullFilter 执行顺序setOrder()精确可控不可控按扫描到的类名顺序URL 映射方式addUrlMappings()可多次追加注解urlPatterns属性写死是否需要额外开启扫描不需要Configuration类里声明即可必须显式加ServletComponentScan适用阶段新代码、需要精细控制老代码迁移、注解已经在别处写好这张表里最容易被忽略的是第二行。很多人以为“写个 Filter 类加上WebFilterSpring 会帮我管理”实际上容器是拿反射newInstance出来的对象Spring 上下文和它毫无关系。你以为的依赖注入实际拿到的是 null。1.3 什么情况下必须走组件注册这条路Spring MVC 的HandlerInterceptor其实已经能覆盖大部分拦截需求为什么还要碰原生 Filter我总结下来有三类场景绕不开。第一类是需要包住整个请求-响应周期的逻辑。拦截器在DispatcherServlet内部执行静态资源请求根本进不去而 Filter 挂在DispatcherServlet之前/*一挂就是全量覆盖。做统一的请求体解密、响应体压缩、耗时打点只能放在 Filter 层。第二类是需要和 Servlet 规范特性打交道的逻辑。比如读取multipart之前的原始字节流、处理sendError之后的兜底、监听HttpSession的创建与销毁。这些能力只有 Servlet 层的组件能提供。第三类是老系统平滑迁移。原有项目里一堆Filter、Listener承担着会话管理、权限预校验、启动预热等职责直接扔掉重写风险太大最省事的方案就是把它们原样挂到 Spring Boot 里。2. RegistrationBean 家族把组件当成 Bean 交给容器托管2.1 ServletRegistrationBean 的构造逻辑与映射规则先看 Servlet 这条。ServletRegistrationBean的构造器接收一个HttpServlet实例和一组 URL 映射它的职责是把这两个东西打包在容器回调时调ServletContext.addServlet()再调addMapping()。Configuration public class NativeComponentConfig { Bean public ServletRegistrationBeanEchoServlet echoServlet() { ServletRegistrationBeanEchoServlet bean new ServletRegistrationBean(new EchoServlet()); bean.setName(echoServlet); bean.addUrlMappings(/echo/*); bean.setLoadOnStartup(1); return bean; } }这里有三处参数值得逐个说清楚。setName不设也能跑但强烈建议设置。不设的时候默认取类的全限定名一旦你后面用ServletContext.getServletRegistration(xxx)去查找或者两个不同包下恰好有同名类就会撞车。我在一个多模块项目里就见过两个模块各自定义了一个AuthServlet全限定名不同所以没报错但线上排查时按名字找不到浪费了半小时。setLoadOnStartup(1)决定这个 Servlet 在容器启动时是否立即初始化。默认值是-1意思是第一次请求才实例化。如果你在init()方法里做了比较重的预热比如加载本地字典、建立连接池建议设成正数把耗时提前到启动阶段暴露出来。反过来如果这个 Servlet 只是低频使用的工具接口保持-1能让启动更快。addUrlMappings(/echo/*)用的是 Servlet 规范的路径匹配语法/echo/*是目录匹配/echo是精确匹配*.do是扩展名匹配。这里有个反直觉的点精确匹配的优先级高于路径匹配路径匹配高于扩展名匹配而DispatcherServlet默认映射的/是最低优先级的默认映射。正因为如此你注册的/echo能把请求从 Spring MVC 手里抢过来两者可以共存而不冲突。2.2 FilterRegistrationBean 的 order 与过滤器链拼接顺序Filter 这条比 Servlet 复杂难点在顺序。Bean public FilterRegistrationBeanTraceFilter traceFilter() { FilterRegistrationBeanTraceFilter bean new FilterRegistrationBean(); bean.setFilter(new TraceFilter()); bean.setName(traceFilter); bean.addUrlPatterns(/*); bean.setOrder(100); return bean; }setOrder的数值语义是越小越先进入。请求进来时按 order 升序串成链响应回来时按相反顺序退出。这个方向性很容易记混我一般用“剥洋葱”来记外层先进后出内层后进先出。Spring Boot 内部已经注册了一批 Filter它们的 order 是固定的常量想让自己的 Filter 处在正确位置就得知道这些“邻居”在哪Filter默认 order 量级作用OrderedCharacterEncodingFilter最高优先级最前统一请求响应编码OrderedFormContentFilter靠前把 PUT/PATCH 的表单体转成参数OrderedRequestContextFilter靠前把请求绑定到当前线程你自定义的 Filter默认LOWEST_PRECEDENCE取决于业务springSecurityFilterChain负数区间非常靠前安全过滤链看到问题了吗如果你自定义一个解析请求参数、处理中文的 Filter却忘了设 order它会排到最后执行时编码过滤器早就跑完了你在 Filter 里getParameter()拿到的可能是乱码。这不是罕见情况我在三四个项目里都见过类似的排查记录。注意FilterRegistrationBean不设 order 时默认是Ordered.LOWEST_PRECEDENCE也就是排在所有显式指定了 order 的过滤器之后只比同为零优先级的兄弟靠前。业务 Filter 一定要显式写 order。2.3 ServletListenerRegistrationBean 与容器生命周期的绑定时机Listener 这条写法最简单坑却最深。Bean public ServletListenerRegistrationBeanAppLifecycleListener lifecycleListener() { ServletListenerRegistrationBeanAppLifecycleListener bean new ServletListenerRegistrationBean(new AppLifecycleListener()); bean.setOrder(1); return bean; }ServletContextListener的contextInitialized回调发生在内嵌容器创建ServletContext的时刻。在 Spring Boot 的启动序列里这个时刻早于ApplicationContext的完全就绪。也就是说如果你在contextInitialized里想直接拿到业务 Bean基本拿不到或者拿到一个还没初始化完的“半成品”。我见过最典型的一次翻车是这样的某人在contextInitialized里给一个静态字段赋了个 Spring Bean然后在 Controller 里用这个静态字段本地单体测试全过一打包部署到容器里就空指针——因为本地跑的是 IDE 里的启动类启动顺序碰巧对上了。绕开这个问题的做法有两个。稳妥的是在监听器里只做不依赖业务上下文的事比如打印启动信息、注册 JMX、写一个启动标记文件。如果非要拿 Bean用ApplicationListenerContextRefreshedEvent替代或者从ServletContext里取WebApplicationContext并加判空兜底。这两种写法都不是“更优雅”而是“更符合时序”。3. ServletComponentScan 这条路线究竟扫描了什么3.1 三个注解的生效前提与扫描范围WebServlet、WebFilter、WebListener这三个注解本身是 Servlet 规范定义的Spring Boot 只是提供了一个扫描器去识别它们。开启扫描的方式是在启动类或者任意配置类上加ServletComponentScan。SpringBootApplication ServletComponentScan public class WebApplication { public static void main(String[] args) { SpringApplication.run(WebApplication.class, args); } }不写这一行三个注解就和装饰品一样容器完全无视。这个前提我在初学阶段反复栽过明明加了WebServlet(/demo)访问 404翻遍文档才发现少了个注解。扫描范围由ServletComponentScan的value或basePackages属性决定不指定时默认取被标注类所在包及其子包。这个默认值带来的问题在多模块项目里特别明显启动类在com.example.app你写在com.example.common.web下的 Filter 就扫不到。表现形式是“注解全对、代码全对、就是没生效”排查起来极其费神。我的建议是显式写清楚扫描包宁可多写一行ServletComponentScan(basePackages {com.example.app.web, com.example.common.web})3.2 注解属性里那几个容易写错的位置WebServlet的映射属性名字是urlPatterns或者其别名value两个都是数组。有人图省事写成WebServlet(/a, /b)编译直接报错因为这两个属性不能同时出现也只能有一个。正确写法是WebServlet(urlPatterns {/a, /b})。WebFilter有个坑urlPatterns不写的话Filter 在部分容器实现里会被注册成“对所有请求生效”在另一些实现里完全不注册。行为依赖容器版本写代码时不该赌这个。显式写urlPatterns /*或者具体的路径前缀比什么都有说服力。WebListener没有属性挂在类上即可但类必须实现ServletContextListener、ServletRequestListener、HttpSessionListener这几个接口之一否则注册上去也不会被调用。3.3 两条路线混用时究竟会发生什么这是实战里最容易出事的组合。假设你有一个TraceFilter类既加了WebFilter(urlPatterns /*)又在配置类里写了一个FilterRegistrationBean返回new TraceFilter()。结果不是“后者覆盖前者”而是两个独立实例都被注册进容器请求会依次经过两次。表现是日志打两遍、耗时数字翻倍、如果里面有 incr 操作数据库还会写两份。为什么会这样注解路线的处理逻辑是扫描到类 → 反射创建实例 → 包装成FilterRegistrationBean。你自己写的那个Bean也会被当作一个ServletContextInitializer注册。两条路各自独立走到容器容器只管往里加不存在去重。排查这种现象的方法很简单在启动日志里搜Mapping filtersMapping filters: traceFilter urls[/*], characterEncodingFilter urls[/*] ...如果同一个 Filter 名字出现了两次通常一个是类全名一个是你设的 name基本就是重复注册了。判断标准也很清晰同一个组件只选一条路线。要么全注解要么全编程式。混着用的人往往觉得“双重保险”实际上是把两倍的风险装进了同一个容器。4. 跑通之后才会撞上的三类问题与排查链路4.1 Filter 顺序失控从现象到定位的完整过程先说一个我实际处理过的案例。现象是某个接口返回的中文变成了乱码但其他接口正常且只在生产环境出现。本地无论怎么压测都复现不了。第一步确认是不是编码过滤器没生效。检查启动日志里的 filter 映射characterEncodingFilter在说明注册成功。第二步怀疑 order。项目里有三个自定义 Filter一个做签名校验order 设为 2一个做日志埋点没设 order一个做响应包装order 设为 10。签名校验里有一段逻辑在失败时response.getWriter().write(...)直接写了中文错误信息。这个 Filter 的 order 是 2比编码过滤器的优先级还靠前此时response的字符编码还没被设置写进去就是乱码。根因就这么简单签名校验应该在编码之后执行但 order 排到了编码之前。修复方案是把它调到编码过滤器之后比如改成Ordered.HIGHEST_PRECEDENCE 10。同时给那个没设 order 的日志 Filter 补上Ordered.LOWEST_PRECEDENCE - 100让它在业务逻辑之后、兜底之前执行。这个案例的排查链路值得记下来看启动日志确认 Filter 都注册成功排除“压根没生效”看每个 Filter 的 order 值排除“顺序错”在 Filter 里打日志确认执行次序排除“日志里看到的和实际执行的不一致”定位到具体哪个 Filter 在什么阶段写了响应跳过前三步直接改代码通常要返工。4.2 组件注册两遍的三种典型成因除了前面说的注解 Bean 混用还有两种更隐蔽的重复注册。第一种是继承关系导致的扫描重复。父类加了WebFilter子类继承了父类但没加注解扫描器在父类所在包和子类所在包分别扫一次产生两个注册。这种情况在分层架构里很常见BaseFilter放在公共模块AuthFilter放在业务模块两个包都在扫描范围内。第二种是**ServletComponentScan写了两次**。有人把它加在启动类上又在某个Configuration类上加了一次两次的扫描范围有重叠同一个类被两个后处理器处理结果同样是两份。表现形式都是“同一个逻辑执行两次”。如果你在里面做过幂等相关的操作比如往 Redis 里写计数器会看到数字莫名翻倍。我一般会在 Filter 的init方法里打一行带hashCode()的日志启动时看一眼有几个不同的实例就能判断。注意排查重复注册时不要只看 Bean 定义的数量。注解路线生成的注册并不是普通的 Spring Beancontext.getBeanDefinitionNames()里看不到它们必须看启动日志或者ServletContext里的注册信息。4.3 拦截范围超出预期静态资源与错误页Filter 挂在/*上意味着它不只拦 Controller还拦静态资源、错误页转发、内部 forward/include。这带来两类典型问题。一类是性能。如果 Filter 里做了重操作查库、解析大 Body而页面又引了几十个静态资源每个资源都要走一遍 Filter首屏时间会被拉长。解决办法是用shouldNotFilter语义OncePerRequestFilter提供或者手动判断request.getRequestURI()做白名单把/static、/favicon.ico、/actuator放过去。另一类是行为异常。原生Filter在内部转发时会再次执行比如 Controller 里request.getRequestDispatcher(/error).forward()转发到错误页时 Filter 又跑了一遍如果里面有“只允许执行一次”的逻辑比如扣减库存就会出问题。Spring 提供的OncePerRequestFilter通过一个 request attribute 标记解决了这个问题但只对同一个请求内的多次 dispatcher 生效。public class TraceFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String uri request.getRequestURI(); if (uri.startsWith(/static/) || uri.equals(/favicon.ico)) { chain.doFilter(request, response); return; } long start System.nanoTime(); try { chain.doFilter(request, response); } finally { long costMs (System.nanoTime() - start) / 1_000_000; System.out.println(request.getMethod() uri - costMs ms); } } }5. 一个可以照着抄的完整落地示例5.1 需求边界与目录划分需求很朴素给某个老系统加一个/echo接口用于健康探测不走 Spring MVC加一个全量请求耗时埋点加一个启动时打印环境信息的监听器。三个组件覆盖三种类型正好把两条路线都摸一遍。目录这样划src/main/java/com/example/app ├── WebApplication.java // 启动类 ├── config │ └── NativeComponentConfig.java // 编程式注册 └── web ├── EchoServlet.java ├── TraceFilter.java └── AppLifecycleListener.java把组件放在web子包里而不是和启动类平级是为了后面演示扫描范围时对照方便。5.2 三段组件的具体实现Servlet 用最精简的写法只输出路径和线程名方便观察请求被哪个线程处理public class EchoServlet extends HttpServlet { Override public void init() { System.out.println([EchoServlet] init, instance this.hashCode()); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(text/plain;charsetUTF-8); resp.getWriter().write(uri req.getRequestURI() , thread Thread.currentThread().getName() , instance this.hashCode()); } }注意init()里打了hashCode()这就是判断有没有被实例化两次的手段。Filter 按前面说的继承OncePerRequestFilterpublic class TraceFilter extends OncePerRequestFilter { private static final SetString SKIP Set.of(/favicon.ico); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String uri request.getRequestURI(); if (SKIP.contains(uri) || uri.startsWith(/static/)) { chain.doFilter(request, response); return; } long start System.nanoTime(); try { chain.doFilter(request, response); } finally { long ms (System.nanoTime() - start) / 1_000_000; System.out.println([Trace] request.getMethod() uri status response.getStatus() cost ms ms); } } }监听器只做无依赖的事public class AppLifecycleListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { ServletContext ctx sce.getServletContext(); System.out.println([Lifecycle] contextPath ctx.getContextPath() , serverInfo ctx.getServerInfo()); } Override public void contextDestroyed(ServletContextEvent sce) { System.out.println([Lifecycle] destroyed); } }三个类都不加任何WebXxx注解全部走编程式注册这样顺序和实例都由配置类说了算。5.3 配置类与实测输出对照Configuration public class NativeComponentConfig { Bean public ServletRegistrationBeanEchoServlet echoServlet() { ServletRegistrationBeanEchoServlet bean new ServletRegistrationBean(new EchoServlet()); bean.setName(echoServlet); bean.addUrlMappings(/echo/*); bean.setLoadOnStartup(1); return bean; } Bean public FilterRegistrationBeanTraceFilter traceFilter() { FilterRegistrationBeanTraceFilter bean new FilterRegistrationBean(); bean.setFilter(new TraceFilter()); bean.setName(traceFilter); bean.addUrlPatterns(/*); bean.setOrder(Ordered.LOWEST_PRECEDENCE - 100); return bean; } Bean public ServletListenerRegistrationBeanAppLifecycleListener lifecycleListener() { ServletListenerRegistrationBeanAppLifecycleListener bean new ServletListenerRegistrationBean(new AppLifecycleListener()); bean.setOrder(1); return bean; } }启动后访问curl http://localhost:8080/echo/ping预期输出[Lifecycle] contextPath, serverInfoApache Tomcat/10.1.x [EchoServlet] init, instance1837462 [Trace] GET /echo/ping status200 cost1ms uri/echo/ping, threadhttp-nio-8080-exec-1, instance1837462这里有几个观察点值得细看。[EchoServlet] init在启动阶段就出现了说明loadOnStartup(1)生效把它改成-1后这行会挪到首次请求时才打印。[Trace]那行的status是 200 而不是 0说明 Filter 的finally是在chain.doFilter返回之后执行的此时响应状态已经被 Servlet 写好了。如果你把打印放在chain.doFilter之前拿到的会是默认值 200区分不出真实业务状态这一点在做错误率统计时特别关键。thread是http-nio-8080-exec-1说明请求由 Tomcat 的工作线程处理和 Spring MVC 用的是同一批线程池所以 Filter 里做阻塞操作同样会拖慢整个线程池。访问一个不存在的路径/echo/nothingEchoServlet也会接到请求因为/*是目录匹配doGet里没有分支判断直接返回 200。这个行为在真实项目里要加兜底否则探测接口会把所有路径都吞掉。6. 几个不写进文档但一定会遇到的实际问题6.1 命名空间前缀与版本对应关系Spring Boot 2.x 用的是javax.servlet.*3.x 全面切到jakarta.servlet.*。这不是改个包名那么简单——如果你项目依赖里混入了旧版本的 Servlet API会出现“Filter接口有两个实现哪个都不对”的诡异编译错误错误信息里一堆同名类看着像环境坏了。判断方法很直接看pom.xml里的 Spring Boot 版本2.x 对应javax3.x 对应jakarta。引入第三方库时要检查它编译时用的是哪个命名空间一个还在用javax的Filter库放进 3.x 项目里运行时ClassNotFoundException跑不掉。我个人的处理习惯是在父 pom 里显式锁一个 servlet-api 的版本并且加provided作用域让内嵌容器自带的实现优先避免依赖传递把版本拉乱。6.2 让 Filter 参与依赖注入的正确姿势回到开头那个空指针。注解路线下Filter 实例由容器反射创建Autowired一定为 null无解。编程式路线的关键差别在于传给setFilter的那个对象是不是 Spring 容器里的 Bean。正确写法是把 Filter 声明成 Bean然后注入进配置方法Component public class TraceFilter extends OncePerRequestFilter { Autowired private TraceService traceService; // ... } Configuration public class NativeComponentConfig { Bean public FilterRegistrationBeanTraceFilter traceFilter(TraceFilter filter) { FilterRegistrationBeanTraceFilter bean new FilterRegistrationBean(); bean.setFilter(filter); bean.setName(traceFilter); bean.addUrlPatterns(/*); bean.setOrder(Ordered.LOWEST_PRECEDENCE - 100); return bean; } }注意setFilter(filter)传的是方法参数不是new。这一字之差决定了Autowired能不能生效。同理Servlet 和 Listener 也一样想要注入就把实例交给 Spring 管理。代价是 Filter 变成单例 Bean 后要注意线程安全。不要在里面存可变的实例字段doFilter里所有状态都用局部变量。这条规则和 Controller 一样但 Filter 更容易被忽略因为它“看起来不像是 Spring 管的”。6.3 测试环境下的验证方式内嵌容器下SpringBootTest(webEnvironment RANDOM_PORT)会真的拉起 TomcatFilter、Servlet、Listener 都会真实注册可以用TestRestTemplate直接发请求验证。但要注意一个细节单元测试里如果用的是webEnvironment MOCK容器根本没启动ServletRegistrationBean不会被执行Filter 也不会被调用。这时候测试“通过”只能说明 Bean 定义存在不说明组件注册成功。我见过有人拿 MOCK 模式的测试结果当佐证上线后发现 Filter 根本没跑。更靠谱的验证手段是在打包之后做一次真实启动看日志里的三行输出Mapping filters、Mapping servlets、监听器打印的上下文信息。三行都在才说明注册链路完整走通了。这三个检查点我一般会写进项目的上线检查清单比任何自动化测试都直白。