前几天帮人排查一个迁移过来的老项目,对方在 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会得到 null |
| Filter 执行顺序 | setOrder()精确可控 | 不可控,按扫描到的类名顺序 |
| URL 映射方式 | addUrlMappings()可多次追加 | 注解urlPatterns属性,写死 |
| 是否需要额外开启扫描 | 不需要,@Configuration类里声明即可 | 必须显式加@ServletComponentScan |
| 适用阶段 | 新代码、需要精细控制 | 老代码迁移、注解已经在别处写好 |
这张表里最容易被忽略的是第二行。很多人以为“写个 Filter 类,加上@WebFilter,Spring 会帮我管理”,实际上容器是拿反射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 ServletRegistrationBean<EchoServlet> echoServlet() { ServletRegistrationBean<EchoServlet> 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 FilterRegistrationBean<TraceFilter> traceFilter() { FilterRegistrationBean<TraceFilter> 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 ServletListenerRegistrationBean<AppLifecycleListener> lifecycleListener() { ServletListenerRegistrationBean<AppLifecycleListener> 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,用ApplicationListener<ContextRefreshedEvent>替代,或者从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 filters:
Mapping 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 Bean,
context.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;charset=UTF-8"); resp.getWriter().write("uri=" + req.getRequestURI() + ", thread=" + Thread.currentThread().getName() + ", instance=" + this.hashCode()); } }注意init()里打了hashCode(),这就是判断有没有被实例化两次的手段。
Filter 按前面说的继承OncePerRequestFilter:
public class TraceFilter extends OncePerRequestFilter { private static final Set<String> 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 ServletRegistrationBean<EchoServlet> echoServlet() { ServletRegistrationBean<EchoServlet> bean = new ServletRegistrationBean<>(new EchoServlet()); bean.setName("echoServlet"); bean.addUrlMappings("/echo/*"); bean.setLoadOnStartup(1); return bean; } @Bean public FilterRegistrationBean<TraceFilter> traceFilter() { FilterRegistrationBean<TraceFilter> bean = new FilterRegistrationBean<>(); bean.setFilter(new TraceFilter()); bean.setName("traceFilter"); bean.addUrlPatterns("/*"); bean.setOrder(Ordered.LOWEST_PRECEDENCE - 100); return bean; } @Bean public ServletListenerRegistrationBean<AppLifecycleListener> lifecycleListener() { ServletListenerRegistrationBean<AppLifecycleListener> bean = new ServletListenerRegistrationBean<>(new AppLifecycleListener()); bean.setOrder(1); return bean; } }启动后访问curl http://localhost:8080/echo/ping,预期输出:
[Lifecycle] contextPath=, serverInfo=Apache Tomcat/10.1.x [EchoServlet] init, instance=1837462 [Trace] GET /echo/ping status=200 cost=1ms uri=/echo/ping, thread=http-nio-8080-exec-1, instance=1837462这里有几个观察点值得细看。
[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/nothing,EchoServlet也会接到请求,因为/*是目录匹配,doGet里没有分支判断,直接返回 200。这个行为在真实项目里要加兜底,否则探测接口会把所有路径都吞掉。
6. 几个不写进文档但一定会遇到的实际问题
6.1 命名空间前缀与版本对应关系
Spring Boot 2.x 用的是javax.servlet.*,3.x 全面切到jakarta.servlet.*。这不是改个包名那么简单——如果你项目依赖里混入了旧版本的 Servlet API,会出现“Filter接口有两个,实现哪个都不对”的诡异编译错误,错误信息里一堆同名类,看着像环境坏了。
判断方法很直接:看pom.xml里的 Spring Boot 版本,2.x 对应javax,3.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 FilterRegistrationBean<TraceFilter> traceFilter(TraceFilter filter) { FilterRegistrationBean<TraceFilter> 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)会真的拉起 Tomcat,Filter、Servlet、Listener 都会真实注册,可以用TestRestTemplate直接发请求验证。
但要注意一个细节:单元测试里如果用的是webEnvironment = MOCK,容器根本没启动,ServletRegistrationBean不会被执行,Filter 也不会被调用。这时候测试“通过”只能说明 Bean 定义存在,不说明组件注册成功。我见过有人拿 MOCK 模式的测试结果当佐证,上线后发现 Filter 根本没跑。
更靠谱的验证手段是在打包之后做一次真实启动,看日志里的三行输出:Mapping filters、Mapping servlets、监听器打印的上下文信息。三行都在,才说明注册链路完整走通了。这三个检查点我一般会写进项目的上线检查清单,比任何自动化测试都直白。