SpringMVC 这东西,说起来真是既熟悉又微妙。Java Web 开发绕不开它,Spring Boot 火了之后好多新同学上来就写@RestController,反而把 SpringMVC 本身给丢了。结果一遇到 404、参数绑定失败、拦截器不生效这类问题就懵,不知道去哪里排查。我接触 SpringMVC 差不多十年了,从最早的 XML 配置到 Java Config 再到 Spring Boot 自动装配,踩过的坑写出来能绕公司工位一圈。这篇不是官方文档复读机,就是想帮你把 SpringMVC 的核心链路彻底捋清楚,结合我实际项目里遇到的坑和常用的排查思路,讲点文档里不写但确实好用的东西。
这篇适合刚入行 Java Web 开发、正在用 Spring Boot 但没仔细看过底层机制的朋友,也适合写了两三年 CRUD 却对请求流转过程不太确定的同学。读完之后你至少能明白三件事:一次 HTTP 请求进来,SpringMVC 到底做了什么;写 Controller 时哪些细节决定成败;出了问题按什么思路去排查而不是瞎改配置。
1. 先别写代码,把请求链路摸清楚再动手
很多新手第一次看 SpringMVC 请求流转图,脑子里只有一句话:请求进来,方法执行,返回结果。但实际面试、排错的时候,考察的全是链路中间那几步。我强烈建议你先把这条链路刻在脑子里,比背一百个注解都有用。
1.1 一次 HTTP 请求从进门到返回,SpringMVC 做了什么
把 SpringMVC 比作一家餐厅,你马上就能理解。DispatcherServlet是前台接待员,所有顾客(HTTP 请求)进门都先找他。HandlerMapping是菜单,接待员根据顾客点的菜(URL)去菜单上找对应的厨师(Controller 里的方法)。HandlerAdapter是传菜员,找到厨师之后,由传菜员把顾客的需求(请求参数)准确递给厨师,再把厨师做好的菜端出来。ViewResolver是摆盘师傅,如果需要上桌(渲染页面),就由他决定用什么盘子。
一次完整请求的流程大概是这样的:
- 浏览器发起
GET /user/123,请求到达 Servlet 容器(Tomcat)。 - 容器根据 URL 映射把请求交给
DispatcherServlet,这是 SpringMVC 的总入口。 DispatcherServlet调用HandlerMapping,让它根据请求路径找到能处理的HandlerExecutionChain。这里不光有 Controller 方法,还有一串拦截器。- 找到之后,
DispatcherServlet再通过HandlerAdapter来真正执行这个 Handler。为什么要多一层适配器?因为 SpringMVC 里能当 Handler 的不止注解方法一种,还有实现Controller接口的、实现HttpRequestHandler的,适配器模式统一了调用方式。 - 在执行方法前,SpringMVC 会利用参数解析器
HandlerMethodArgumentResolver把请求里的参数转换成方法入参。 - 方法执行完,返回值处理器
HandlerMethodReturnValueHandler开始工作:如果你加了@ResponseBody,结果会被消息转换器(比如 Jackson)直接写成 JSON 写回响应;如果没加,返回的是视图名,交给ViewResolver解析成实际页面。 - 最后响应返回给浏览器。
这中间任何一步出了问题,表现到前端就是各种状态码:404 可能是第 3 步没找到 Handler,400 可能是第 5 步参数解析失败,500 可能是第 6 步序列化或者方法本身抛异常。搞清楚故障出在第几步,排查就成功了一半。
1.2 前端控制器 DispatcherServlet 为什么是“总调度台”
DispatcherServlet本质上就是一个 Servlet,它继承自FrameworkServlet,在 Servlet 容器启动时被初始化。Spring Boot 的自动配置里已经替你注册好了这个 Servlet,默认映射路径是/,也就是说所有请求都会先进到它这里。
有人问:为什么 SpringMVC 一定要搞一个前端控制器?直接把请求映射到对应 Servlet 不行吗?这就像餐厅里没有前台接待,顾客直接闯进后厨找厨师,看似高效,实际上乱套。有了统一的DispatcherServlet,你可以在请求真正进入业务代码之前统一处理编码、跨域、拦截校验,也可以在它返回之前统一处理异常、包装响应结构。所有横切逻辑集中在一处,代码维护起来才舒服。
调试的时候有个特别实用的技巧:在DispatcherServlet.doDispatch()方法上打断点。这个方法就是整个请求流转的中枢,你会看到mappedHandler是怎么被找到的、HandlerAdapter是怎么选出来的、拦截器的preHandle执行到哪一步。我排查复杂问题基本都从这里开始,比在业务代码里一层层找快得多。
1.3 HandlerMapping、HandlerAdapter、ViewResolver 各管哪一段
这三个组件是 SpringMVC 的“三驾马车”,理解它们的分工,你才算真正入了门。
HandlerMapping负责“找”。它根据请求 URL 返回一个执行链。SpringMVC 里有多个 HandlerMapping 实现,最常见的是RequestMappingHandlerMapping,专门负责解析@RequestMapping系列注解。还有一个容易踩坑的点:多个 HandlerMapping 是有顺序的,SpringMVC 会按Order排序后依次询问,谁先找到就先用谁。
HandlerAdapter负责“调”。拿到 Handler 之后,适配器知道怎么调用它。我们平时写的方法带各种注解参数,就是RequestMappingHandlerAdapter在背后做参数解析和返回值处理。这个适配器内部维护了一堆HandlerMethodArgumentResolver和HandlerMethodReturnValueHandler,列表很长,而且有顺序。这就是为什么你自定义的参数解析器有时候不生效,多半是顺序排得太靠后了。
ViewResolver负责“渲染”。InternalResourceViewResolver最老牌,专门做前缀后缀拼接,比如配置了前缀/WEB-INF/views/和后缀.jsp,返回userList就对应到/WEB-INF/views/userList.jsp。前后端分离之后,项目里大量接口直接返回 JSON 不走视图解析,但理解这套机制依然有必要,因为很多老项目、后台管理系统还在用服务端渲染。
提示:SpringMVC 的组件设计大量使用了策略模式和适配器模式。遇到需要扩展的场景,记住一条原则:不要改框架代码,往机制里“插”一个自己的实现,再通过配置让它生效。
2. 手写 Controller 前必须搞懂的注解细节
Controller 是使用频率最高的类,但越常用越容易忽略细节。我见过不少项目里@Controller和@RestController混用,年轻人可能觉得无所谓,实际后果挺严重。
2.1 @Controller 和 @RestController 到底怎么选
@RestController是@Controller和@ResponseBody的组合注解。区别就在响应处理上:
用@Controller时,方法返回值默认被当作视图名,SpringMVC 会去找对应的 View 来渲染。如果方法上加了@ResponseBody,返回值才会被直接写入响应体。用@RestController时,不需要每个方法单独加@ResponseBody,默认所有返回值都直接序列化写入响应体。
那实际项目怎么选?一句话:页面渲染用@Controller,接口返回 JSON 用@RestController。如果项目是前后端分离,绝大多数 Controller 都该用@RestController。不过要特别注意一个坑:在@RestController里返回String,这个字符串会被原样写到响应体,而不是被当成视图名。有的同学把老项目的@Controller改成@RestController,结果页面返回了一坨字符串,还以为框架坏了。
如果你的类上加了@Controller,但某个方法想返回 JSON,就在方法上加@ResponseBody。反过来也一样。这个组合方式在老项目中非常常见。
2.2 参数绑定:路径参数、查询参数、JSON、表单数据的完整链路
参数绑定是 SpringMVC 里最实用的能力,也是报错重灾区。我按使用频率帮你梳理一遍。
路径参数用@PathVariable。比如接口设计成/user/{id},方法签名public Result getUser(@PathVariable("id") Long id)。使用 RESTful 风格时这个注解是主力。注意,如果你用/user/{id:[0-9]+}这种正则约束,命中了特定格式才匹配,否则会落到别的接口。
查询/表单参数用@RequestParam。它对应 URL 问号后面的参数,也兼容表单提交字段。建议显式设置value和defaultValue,比如@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum。这样参数缺失也不会报错,前端调用起来更友好。还有个细节:@RequestParam默认required = true,也就是说参数必须传,不传直接 400。真实项目里除了必填项,建议都设默认值或者改成required = false。
JSON 体用@RequestBody。SpringMVC 会通过HttpMessageConverter把请求体里的 JSON 字符串反序列化成对象。前端 POST 一个 JSON,后端用@RequestBody UserDTO user接收,这是当前前后端分离项目的最主流写法。但要注意,一个方法里最多只能有一个@RequestBody,因为请求体只能读一次,放两个肯定会报错。
对象绑定接收表单字段。方法参数直接写User user,SpringMVC 会把请求里的字段按属性名自动填充到对象里。这个方式在服务端渲染表单提交的场景里特别省事,但它做不了复杂嵌套和类型转换,适合字段少的简单表单。
日期参数是个老坑。前端传来2024-01-15,后端字段是Date或LocalDate,不配格式化注解大概率解析失败。接收参数时用@DateTimeFormat(pattern = "yyyy-MM-dd"),返回 JSON 时用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。两个注解一个管接收、一个管输出,分工明确。
2.3 返回值处理和消息转换:什么时候走视图,什么时候直接写 JSON
我见过最混乱的代码就是返回值乱写:有的方法返回ModelAndView,有的返回String,有的返回Map,搞得整个团队读代码全靠猜。实际上只要把 SpringMVC 的处理原则记清楚,返回值设计可以很统一。
如果方法没有@ResponseBody,返回值会被HandlerMethodReturnValueHandler处理成 ModelAndView。String类型默认被当作视图名,ModelAndView可以同时携带数据模型和视图,void则配合HttpServletResponse直接写响应。这种模式基本只服务于服务端渲染项目。
如果方法有@ResponseBody,返回值会交给消息转换器。Jackson 负责把对象序列化成 JSON,String就直接写字符串。很多同学会踩Long精度丢失的坑:Java 的Long一旦超过 JavaScript 的Number安全范围,前端拿到的值就变样。解决方案是给字段加@JsonSerialize(using = ToStringSerializer.class),把 Long 转成字符串再输出。这个问题在分布式系统里几乎必现,因为分布式 ID 基本都是雪花算法生成的 Long。
统一返回值结构也是必须养成的好习惯。我用过的最简单结构就是Result<T>,包含code、message、data三个字段。不管成功失败,接口都返回这个结构,前端只需要解析一种格式。这样做配合全局异常处理,调用方判断是否成功只需要看code。
2.4 全局异常处理:@ControllerAdvice 的正确打开方式
没有全局异常处理的项目,Controller 里到处是 try-catch,代码又臭又长。这个坑我早期也踩过,后来用了@ControllerAdvice,才把业务代码和异常处理彻底分开。
@ControllerAdvice是全局异常处理的载体,配合@ExceptionHandler使用。它的工作方式是:Controller 方法抛出异常后,SpringMVC 在解析 Handler 的环节会先询问是否有匹配的@ExceptionHandler,有就由它接管。
我推荐的做法是分三层处理:业务异常、参数校验异常、系统异常。
业务异常用自定义异常类,比如BizException,构造时传入错误码和提示信息。@ExceptionHandler(BizException.class)里直接返回Result.error(e.getCode(), e.getMessage()),让前端能拿到明确的业务提示。
参数校验异常用MethodArgumentNotValidException或BindException,这两个是@Valid校验失败时抛出的。注意处理顺序:Spring 会选择最精确的异常处理器,如果你同时写了Exception的兜底和这个具体的处理器,小异常会先被具体的接住。我一般给校验异常单独写一个方法,返回参数错误的提示。
系统异常用Exception.class兜底。这里有个重要原则:对外只返回“系统繁忙,请稍后重试”这样的模糊提示,完整的堆栈信息必须记到日志里,不能暴露给调用方。安全是第一位的,堆栈信息泄露出去等于给攻击者递刀子。
注意:捕获
Exception.class兜底时,不要丢原始信息。用log.error("系统异常", e)记录完整堆栈,返回给调用方的 message 用统一文案。别图省事直接e.toString()返回。我见过某项目把 SQL 异常直接返回给前端,报错信息里连表名都带出来了,这是很严重的安全隐患。
3. 拦截器、过滤器、配置项:影响项目质量但容易被忽略的部分
写完 Controller 能跑,只是第一步。真实项目里登录校验、跨域处理、静态资源放行这些横切逻辑,才是决定项目质量的关键。
3.1 基于 Java Config 的配置方式,别再翻 web.xml 了
Spring Boot 出现之后,XML 配置基本退场了,但 SpringMVC 的配置思路还是要懂。Spring Boot 里默认已经自动配置了 SpringMVC,你只需要通过WebMvcConfigurer接口做个性化定制,不需要加@EnableWebMvc。我自己试过一次加@EnableWebMvc导致默认配置全丢的情况,静态资源全 404,配置的 Jackson 规则也没了,排查了半天,后来才知道这个注解会关掉 Spring Boot 的 WebMvcAutoConfiguration。
常见的定制点包括:addInterceptors注册拦截器,addCorsMappings配置跨域,addViewControllers注册简单页面跳转,configureMessageConverters自定义消息转换器。
一个很实用的例子:用addViewControllers把首页跳转直接写进配置,省掉一个空 Controller。“/” 路径直接转发到index.html或者某个视图。代码量少,意图还清楚。
WebMvcConfigurer的实现类用@Configuration标注即可。另外说一句,如果你确实需要完全接管 SpringMVC 的配置,才考虑@EnableWebMvc,正常情况下不要碰,除非项目是纯 XML 老架构升级。
3.2 拦截器、过滤器、AOP:三者的定位差异与使用时机
这个问题的辨析频率极高,面试八股文常客,实际开发中也确实容易混淆。一句话总结:拦截器是 SpringMVC 层面的,过滤器是 Servlet 层面的,AOP 是 Spring 方法层面的。三者执行时机不同,能拿到的信息也不同。
过滤器Filter在请求进入DispatcherServlet之前执行,属于 Servlet 规范。它拿不到 SpringMVC 的 Handler 信息,只能拿到原始的ServletRequest。适合做请求日志、编码设置、跨域响应头添加这类最前置的处理。Spring Boot 里用FilterRegistrationBean注册,注意设置过滤路径和顺序。
拦截器HandlerInterceptor在请求进入 Controller 方法之前执行,已经处于 SpringMVC 上下文里。它有三个回调方法:preHandle(请求处理前)、postHandle(方法执行后视图渲染前)、afterCompletion(整个请求结束后)。最关键的是preHandle里能拿到HandlerMethod对象,通过它可以获取目标类和方法上的注解。这就让登录校验变得非常优雅:自定义一个@RequireLogin注解,在拦截器里校验用户是否登录,代码统一在一处维护。
AOP 面向的是 Spring Bean 的方法调用,不关心请求也不关心视图,适合做业务方法级的增强,比如操作日志、方法耗时统计、分布式锁。
选择建议:需要操作请求或响应的内容,用过滤器;需要判断是不是某个注解 Controller 方法,用拦截器;需要增强具体业务方法,用 AOP。
实际开发中一个高频坑:登录拦截器把预检请求OPTIONS也拦截了,导致跨域场景下前端报错。解决办法是在preHandle里判断请求方法,如果是OPTIONS直接返回 true 放行。
3.3 静态资源放行与跨域配置的实操细节
Spring Boot 默认把/static、/public、/resources等目录下的文件映射为静态资源。如果你继承了WebMvcConfigurer并且重写了addResourceHandlers,一定要记得把默认配置补上,否则静态资源会全部 404。我处理过一个项目,同事为了给上传文件加映射路径,重写了这个方法却忘了加默认映射,结果整个前端页面样式全丢了。
跨域配置现在几乎是必备。前后端分离项目部署在不同域名,不解决跨域请求根本没法调通。SpringMVC 里配置跨域有两种方式:注解@CrossOrigin加在类或方法上,适合局部接口;addCorsMappings配置全局规则,适合整个项目统一处理,推荐用这个。
配置全局跨域时注意几个细节。allowedOriginPatterns和allowedOrigins的区别:如果你需要allowCredentials(true)同时允许特定来源,必须用allowedOriginPatterns,用allowedOrigins加allowCredentials(true)在某些版本下会直接报错,因为响应头Access-Control-Allow-Origin不能同时是*和具体的 origin。预检请求缓存时间maxAge建议设置一下,避免每次请求都要先 OPTIONS 预检一次,影响性能。
4. 实战踩坑记录:排查思路与高频问题速查
最后这部分是我最想分享的。SpringMVC 的知识点大家都看过,但真实项目里的坑,往往在文档里找不到答案。我把这些年遇到的高频问题按“现象—原因—排查路径”的方式整理出来,你下次遇到类似问题可以直接照着做。
4.1 请求 404 还是 400?到不了 Controller 的排查路径
404 最让人头疼,因为它可能是路由错了、类没被扫描、甚至静态资源问题。我的排查顺序是固定的,从外到内层层剥:
第一,确认请求确实打到了这个服务。看后端日志,如果连访问日志都没有,大概率是网关或者负载均衡层面的问题。
第二,确认 Controller 类确实被 Spring 容器扫描到了。最常见的情况是 Controller 包和启动类不在同一个包结构下,Spring Boot 默认扫描启动类所在包及其子包,扫不到就不会注册 HandlerMapping。这个 bug 在新项目里出现频率极高,解法就是调整包结构或加scanBasePackages。
第三,确认方法上的 URL 映射正确。@RequestMapping的 value 写错了、少了/,都会导致匹配不上。还有个细节:RESTful 接口用@GetMapping但前端实际发的是 POST,就会变成 405,注意区分。
第四,检查是否有拦截器或过滤器拦截了请求没有放行。
400 的状态码含义是“请求无法被服务器理解”,绝大多数情况发生在参数绑定阶段。典型的报错信息是Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'。看到这种日志,直接去查对应参数的类型是否匹配。前端传了字符串,后端要求 Long,SpringMVC 没法自动转换就会报 400。排查时把目光集中在入参类型和注解上。
4.2 JSON 序列化循环引用、日期格式这些老问题
前后端分离接口,返回 JSON 的过程最容易出诡异的 bug。第一个高频问题是循环引用:A 对象里有一个 B 对象列表,B 对象里又引用了 A,直接用 Jackson 序列化会抛出Infinite recursion。老项目里用 JPA 时实体类互相关联,几乎必现。解决思路有两个:在关联字段上加@JsonIgnore直接不输出某一侧;或者用@JsonIdentityInfo把循环引用改成 ID 引用。不过我实际更推荐的做法是设计 VO/DTO,只返回需要暴露的字段,本质上也解决了循环引用。
第二个高频问题是空值字段。默认 Jackson 会把null字段也输出,前端拿到的 JSON 里一堆"field": null,又丑又容易误导调用方。在配置里设置spring.jackson.default-property-inclusion=non_null可以让空值字段直接不序列化。如果要在单个字段控制,用@JsonInclude(Include.NON_NULL)。
第三个高频问题是日期格式。实体类里直接返回Date,序列化结果是时间戳,前端还要自己换算。统一在配置里指定spring.jackson.date-format=yyyy-MM-dd HH:mm:ss和时区spring.jackson.time-zone=GMT+8,问题从源头消失。
4.3 中文乱码、参数丢失、文件大小限制这些隐藏的坑
中文乱码在老项目中几乎绕不开。如果 POST 提交表单到后端,后端拿到的中文是乱码,先检查过滤器配置。Spring 提供CharacterEncodingFilter,关键在于设置forceEncoding = true,这样无论请求还是响应都会被强制使用指定编码,否则如果请求头里没有声明 charset,过滤器默认不生效。Spring Boot 里可以用一个@Bean注册并设置setForceEncoding(true)。
参数丢失的情况我也遇到过。前端明明传了字段,后端属性却是 null。排查思路是先看请求格式:如果前端Content-Type是application/json,后端就必须用@RequestBody接收;如果前端是 form 表单提交,后端直接对象接收即可,用@RequestBody反而拿不到数据。前后端对不上,参数就会静默丢失。
文件上传大小限制是另一个经典隐藏坑。Spring Boot 默认单文件最大 1MB、总请求 10MB,业务需要上传大文件时,直接报FileUploadException。解法是在配置里调大spring.servlet.multipart.max-file-size和spring.servlet.multipart.max-request-size。还有一点,上传接口的@RequestParam("file") MultipartFile file里参数名必须和表单里的 name 一致。
4.4 高频问题速查表
| 现象 | 常见原因 | 优先排查方向 |
|---|---|---|
| 404 且后端无访问日志 | 请求没到服务或被网关拦截 | 网关路由、网络链路、防火墙 |
| 404 但日志有请求记录 | Controller 未被扫描或映射错误 | 包扫描范围、@RequestMapping value |
| 400 错误 | 参数类型转换失败 | 入参类型、@DateTimeFormat、@RequestParam |
| 405 错误 | 请求方法不匹配 | 前端方法、后端 @GetMapping/@PostMapping |
| 500 错误且日志有异常 | 业务代码异常或序列化问题 | 全局异常处理器、Jackson 序列化 |
| 中文乱码 | 编码过滤器未强制生效 | CharacterEncodingFilter forceEncoding |
| JSON 循环引用 | 实体类双向关联 | @JsonIgnore、VO 设计 |
| 静态资源 404 | WebMvcConfigurer 覆盖了默认映射 | addResourceHandlers 补默认配置 |
| 拦截器不生效 | 未注册或拦截路径写错 | addInterceptors、addPathPatterns |
| 文件上传失败 | 默认大小限制 | spring.servlet.multipart 配置 |
心得:排查 SpringMVC 问题,我最常做的一件事就是把日志级别调到 DEBUG,重点看
RequestMappingHandlerMapping输出的映射信息。它会把所有已注册的 URL 和方法列出来,你一眼就能看出自己的接口到底有没有被注册、路径拼接是否符合预期。比自己盯着代码猜高效太多。
5. 调试 SpringMVC 时最实用的小技巧
前边讲的都是知识结构和排查思路,最后分享几个我实际工作里验证过好用的调试手法,都特别简单但特别管用。
第一个就是看启动日志里的映射列表。Spring Boot 启动时控制台会打印RequestMappingHandlerMapping注册的每个接口路径。我曾靠这个方法在一个几百个接口的老项目里快速定位到某接口路径冲突:两个 Controller 里的方法映射到了同一个 URL,启动时能明显看到重复注册的日志,但没人注意。接口调用时永远走到先注册的那个,后写的完全失效。
第二个是活用HandlerInterceptor的afterCompletion方法。这个方法无论 Controller 是否抛异常都会执行,非常适合做接口统一耗时统计。我曾在一个项目里用它把所有接口的响应时间记到日志里,定位慢接口时不用改任何业务代码。
第三个是深入理解HandlerMethodArgumentResolver自定义扩展。如果你有一个公共参数(比如当前登录用户)需要在每个接口里使用,不要在每个 Controller 方法里重复从 Redis 查询。自定义一个参数解析器,通过HandlerMethodArgumentResolver自动注入,可以把横切逻辑收敛成一行参数声明。我第一次搞这个功能时觉得门槛高,动手之后发现核心逻辑只有几十行,整个项目的 Controller 代码干净了不少。这个扩展点属于 SpringMVC 的高级玩法,但对真实项目的代码质量提升是实打实的。
第四个技巧值得单独说:前后端联调时,408/413 这些不太常见的状态码别忽略。413 通常意味着上传的请求体太大了,要么是 Nginx 的client_max_body_size配置问题,要么是 Spring 的上传限制。我之前遇到过前端上传功能在测试环境正常、生产环境报错,查到最后发现是生产 Nginx 配置没改,和代码一点关系都没有。
写在最后的话:SpringMVC 老归老,但它的设计思路至今仍是 Web 框架里的教科书级范本。很多人觉得 Spring Boot 让一切变得简单,不用再关心底层细节,可一旦遇到问题,底层知识就是你的救命稻草。把这套请求链路的运转逻辑彻底吃透,不管框架怎么变,你都能快速定位问题。我个人的习惯是:每接触一个新框架,先看它的请求入口和核心处理类的源码,不求全懂,但求在脑子里画出一条完整的链路图。耐心看下来,你会发现在 Java Web 这条路上,地基越扎实,上层走得越稳。