先交代一下背景。我手头一个老项目,Spring Boot 2.7.9 + Spring Framework 5.3.x,跑了三年多,一直稳如老狗。因为团队整体要切 JDK 17 和新的基础设施,我被迫把 SpringMVC 一路升到 6.1(对应 Spring Boot 3.2)。当时想得很天真:不就是换个版本号吗?实际动手之后,javax 改 jakarta、路由匹配规则变化、springmvc拦截器不生效、静态资源 404——这些坑排着队等我。
这篇不聊虚的,只讲我实际踩过并且已经解决的问题。我把每个问题的现象、原因、解决办法都整理了,配置和代码直接给全。适合两类人看:一类是从 Spring Boot 2.x / SpringMVC 5 往 SpringMVC 6 升级的开发者;一类是想把 springmvc工作流程、springmvc拦截器这块彻底搞明白的同学,后者可以把第 5 节当作一份精简的复习资料。
先说结论:SpringMVC 6 整体不难,难的是旧习惯。很多报错都是因为“你以为它还是老样子”,下面逐条拆。
1. 升级先捋清版本基线:SpringMVC 6 到底多了什么
升级前先别急着改代码,把版本基线理清楚,后面所有坑都能对上号。我当时的版本对照是这样的:
| 项目 | 升级前 | 升级后 |
|---|---|---|
| Spring Boot | 2.7.9 | 3.2.x |
| Spring Framework | 5.3.x | 6.1.x |
| JDK | 8 | 17 |
| Servlet 命名空间 | javax.servlet | jakarta.servlet |
| 默认路径匹配方式 | AntPathMatcher | PathPatternParser |
| 拦截器基类 | HandlerInterceptorAdapter | 已移除,直接实现 HandlerInterceptor |
Spring Framework 6 的底座是 Jakarta EE 9/10,JDK 要求最低 17。这不是可选项,而是硬门槛——JDK 不到 17 连框架都加载不起来。所以升级之前,先把 JDK 版本统一掉,别想着“先让代码编过再处理环境”,那样只会把问题搅在一起。建议直接在项目根目录的 README 里写清楚目标版本矩阵,团队所有人照着统一环境操作,能省掉一半“在我机器上是好的”这种破事。
1.1 从 javax 到 jakarta,先解决编译都过不去的问题
升级后第一波报错毫无悬念:package javax.servlet does not exist。SpringMVC 6 把整个 Servlet 规范迁移到了jakarta.servlet命名空间,所有直接或者间接引用javax.servlet的代码都要跟着改。
// 升级前 import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; // 升级后 import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse;除 Servlet 之外,Bean Validation 的包名同样从javax.validation变成了jakarta.validation。这地方非常容易漏:很多人只盯着javax.servlet替换,结果@NotNull、@Valid还引用着旧的javax.validation.constraints.NotNull。如果项目里恰好还有旧的 validation-api 依赖,编译能过,但校验完全不生效——这是最隐蔽的坑,我排查了一整个下午才定位到。
替换的方式不用一个个手敲。如果你的代码量不大,直接用 IDE 的全局替换即可;代码量大的话,推荐 OpenRewrite 的 Spring Boot 3 迁移配方,它能把常见的包名替换、API 变更一次性处理掉。不过无论用哪种方式,替换完都要全局搜一遍javax.servlet、javax.validation、javax.annotation,确认没有漏网的。javax.annotation这个也要注意,Spring 6 里@Resource、@PostConstruct这些注解同样迁移到了jakarta.annotation,漏了的话启动时可能会有诡异的 Bean 初始化失败。
还有个容易被忽略的点:HandlerInterceptorAdapter在 Spring 6 中被移除了。以前写拦截器习惯extends HandlerInterceptorAdapter,升级后直接编译报错。正确做法是implements HandlerInterceptor,后面第 3 节我会贴完整代码。遇到这种“类找不到”的报错,别慌,先查这个类是新版本删掉的还是依赖没拉进来,多数是前者。
1.2 第三方依赖里残留的 javax 才是大坑
如果说包名替换是明坑,那第三方依赖里的旧 Servlet API 就是暗坑。我遇到的情况是:项目里有一个内部公共组件,还是两年前编译的,内部引用了javax.servlet.http.HttpServletResponse。项目本身升级后,编译、启动都正常,但只要走到那个组件的方法,立刻抛NoClassDefFoundError: javax/servlet/ServletException。
这就是典型的“依赖残留”。Spring Boot 3 自带的容器(Tomcat 10.1 之后)只认 jakarta,不再提供 javax.servlet 类,谁还引着旧 API,谁就会在运行时炸。而且这种错误往往出现在运行一段时间之后,不是启动时立刻爆出来,排查难度比编译报错高得多。
mvn dependency:tree -Dincludes=javax.servlet用上面这条命令可以把依赖树里所有 javax.servlet 相关依赖揪出来。找到之后分两种情况处理:能升级的,把第三方组件升到适配 Jakarta 的版本;不能升级的,在 Maven 里排除掉冲突的旧依赖。
<dependency> <groupId>com.example</groupId> <artifactId>legacy-common</artifactId> <exclusions> <exclusion> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> </exclusion> </exclusions> </dependency>注意:排除依赖只是“眼不见为净”,前提是你确认代码里没有再直接调用 javax 的类。最稳妥的办法还是让第三方组件方提供基于 Jakarta 的新版本,排掉旧依赖只是临时方案。
2. 路由匹配规则变了:404 是最忠诚的报警器
版本升级完、编译也过了,进入联调阶段才是真正的好戏开场。我最先遇到的,是一批莫名其妙的 404。接口定义明明没动,前端也没改,发出去的请求就是找不到 handler。
2.1 PathPatternParser 取代 AntPathMatcher,斜杠这种细节最要命
SpringMVC 6 默认使用PathPatternParser做路径匹配,取代了老版本的AntPathMatcher。框架这么做的原因是性能更好,匹配规则更清晰,不再有正则回溯之类的不确定性。但随之而来的规则变化,会让老代码原地崩溃。
其中最典型的:尾部斜杠不再自动匹配。老版本中/user这个映射默认也能接住/user/的请求,很多前端代码因此习惯性地在 URL 后面加一个斜杠。新规则下,/user只匹配/user,/user/是另一个路径,找不到 handler,直接 404。
| 对比项 | 老版本(AntPathMatcher) | SpringMVC 6(PathPatternParser) |
|---|---|---|
URL/user能否匹配请求/user/ | 默认可以 | 不能 |
/user/*能否匹配/user/a/b | 不能(* 只匹配单段) | 不能 |
/files/{*path}多段路径参数 | 写法复杂 | 原生支持 |
| 匹配性能 | 尚可,有回溯风险 | 更快、更稳定 |
新写法里比较有用的特性是{*path}捕获剩余路径。比如:
@GetMapping("/files/{*path}") public String file(@PathVariable String path) { // path = "report/2025/01/data.xlsx" return handle(path); }这在老版本里得写正则或者用/**加手动截取,现在一个路径变量就搞定。
2.2 我的 404 排查实录与两种解法
我当时遇到的报错是这样的:系统首页正常,但所有以斜杠结尾的业务接口全部 404,日志里刷着一行No mapping for GET /api/order/detail/。接口定义我确认过是对的,当时第一反应是“springmvc拦截器把请求吞了”,于是把拦截器全部注释掉,404 依旧。然后才想到路径匹配策略。
排查过程其实就三步:
- 先看日志里有没有
No mapping for ...,有就是根本没找到 handler,跟业务代码无关。 - 用 curl 直接把尾部斜杠去掉再请求一次,如果通了,十有八九是尾斜杠匹配问题。
- 去 Spring Framework 6 的迁移说明里确认匹配策略的变更。
解法有两种,按团队情况选。
解法 A:统一前端 URL,去掉尾部斜杠。这是官方推荐的方向。HTTP 语义里/api/order/detail和/api/order/detail/本来就是两个不同的 path,后端没必要把两者强行等价。
解法 B:切回 AntPathMatcher 做兼容。项目实在改不动 URL 时,可以临时恢复老规则:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher如果是纯 Spring MVC 项目(不走 Boot),在WebMvcConfigurer里把匹配器设置回去:
@Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setPathMatcher(new AntPathMatcher()); }坦白讲,解法 B 只能救急。Spring 官方在新版本里已经明确不鼓励尾斜杠匹配,后续版本会不会彻底移除都是未知数。我最后是让前端做了全局替换,把 URL 全部规范化,一劳永逸。
另外推荐一个配套设置:在配置文件里打开spring.mvc.throw-exception-if-no-handler-found=true,同时配合全局异常处理,把“找不到 handler”的 404 转成统一的 JSON 结构。不然很多 404 会直接打到默认错误页,前端再做一份 404 文案,体验很割裂。这个配置我在升级前完全没在意,踩过一次坑之后才知道它多有用。
3. springmvc拦截器的坑:不生效、拦不到、拦错路径
路径匹配搞定之后,又轮到 springmvc拦截器。这节内容在网上一搜一大把,但我还是要说一句:新版本里拦截器的问题,十有八九不是 API 变了,而是“你以为配了,其实没配到位”。
3.1 拦截器不触发的三个常见原因
原因一:配置类根本不在扫描路径里。WebMvcConfigurer的实现类必须被 Spring 容器扫描到,拦截器才会注册。我见过有人把配置类放在com.example.legacy这种老包,主类在com.example.app,扫描不到,代码看着没什么问题,拦截器就是死活不跑。解决办法很简单,把配置类放到主类所在包的子包下,或者用@ComponentScan显式指定。
原因二:Spring Boot 项目里顺手加了@EnableWebMvc。这可以说是升级头号杀手。@EnableWebMvc会让 Boot 的WebMvcAutoConfiguration直接失效,MVC 的全部默认配置回到框架最基础的状态。表现不只是拦截器不生效,静态资源映射、JSON 消息转换、默认异常处理全部会丢。如果你是在 Spring Boot 里写 Web 配置,不要加@EnableWebMvc,只需要写一个@Configuration类去实现WebMvcConfigurer就够了。
原因三:拦截路径写错。addPathPatterns("/api/*")和addPathPatterns("/api/**")差别巨大。*只能匹配一个路径段,/api/*能拦到/api/login,拦不到/api/user/info。很多人的拦截器“只拦了一半”,不是代码 bug,是对通配符理解偏差。这类问题在新版本里尤其常见,因为 PathPatternParser 对通配符的解释更严格,老版本里一些“半模糊”写法还能匹配上,新版本就直接不认了。
3.2 新版本下拦截器注册的正确姿势(含代码)
先看拦截器本体。Spring 6 之后没有HandlerInterceptorAdapter了,直接实现HandlerInterceptor接口:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("X-Token"); if (token == null || token.isBlank()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或token缺失\"}"); return false; } // 这里写 token 解析和用户信息填充 return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // handler 执行完后、视图渲染前 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求结束,无论是否异常都会调用 } }注册方式:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**", "/user/**") .excludePathPatterns("/api/login", "/api/register", "/static/**", "/error"); } }几个细节值得记一下:
excludePathPatterns一定要包含登录、注册、静态资源、健康检查这些公开接口,不然系统一上线,还没登录就把自己锁死了。- 多个拦截器时,
preHandle按addInterceptor的调用顺序执行,postHandle和afterCompletion按相反顺序执行。如果多个拦截器之间有依赖关系,这个先后顺序要想清楚。 - 如果拦截器里需要访问 Spring 容器中的 Bean,不要用
new创建,把拦截器也定义成@Component,再通过构造注入拿进来。直接在addInterceptors里new出来的对象是不受容器管理的,里面注入的任何依赖都是 null,这个坑我见过太多次。
3.3 OPTIONS 预检请求被拦截:跨域失败的常见元凶
跨域问题在新版本里也容易和拦截器撞在一起。浏览器在发起跨域 AJAX 前会先发一个 OPTIONS 预检请求,如果你的拦截器对所有请求都要 token,OPTIONS 直接 401,浏览器的正式请求根本没机会发出去,前端看到的就是“跨域失败”。
我在上面拦截器代码里已经放了放行逻辑:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }更规范一点可以用 Spring 自带的判断:
import org.springframework.web.cors.CorsUtils; if (CorsUtils.isPreFlightRequest(request)) { return true; }同时保证 CORS 配置是对的。全局 CORS 可以在WebMvcConfigurer里加:
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://front.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); }注意:
allowCredentials(true)时,allowedOrigins不能写*,必须写明确域名,否则浏览器直接拒绝。这是我当时在 Safari 下怎么调都不通、换 Chrome 才看到控制台完整报错的原因。跨域问题的排查建议直接用 Chrome 的 Network 面板,看 OPTIONS 请求的响应头,比猜快得多。
4. 静态资源、视图解析与请求响应的连带坑
拦截器消停之后,静态资源又开始闹。CSS、JS 全部 404,接口倒是全好了。
4.1 静态资源 404 的排查思路
先别急着加资源映射,先想一个问题:你的项目里是不是也加了@EnableWebMvc?如果是,恭喜,你找到了问题根源。原因在第 3 节说过:@EnableWebMvc把 Boot 的默认静态资源映射干掉了,classpath:/static/下的文件全部失去访问路径。
去掉@EnableWebMvc,Spring Boot 3 默认会自动把以下位置映射为静态资源:classpath:/META-INF/resources/、classpath:/resources/、classpath:/static/、classpath:/public/。如果你的文件一直放在src/main/resources/static/css/app.css,访问路径应该是/css/app.css,不需要自己配。
特殊情况才需要手工加资源映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:/data/upload/") .setCachePeriod(3600); }这种一般用于把服务器磁盘上的目录暴露出来。要注意的是,自定义/files/**这类路径时,别和 Controller 的@GetMapping("/files/{*path}")撞车,否则 Spring 会因为路径冲突报 ambiguous mapping,两个都失效。我升级时就因为本地测试环境保留了一个旧的/files/**映射,结果跟新版 Controller 撞了,报错信息还特别不明显,排查了很久。
4.2 视图解析与转发/重定向的细节
如果项目还在用 JSP,新版本里要格外留个心眼。Spring Boot 3 以 jar 方式打包时,内置容器对 JSP 的支持非常有限,InternalResourceViewResolver经常出现“能找到视图但渲染 500”的尴尬。强烈建议趁升级机会把视图层迁到 Thymeleaf,或者坚持 JSP 就改打成 war 包部署到外部容器。
Thymeleaf 在 Spring Boot 3 下基本是零配置,加依赖后把页面放到src/main/resources/templates/,Controller 返回字符串即可:
@GetMapping("/page") public String page() { return "index"; }再提醒一个容易忽略的细节:forward:和redirect:的行为差异。forward:是服务端内部转发,浏览器 URL 不变,请求会再走一遍 DispatcherServlet 的流程,但不会重新触发已经执行过的拦截器的preHandle;redirect:是让浏览器重新发起请求,URL 会变,会完整走一遍新的请求链路。写权限控制逻辑时,想清楚用的是哪种,不然会出现“明明要跳转,结果被拦截器拦下来”的乌龙。
另外顺带提一句消息转换器。如果你在WebMvcConfigurer里重写了configureMessageConverters,它会清空默认的消息转换器列表,Jackson 就不会自动生效,表现为接口返回的数据变成纯字符串或者直接 406。如果没有特别明确的需求,优先用extendMessageConverters做追加而不是覆盖。这是升级后一个比较典型的隐藏坑,尤其是老项目里如果有人为了“定制 JSON”重写过这个方法。
5. 顺带把 springmvc工作流程彻底捋一遍
聊到这里,其实已经把 SpringMVC 工作流程里最容易出问题的几个环节全部踩了一遍。借着这次升级,我把整套流程重新梳理一遍,这部分对你理解新版本和面试都有用。
5.1 一次请求从头到尾经历了什么
| 环节 | 核心组件 | 一句话职责 |
|---|---|---|
| 1. 入口 | DispatcherServlet | 前端控制器,统一接收 HTTP 请求 |
| 2. 找 handler | HandlerMapping | 根据 URL 找到对应的 Controller 方法 |
| 3. 适配调用 | HandlerAdapter | 执行定位到的方法,处理参数绑定、校验 |
| 4. 前置/后置 | HandlerInterceptor | 请求前、handler 后、完成后三段拦截 |
| 5. 结果封装 | ModelAndView / HttpMessageConverter | 返回视图或直接输出 JSON |
| 6. 视图渲染 | ViewResolver + View | 把逻辑视图名解析成真实视图并渲染 |
一次请求进来后,完整链路是这样的:
- 请求先落在
DispatcherServlet。它是 SpringMVC 的“中央调度器”,所有 handler 的查找、适配、结果处理都从这里开始,但它自己不做业务。 HandlerMapping根据请求 URL 和 Controller 上的@RequestMapping生成匹配结果。默认实现是RequestMappingHandlerMapping,新版本里它用的路径匹配器就是 PathPatternParser。匹配结果里除了 handler 方法本身,还有一组需要执行的拦截器。HandlerAdapter开始调用 handler。这一步会做参数解析:@PathVariable、@RequestParam、@RequestBody全部在这里绑定。参数绑定出错时,比如 JSON 格式不对,会抛HttpMessageNotReadableException,这就要靠@ControllerAdvice接住。- 拦截器链路在这个阶段起作用。顺序是:
preHandle(进入 handler 前)→ 业务方法 →postHandle(handler 返回后、视图渲染前)→afterCompletion(请求结束)。 - Controller 方法执行完,结果的归途分两种:方法上有
@ResponseBody或者类上有@RestController时,RequestMappingHandlerAdapter会用HttpMessageConverter(默认是 Jackson 的MappingJackson2HttpMessageConverter)把对象序列化成 JSON 写回;否则返回的字符串会当作逻辑视图名交给 ViewResolver。 ViewResolver拿到逻辑视图名后解析成真实视图。Thymeleaf 有ThymeleafViewResolver,JSP 有InternalResourceViewResolver,解析成功后由View.render输出 HTML。
5.2 新版本里工作流程里哪些环节悄悄变了
对照上面流程,升级后的变化集中在几个地方:
- 第 2 步的匹配器换了。
AntPathMatcher变成PathPatternParser,规则和性能都变了。权限系统里如果用了/**之类的表达式,要按新规则重新验证一遍。 - 第 4 步的拦截器简化了。
HandlerInterceptorAdapter删除,接口方法本身就是默认空实现,直接implements HandlerInterceptor就行。 - 第 5 步的错误处理更规范了。Spring 6 中
ResponseEntityExceptionHandler默认生成ProblemDetail(RFC 9457 格式)响应体,异常结构变成{ "type": "...", "title": "Bad Request", "status": 400, "detail": "..." },做全站统一报错时比之前的自定义 Map 更规范。 - 第 5 步的异步支持更完善。
DeferredResult、CompletableFuture这类异步返回在 Spring 6 里是原生支持的,适合对接慢接口、消息队列回调,不会因为线程阻塞占满容器线程池。 - 内容协商策略收敛了。老版本可以通过 URL 后缀(
.json、.xml)来协商响应格式,新版本默认不再支持后缀匹配,必须通过Accept请求头来定。如果你有/user.json这种老接口,升级后大概率 404,要改用Accept: application/json。这个问题很多 migrate 项目都会遇到,前端和后端要同步改造。
6. 高频问题速查表与升级实操清单
最后把这次升级过程中遇到的所有问题汇总成一张速查表,再给你一份升级前自查清单。
6.1 常见报错与解决方案对照表
| 现象 | 原因 | 解决方案 |
|---|---|---|
编译报错package javax.servlet does not exist | Servlet 命名空间迁移 | 全部替换为jakarta.servlet |
运行时报NoClassDefFoundError: javax/servlet/... | 第三方依赖残留旧 Servlet API | mvn dependency:tree排查后升级或排除 |
| 接口 404,URL 最后带斜杠 | PathPatternParser 不做尾斜杠匹配 | 前端去掉斜杠,或临时切 ant_path_matcher |
| 静态资源 404 | 误用@EnableWebMvc导致默认映射丢失 | 去掉该注解,或手动补 resource handler |
| 拦截器完全不执行 | 配置类没被扫描 / 路径写错 | 检查包扫描、addPathPatterns写成/** |
| 拦截器只拦住部分接口 | *与**通配符理解错误 | 用/**匹配多段路径 |
| 跨域请求失败、控制台报 OPTIONS 401 | 拦截器拦截了预检请求 | preHandle放行 OPTIONS /CorsUtils.isPreFlightRequest |
HandlerInterceptorAdapter找不到 | Spring 6 已移除该类 | 改为implements HandlerInterceptor |
| JSP 渲染 500 | Boot 3 jar 打包对 JSP 支持弱 | 迁移 Thymeleaf 或改 war 部署 |
| JSON 输出异常或 406 | configureMessageConverters覆盖默认转换器 | 改用extendMessageConverters |
/user.json老接口 404 | 新版本不支持后缀内容协商 | 改成Accept请求头协商 |
6.2 升级前建议你按这个清单自查
- 先统一 JDK 17,再动框架版本,避免环境变量和编译目标互相干扰。
- 全局搜一遍
javax.servlet、javax.validation、javax.annotation,替换成 jakarta 同名包。 - 检查所有
extends HandlerInterceptorAdapter的地方,改成implements HandlerInterceptor。 - 用
mvn dependency:tree排查旧 Servlet API,对每个第三方依赖确认它是否支持 Jakarta。 - 对着接口清单测试一遍 URL,重点看尾斜杠、大小写、URL 编码,前端如有拼斜杠的习惯必须改。
- 检查拦截器、过滤器、Spring Security 的路径规则,如果 Security 还在用
antMatchers,Spring Security 6 已经把它标记为弃用并建议移除,统一换requestMatchers。 - 静态资源和 JSP/模板:确认
@EnableWebMvc没被误加、资源路径没冲突、模板方案在新版本可用。
最后再分享一个我自己的习惯:升级这种事,别指望一次到位,也别怕当场报错。报错日志其实是最诚实的老师,它告诉你哪里变了、哪里漏了。我每次升级完都会先跑一遍全链路接口测试,再用浏览器把页面全部点一遍,把 404、406、500 这些问题在前置环境里全部炸出来,而不是等上线后让用户帮你测。
我在这个项目里踩过的最大教训是:改动之前,先把“旧行为”和“新行为”的差异表列出来,逐项核对,而不是看到一个报错改一个。如果你正在做 SpringMVC 升级,强烈建议把我这份清单拿着,一边升级一边打勾,能少走一半弯路。