上个月把项目从 Spring Boot 2.6 直接升级到 3.2,对应的 Spring Framework 版本从 5.3 一下拉到了 6.1。本来以为只是改改版本号、换换依赖的事,结果从启动到联调整整折腾了两天,其中一大半时间都耗在 SpringMVC 新版本的“隐藏变化”上。
这次升级遇到的最典型的问题有三个:javax 到 jakarta 的包名迁移导致项目直接起不来;springmvc拦截器注册得好好的,结果就是不生效,登录态直接裸奔;以及各种 404、静态资源放行不对的诡异现象。好在每个问题最后都查到了根因并且解决,标题里敢写“[已解决]”是真的解决了。下面按问题逐个复盘,顺便把 SpringMVC 工作流程里容易混淆的 Filter、HandlerInterceptor、HandlerMapping 之间的关系理一遍。
如果你的项目还在 Spring Boot 2.7 或更低版本、近期准备升级,这篇文章可以当一份避坑清单;如果你已经在 3.x 上遇到了类似问题,可以直接跳到对应小节看结论。
1. 升级前的版本盘点与风险预判
1.1 新版本到底“新”在哪
先说一个容易忽略的事实:Spring Boot 3.x 对应的 Spring Framework 6.x,并不是在 Spring 5 基础上小修小补,而是跨了两代,很多底层假设直接变了。Spring Framework 6 的基线要求是 Java 17+,对所有基于 Servlet 的代码做了 Jakarta EE 9 迁移,默认的路径匹配器也从 AntPathMatcher 换成了 PathPatternParser。也就是说,哪怕你的业务代码一行没改,光是把 spring-boot-starter-web 的版本号从 2.6 改成 3.2,运行时的行为都可能不一样。
这一点特别影响那些“看起来没问题”的配置,尤其是 springmvc拦截器。拦截器的注册 API 本身没变,但拦截器内部的路径匹配规则、过滤链的组装方式都换了底层实现。项目里积累多年的通配符路径表达式,很可能在新版本里含义完全变了。升级前如果没意识到这一点,后面排查拦截器失效问题的时候很容易被带到沟里去。
1.2 踩坑前先把家底盘清
我的建议是,升级前先做一次“家底盘点”。不是说上来就改 pom.xml,而是先确认下面几件事:
- 当前项目是用 spring-boot-starter-web 打包成 jar 运行,还是打成 war 放到外部 Tomcat?外部容器部署的话,容器版本必须至少是 Tomcat 10,因为 Servlet 规范已经换包名了。
- 项目里直接使用了哪些 javax 开头的 API?比如 javax.servlet、javax.validation、javax.annotation。这些在 Spring Boot 3 里全部要换成 jakarta 开头。
- 是否用到第三方库?比如老版本的 Dubbo、Shiro、某个内部封装的 starter。这些库如果还是基于旧的 javax.servlet 编译的,光替换业务代码没用,传递依赖会把旧类重新带进来。
- 是否在代码里大量使用 Ant 风格的通配路径?比如
/api/*.do、/path/**/detail、带正则的路径变量。这类写法在新版本路径匹配器下最容易翻车。
把这些信息列出来,后面升级的时候就能有的放矢。我这次就是因为只关注了版本号,没有提前梳理外部容器和第三方库的兼容性,才在第一步就踩了个大坑。
2. 第一个大坑:javax 变成 jakarta,启动直接失败
2.1 报错现场还原
升级之后第一次启动,还没来得及看到日志里的 Spring Boot Logo,直接抛了一串异常。核心报错是:
java.lang.NoClassDefFoundError: javax/servlet/ServletContext这个错很典型,一看就是运行时找不到 servlet 相关类。因为项目是打成 war 部署到外部 Tomcat 的,而 Tomcat 9 及其之前的版本,Servlet 规范还叫 javax.servlet;Spring Boot 3 和 Spring Framework 6 已经基于 Jakarta EE 9,Servlet API 的包名变成了 jakarta.servlet。两边对不上,容器启动阶段就崩了。
2.2 包名迁移背后的逻辑
这不是 Spring 心血来潮,而是整个 Java EE 生态的变更。Java EE 从 8 升级到 9 时改名为 Jakarta EE,并把所有 API 的包名前缀从 javax 换成了 jakarta。Servlet、Validation、Annotation 这些规范全部受影响。Spring Framework 6 选择了全面跟随 Jakarta EE 9,所以不只是 Spring MVC 的代码要改,所有依赖 Servlet API 的库也得跟着升级。
很多人以为升级 Spring Boot 3 就是换依赖,实际上如果你的工程里遍布import javax.servlet.http.HttpServletRequest,那等于所有涉及 Web 层的代码都要动。这个工作量说大不大,说小也不小,但必须一次做干净。
2.3 替换代码时容易漏掉的地方
我在替换包名时用的是 IDE 的全局替换,直接搜javax.servlet换成jakarta.servlet。这一步很顺利,但紧接着又踩了两个隐蔽的地方。
第一个是 javax.validation。项目里大量使用了@Valid、@NotNull这类校验注解,它们来自 validation-api,包名同样要换:
// 旧写法 import javax.validation.Valid; import javax.validation.constraints.NotNull; // 新写法 import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull;第二个是 javax.annotation。项目里用到了@Resource、@PostConstruct、@PreDestroy,这些注解也迁移到了 jakarta.annotation 下。如果漏掉,同样会在运行期报找不到类。
漏掉这些包名的直接后果是编译能过,但启动时报NoClassDefFoundError或者ClassNotFoundException,而且往往报错位置在某个深层依赖里,排查起来非常费劲。所以我强烈建议替换完成后跑一遍依赖树,检查是否还有传递依赖带入了 javax 系列包:
mvn dependency:tree -Dincludes=javax.servlet:javax.servlet-api如果结果里还有 javax 开头的 servlet 相关依赖,说明某个第三方库还没升级兼容版本,需要手动排除或者升级库本身。这一步很多人容易忽略,但它决定了你的应用在外部容器里能不能正常启动。
3. 第二个大坑:springmvc拦截器“注册了却不生效”
3.1 现象描述
包名迁移解决之后,应用能正常启动了,但紧接着发现一个更隐蔽的问题:登录拦截器完全没生效。我访问一个需要登录才能看的接口,后端直接返回了数据,登录校验逻辑压根没走。
这个问题的隐蔽之处在于,配置类和拦截器类代码一行没改,IDEA 里看依赖也都在,断点打上去,preHandle方法就是进不去。我甚至怀疑过是不是拦截器类没有被 Spring 扫描到,反复检查了@Component注解和包扫描路径,都没问题。
3.2 先回顾 SpringMVC 工作流程再排查
排查这种问题,不能靠猜,最好先回到 SpringMVC 工作流程本身。一个标准的请求处理链路大概是这样的:请求先到达 DispatcherServlet,它根据请求路径去找 HandlerMapping,拿到一个 HandlerExecutionChain,也就是“拦截器链 + Controller 方法”的组合。然后再由 HandlerAdapter 执行这个链路。执行的时候,会先调用拦截器链里的preHandle,如果返回 true 才继续进入 Controller 方法,处理完之后再由postHandle和afterCompletion做收尾。
所以拦截器不生效,无非下面几个环节出了问题:HandlerMapping 没有把请求匹配到 Controller;匹配到了 Controller 但拦截器没有挂上去;拦截器挂上去了但路径规则把它排除掉了;或者拦截器链根本没有被 HandlerAdapter 执行。
对照这个流程,我先看 HandlerMapping。新版本的 SpringMVC 默认使用 PathPatternParser 来做路径匹配,而我的老项目里,Controller 上写的是这样的映射:
@GetMapping("/api/v1/user/detail") public Result userDetail(@RequestParam Long id) { ... }拦截器注册写的是:
registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/*") .excludePathPatterns("/api/login");问题一下浮出水面了。/api/*在旧版 AntPathMatcher 里也匹配不了/api/v1/user/detail这种多级路径,按理说升级前拦截器也不该生效。但事实是升级前的项目用的表达式是/api/**,某次维护时被改成了/api/*,在旧版本里误打误撞也能拦住。升级之后 PathPatternParser 对路径段的划分更严格,*只匹配单段路径,多级路径直接不匹配,等于拦截器被静默架空了。
3.3 真正原因:默认路径匹配器换了
这个坑的本质原因是 Spring Framework 6 把默认的路径匹配器从 AntPathMatcher 换成了 PathPatternParser。两者表面上看都是处理通配符,但语法和语义有差异。简单说,PathPatternParser 更规范也更严格,它对路径段的划分是明确的:
*匹配一个路径段内的任意字符,但不匹配斜杠,也不能跨段。**匹配 0 到多个路径段。{name}匹配一个路径段,并作为路径变量绑定。{*name}匹配 0 到多个路径段,常用于 catch-all 的场景。
老项目里常见的/api/*想表达“下面所有接口”这种模糊写法,在新版里是行不通的,必须改成/api/**。类似的问题还有在拦截器 excludePathPatterns 里写/assets/*.js这种后缀匹配,在 PathPatternParser 下也容易出问题。
另一个要注意的点是,这个变化不只影响拦截器,还会影响@RequestMapping的路径映射和静态资源的路径匹配。如果你在项目里大量使用/**、/static/**这种标准写法,问题不大;如果你用的是各种自创的、在 AntPathMatcher 默认实现下碰巧能跑的通配符表达式,升级后就要逐条检查。
3.4 解决方案对比
解决这个问题有两条路,我建议优先改表达式而不是换回老策略。
第一,把所有拦截器的路径表达式统一改成新语法。因为 SpringMVC 后续只会加强对 PathPatternParser 的支持,投奔新语法是长期成本最低的选择。我的登录拦截器最后改成了这样:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Resource private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register", "/error", "/static/**", "/favicon.ico"); } }关键就是把/api/*改成/api/**,并且把静态资源路径、错误页路径都显式排除掉,避免拦截器把静态资源也拦住。
第二,如果老项目里历史表达式太多,短时间改不完,可以暂时把路径匹配策略切回 AntPathMatcher:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher这个配置在 Spring Boot 3 里依然有效,设置之后 HandlerMapping 和拦截器的匹配规则会退回旧行为。但我个人建议只把它当临时方案,因为后续版本对 PathPatternParser 的依赖会越来越深,老策略迟早会被彻底移除。
这里还要多说一句,排查拦截器问题时,记得把日志级别调低,能看到 SpringMVC 的匹配过程:
logging: level: org.springframework.web: DEBUG org.springframework.web.servlet: TRACE我实际排查时就是靠 TRACE 日志确认了拦截器链里根本没有我注册的 LoginInterceptor,才把目光聚焦到路径匹配规则上。没有这一步,我可能还在扫描配置类上瞎折腾。
4. 第三个大坑:404、静态资源与欢迎页
4.1 首页 404 排查实录
拦截器问题解决之后,系统能登录了,但新的问题又来了:访问应用根路径直接 404,连个欢迎页都没有。项目里的 index.html 明明放在src/main/resources/static/下面,Spring Boot 的默认静态资源位置也没改过。
排查思路还是回到 SpringMVC 工作流程。静态资源请求会被 SimpleUrlHandlerMapping 交给 ResourceHttpRequestHandler 处理,而 index.html 的欢迎页功能是由 WelcomePageHandlerMapping 处理的。这个映射在 Spring Boot 3 里依然存在,但它的优先级比 RequestMappingHandlerMapping 低。我的项目里恰好写了一个@GetMapping("/")的 Controller,直接把根路径给占了。旧版本里欢迎页还能跟 Controller 共存,新版本对欢迎页和手写映射的冲突处理更严格了,手写映射直接压过了欢迎页,Controller 又没返回正确视图,于是 404。
解决办法很简单,要么删掉那个抢根路径的 Controller,要么在 Controller 里显式转发到静态页面:
@GetMapping("/") public String index() { return "forward:/index.html"; }我选择了保留 Controller,因为它在接口统一返回格式上还有作用,只是在里面加了个转发。
4.2 静态资源被拦截器“误伤”
欢迎页 404 刚解决,又发现页面上的 CSS 和 JS 全部加载不出来。一看请求日志,静态资源请求全被 LoginInterceptor 给拦下来了。原因是拦截器里我写的是addPathPatterns("/**"),把所有路径都纳入了拦截范围,但 excludePathPatterns 里只排除了接口路径,没有排除静态资源路径。
这个问题在升级前其实不存在,因为那时候静态资源路径由容器默认放行,拦截器对/static/**的匹配也相对宽松。升级后 PathPatternParser 对路径段匹配更严格,拦截器的/**确实把所有资源路径都覆盖到了,静态资源走到了拦截器链里。如果不排除,就会出现“页面能打开但寸步难行”的诡异情况。
正确的做法是把静态资源路径全部排除掉:
.excludePathPatterns("/static/**", "/css/**", "/js/**", "/images/**", "/favicon.ico");如果你的项目把静态资源放在 classpath:/static/ 下,并且访问路径不带 /static 前缀,比如直接访问/css/app.css,那排除路径就要写成/css/**而不是/static/**。这个细节我踩过,值得留意。
4.3 顺带踩了的 CORS 坑
静态资源问题解决之后,前端同事又来反馈跨域问题。项目里用的是全局 CORS 配置,在 WebMvcConfigurer 里重写了addCorsMappings:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); }老版本里这段配置很好用,但新版本里如果请求是 OPTIONS 预检请求,并且被拦截器拦住了,就会直接返回 401 或 403,根本没机会走到 CORS 处理的环节。解决方式有两个:一是在拦截器的 excludePathPatterns 里把 OPTIONS 请求放行,二是在拦截器 preHandle 里加一个判断,请求方法是 OPTIONS 就直接放行。
我最后在拦截器里加了这个小逻辑,保证所有预检请求都能绕过登录校验,因为浏览器预检请求本来就不携带业务凭证,拦在那里除了制造麻烦没有任何意义。
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }5. 排查工具、高频问题速查与版本兼容参考
5.1 一套行之有效的排查套路
经历这几个坑之后,我总结了一套排查 SpringMVC 新版本问题的固定套路,分享出来应该能帮大家少走弯路。
第一步,先确认请求有没有被 DispatcherServlet 接住。看日志里有没有 DispatcherServlet 的进入记录,如果没有,问题在容器层面,比如 servlet 路径映射不对;如果有,问题就在 SpringMVC 内部。
第二步,看 HandlerMapping 选择了哪个处理器。把org.springframework.web.servlet的日志级别调到 TRACE 后,SpringMVC 会打印出当前请求命中了哪个 HandlerMapping、哪个 Handler。这一步能快速定位是想匹配的路径规则根本没生效,还是被另一个 Handler 抢先截走了。
第三步,看 HandlerExecutionChain 里有哪些拦截器。日志会列出拦截器链的组成,如果里面没有你注册的拦截器,基本就是路径匹配规则的问题;如果有,再看是哪个拦截器返回了 false,把链断掉了。
第四步,如果前三点都正常但响应还是不对,重点检查 CORS、消息转换器、参数绑定和异常处理器。很多时候问题不在匹配,而在后续的执行链。
5.2 高频问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 启动报 javax.servlet 找不到 | Servlet API 包名迁移,容器版本过旧 | 升级外部容器到 Tomcat 10+,代码包名换 jakarta |
| 编译通过但运行期 java.lang.NoClassDefFoundError | 第三方库内仍传递引用旧 javax API | 升级第三方库到 Jakarta 兼容版本,或排除旧依赖 |
| 拦截器 preHandle 不执行 | 拦截器路径表达式不匹配 PathPatternParser 规则 | 检查 addPathPatterns,把单段/*改成/**,或用 ant_path_matcher 兜底 |
| 静态资源被拦截器拦截 | excludePathPatterns 没包含静态资源路径 | 显式排除/css/**、/js/**、/images/**等路径 |
| 访问根路径 404 | Controller 抢占了/,欢迎页失效 | 删除根路径映射,或在 Controller 内 forward 到 index.html |
| 浏览器预检请求失败 | OPTIONS 请求被拦截器拦下 | 在 preHandle 中直接放行 OPTIONS 请求 |
| 日期参数传字符串失败 | 新版本对 LocalDateTime 的格式化配置调整 | 检查 spring.mvc.format.date,或 @DateTimeFormat 显式指定格式 |
| 跨域配置不生效 | CORS 请求在到达 CORS 处理器前被拦截器拦截 | 拦截器放行预检请求,并确认 CorsRegistry 的路径规则 |
5.3 版本兼容参考
如果你正准备升级,下面这个参考表可以帮你快速决策。Spring Boot 2.7 对应 Spring Framework 5.3,Spring Boot 3.0 对应 Spring Framework 6.0,Spring Boot 3.2 对应 Spring Framework 6.1。越往后,对 Jakarta EE 和 PathPatternParser 的依赖越彻底,所以不要指望还有“保持老写法也能跑”的过渡期。
| 组件 | Spring Boot 2.7 | Spring Boot 3.2 |
|---|---|---|
| Java 版本要求 | Java 8+ | Java 17+ |
| Servlet API | javax.servlet | jakarta.servlet |
| Validation API | javax.validation | jakarta.validation |
| 默认路径匹配器 | AntPathMatcher | PathPatternParser |
| 内置 Tomcat | 9.x | 10.1 |
如果你因为团队规范原因暂时无法升到 Java 17,那就老老实实留在 Spring Boot 2.7,不要强上 3.x。Java 版本不达标,Spring Boot 3 连启动都做不到,这一点没有商量余地。
结尾
这一趟升级下来,最大的体会是:SpringMVC 新版本的坑,绝大多数不是“新功能不会用”,而是“旧写法在新规则下悄悄失效”。特别是 springmvc拦截器这块,API 看起来一模一样,但底层的路径匹配规则换了,代码不报错,行为就是不对,这种问题最耗时间。
最后分享一个排查细节:遇到这类问题,优先看日志,不要急着改代码。把org.springframework.web.servlet调到 TRACE,SpringMVC 会把 HandlerMapping 的匹配过程、拦截器链的组装过程原原本本打出来。我这次能在一小时内定位到拦截器失效,靠的就是这条日志,而不是靠猜。升级这种事,一次踩坑是教训,两次踩坑就是不长记性了。希望这份复盘能让你少熬两个夜。