☰
SpringMVC核心原理与实战:从请求流程到拦截器排错
2026/10/1 18:21:04 网站建设 项目流程

SpringMVC这名字,但凡搞过Java后端的同学都不会陌生。哪怕现在Spring Boot已经大行其道,Controller、Service、Repository这套Web层的核心机制,骨子里跑的还是SpringMVC那套东西——深入理解它的工作流程、请求处理链路和拦截器机制,对排查线上问题、做二次封装、甚至哪天要手写框架,都有实打实的好处。不少朋友问我,SpringMVC到底怎么学才不算白学?这篇博文我就把自己从入门到干活的经验一次性梳理出来,围绕SpringMVC的工作流程、参数绑定、数据返回、拦截器实战和经典排错这五块展开,只讲实战中用得上的东西,适合刚学完Java基础、准备啃Web框架的同学,也适合已经会用Spring Boot但想补一补底层原理的开发者。

1. SpringMVC整体设计:一次HTTP请求的完整旅程

1.1 DispatcherServlet:所有请求的“前台接待”

SpringMVC的核心是一个叫DispatcherServlet的Servlet,它本质上就是一个前端控制器。所有进来的HTTP请求,第一步都会被它拦下来,然后由它统一调度其他组件去干活。你可以把它理解成公司前台:客人来了,前台不会自己直接处理业务,而是先确认你要找谁,然后把你带到对应的部门去。

当你在web.xml里配置了SpringMVC后,容器启动时会初始化DispatcherServlet。只要请求路径命中它的url-pattern,就会进入SpringMVC的请求处理管道。一个完整的请求生命周期大概是这么走的:

  1. 浏览器发出的请求被DispatcherServlet接收;
  2. DispatcherServlet根据请求URL,询问HandlerMapping找到对应的Controller方法;
  3. 命中后会生成一条HandlerExecutionChain,其中可能包含多个拦截器;
  4. 在真正调用Controller方法之前,拦截器的preHandle会先执行;
  5. HandlerAdapter负责真正调用Controller方法,框架会自动完成参数绑定;
  6. Controller方法执行完,返回ModelAndView(或者被@ResponseBody直接写回数据);
  7. 返回给DispatcherServlet后,拦截器的postHandle执行;
  8. ViewResolver把逻辑视图名解析成真正的视图,渲染HTML,响应回浏览器;
  9. 最后执行拦截器的afterCompletion,回收资源或记录日志。

我当年第一次看这个流程的时候,最大的困惑是:为什么搞得这么绕?直接请求Controller不就行了吗?后来才明白,这么设计的核心目的是解耦。DispatcherServlet不关心业务逻辑,HandlerMapping只负责“找”,HandlerAdapter只负责“调”,ViewResolver只负责“渲染”,每一环都可以独立替换。这正好符合开闭原则——想加功能,不需要把Servlet推倒重来。

1.2 HandlerMapping与HandlerAdapter怎么配合?

HandlerMapping的作用是“根据请求找到处理者”。在注解驱动时代,框架默认用的是RequestMappingHandlerMapping,它会在Spring容器启动时扫描所有标了@Controller和@RequestMapping的方法,把URL和Method的映射关系存到内存里。所以你在浏览器输入一个URL,它能很快定位到对应的Controller方法。

HandlerAdapter就更关键了。它负责把请求参数转换成方法参数、调用方法、再把返回值包装成ModelAndView或者直接写回响应。这里面最核心的一块是参数解析器HandlerMethodArgumentResolver,框架内置了一大堆,比如@RequestParam、@PathVariable、RequestBody都是靠它们解析的。这也是为什么你可以在Controller方法里写各种五花八门的参数,框架都能帮你塞进来。

实际开发中,很多同学直接用了Spring Boot的自动配置,基本不感知这两个组件。但排查问题时你会意识到,很多“接口参数怎么突然收不到”的怪事,本质上都出在参数解析这个环节。所以面试被问“SpringMVC九大组件”的时候,至少要把HandlerMapping、HandlerAdapter、ViewResolver、HandlerExceptionResolver这四件套讲清楚,才算真的懂。

1.3 视图解析与渲染:返回的不一定真的是页面

Controller方法执行完,返回的东西可以五花八门:字符串逻辑视图名、ModelAndView、ModelMap,或者直接通过@ResponseBody输出JSON字符串。如果走的是视图渲染路线,ViewResolver就会介入,最常见的是InternalResourceViewResolver,它会帮你做前缀后缀拼接,比如你返回view,配置了prefix=/WEB-INF/views/、suffix=.jsp,它最终解析到/WEB-INF/views/view.jsp。

注意,服务端渲染这种模式的短板在于前后端耦合,如果项目是前后端分离架构,后端一般就只负责返回JSON数据,渲染的事交给前端框架。所以现在很多项目里,@ResponseBody + 消息转换器的组合比视图渲染更常用。但这不代表ViewResolver就不重要了,你去看老项目的代码,或者接手一些遗留系统,还是会碰到JSP、Freemarker模板渲染。这块原理该懂还是得懂。

关于九大组件,我的建议是别硬背,结合流程去理解:请求来了DispatcherServlet要分发,就必须有HandlerMapping、HandlerAdapter;参数要转换,就有HttpMessageConverter;出异常了,有HandlerExceptionResolver兜底;要解析视图返回页面,则需要ViewResolver。顺着这个思路,九大组件自然就串起来了。

2. 环境搭建与第一个Controller

2.1 依赖与基础配置:别一上来就Spring Boot

很多新手一入门就学Spring Boot,导致连SpringMVC是怎么被装进Web容器里的都不知道。为了把原理讲透,我用传统的SSM工程来演示一遍是如何一步步把SpringMVC跑起来的。先看Maven依赖,项目需要一个web工程,引入spring-webmvc是必须的,另外还需要Servlet API、JSP相关依赖,以及Jackson用于JSON转换。

<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>

这里有个细节需要注意:Servlet API和JSP API的作用域要设成provided,因为Tomcat这类Servlet容器自带这些类库,打包时如果带进去反而会和容器冲突。我见过不少同学把war包里塞了servlet-api,结果部署到Tomcat上启动就报LinkageError,这就是依赖作用域没搞对。

2.2 DispatcherServlet与Spring配置文件:让框架先跑起来

传统Web工程需要配置web.xml,把DispatcherServlet注册进去,并告诉它SpringMVC的配置文件在哪。同时,项目里如果还需要Service层、Dao层的Spring容器,通常会再用ContextLoaderListener加载一个Spring根容器,让父子容器各司其职。

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <!-- 加载Spring根容器,管理Service、Dao等Bean --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 加载DispatcherServlet,管理Controller、拦截器、视图解析器等Web层Bean --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>

url-pattern这里有几个坑。最常被问到的就是:到底配“/”还是“/”。“/”表示除了JSP以外的所有请求都交给DispatcherServlet处理,这样前端页面还能走容器的JSP处理器;“/”会把JSP请求也拦截住,页面渲染就会出问题。所以标准做法是配“/”。

springmvc.xml里,配好组件扫描、注解驱动、视图解析器就能跑通第一个接口了:

<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd"> <context:component-scan base-package="com.example.controller"/> <mvc:annotation-driven/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> </beans>

这里我建议把Controller和Service分开扫,根容器扫Service、Dao,SpringMVC容器扫Controller,避免重复扫描导致Bean管理混乱。虽然Spring Boot时代这些琐碎配置都被自动化了,但理解了这层关系,你在Boot里遇到奇怪的Bean覆盖问题时能快速定位。

2.3 第一个Controller:页面跳转和JSON返回都要会

Controller的写法从最早的实现Controller接口,到后来的@Controller注解,再到现在的@RestController,风格一直在简化。传统写法是这样:

@Controller @RequestMapping("/hello") public class HelloController { @GetMapping("/page") public String toPage(Model model) { model.addAttribute("message", "Hello SpringMVC"); return "hello"; } @GetMapping("/json") @ResponseBody public Map<String, Object> toJson() { Map<String, Object> result = new HashMap<>(); result.put("code", 200); result.put("message", "Success"); return result; } }

sheet说回hello方法,最终会去/WEB-INF/views/hello.jsp,Model里的数据可以通过EL表达式直接取。而toJson方法因为加了@ResponseBody,就会通过Jackson把Map序列化成JSON字符串写回浏览器。这里必须注意:如果springmvc.xml里没有配 mvc:annotation-driven/ ,@ResponseBody很可能不生效,原因是缺少消息转换器的自动注册。我遇到不少报错,接口明明注解都写了,却一直返回404或直接下载JSON文件,十有八九就是漏了这一行。

3. 参数接收与数据返回:日常开发的核心技能

3.1 请求参数绑定的4种常用姿势

Controller方法里的参数绑定,是日常编码使用频率最高的能力之一。我总结了四个最常用的姿势,面试也总考:

第一种,@RequestParam。适合接收URL上的query参数或表单字段,比如/user/query?age=20,方法参数里写@RequestParam("age") Integer age就能拿到。这个注解的required属性默认是true,如果前端没传这个参数,SpringMVC直接报400。所以拿不准前端传不传的时候,可以设置required = false,或者给一个defaultValue。

第二种,@PathVariable。这是REST风格接口的关键注解,适合把参数放在URL路径里,比如/user/101,Controller里写@GetMapping("/user/{id}"),再用@PathVariable("id") Long id接收。用这个方式时,参数名称最好显式声明,不然有些环境下会因为编译参数没开-parameters而解析不到。

第三种,POJO对象绑定。给方法加一个实体类参数,比如public String save(User user),框架会自动把请求参数和User属性对应映射,支持级联属性,比如user.address.city这样的嵌套字段。这个写法在表单提交场景下非常省事,但要注意:请求参数的key如果没有一个匹配的属性,SpringMVC会直接忽略,不会报错,这会让一些问题隐藏起来,排查的时候要留个心眼。

第四种,@RequestBody。这个是前后端分离项目里最常用的,前端以JSON格式把数据放到请求体里,后端用一个实体类接收。注意,它和上面三种完全是两个路子,走的是HttpMessageConverter消息转换器,而且一个Controller方法里只能有一个@RequestBody参数。如果前端提交的是application/x-www-form-urlencoded格式,那@RequestBody就拿不到数据,只能走POJO绑定或@RequestParam。

3.2 从ModelAndView到@ResponseBody,数据到底怎么出去?

服务端渲染时代,Controller返回ModelAndView是最正统的写法,代码看起来就是“准备数据 + 指定视图名”:

@RequestMapping("/detail") public ModelAndView detail(@RequestParam Long id) { User user = userService.getById(id); ModelAndView mv = new ModelAndView(); mv.addObject("user", user); mv.setViewName("userDetail"); return mv; }

这种模式在前后端不分离的时代很自然,但在现在的前后端分离架构里,后端已经不太需要关心视图了。Controller只要把数据通过@ResponseBody输出成JSON,前端拿到数据自己渲染页面。这样切分以后,后端接口可以一批人开发,前端页面可以一批人开发,只要约定好接口契约,两边就能并行推进,互不阻塞。

@ResponseBody底层依赖HttpMessageConverter,其中最关键的是MappingJackson2HttpMessageConverter,它专门负责把Java对象序列化为JSON。也就是说,只要项目引入了Jackson依赖,又开启了mvc:annotation-driven,返回对象时框架会自动完成序列化,你不需要手动处理。

不过要注意序列化的一些小坑。比如日期类型的默认格式不太友好,通常会得到一串时间戳,如果想输出yyyy-MM-dd HH:mm:ss,要么给字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),要么全局配置Jackson的ObjectMapper。另外,遇到循环引用(比如双向关联的实体类),序列化会抛StackOverflowError,解决办法是加@JsonIgnoreProperties或者改用DTO对象,别直接拿实体类往外丢。

3.3 REST风格接口的实战建议

SpringMVC 4.3以后推出了一组组合注解,@GetMapping、@PostMapping、@PutMapping、@DeleteMapping,分别对应HTTP的GET、POST、PUT、DELETE方法。相比一个全面通吃的@RequestMapping,组合注解更容易表达接口语义,代码可读性也更高。比如获取资源用GET,创建资源用POST,更新用PUT,删除用DELETE,URL只描述资源本身,动词交给HTTP方法。

在实际项目里,我一般建议团队内部定好规范:查询类接口用GET,写操作必须用POST或PUT,删除操作我倾向于用POST加一个/delete/xxx路径,因为不少网关和日志系统对DELETE的支持不太友好,而且DELETE请求没法带请求体,某些场景下批量删除会很别扭。这不是说DELETE不好,而是要在团队可维护性和技术落地方案之间做取舍。

另外,RESTful接口在Controller方法返回时,一般搭配ResponseEntity或者自定义统一响应体,比如Result<T>。我个人更推荐统一响应体,里面放code、message、data三个字段。这样不管接口正常还是出错,前端都能用同一套逻辑去处理,排查问题时也不用到处找特殊格式。

4. 拦截器深入实战:登录校验、权限控制与性能日志

4.1 HandlerInterceptor生命周期与执行顺序

如果说过滤器Filter是Servlet规范层面的“门卫”,那SpringMVC的HandlerInterceptor就是业务层面的“安检员”。它定义在SpringMVC框架里,有权限拿到Spring容器的Bean,比如Service、RedisTemplate这些,所以非常适合做登录态校验、接口幂等判断、日志埋点这类业务逻辑。

HandlerInterceptor接口里有三个方法:

  • preHandle:在调用Controller方法之前执行。返回true则放行,返回false则中断请求,Controller不会执行;
  • postHandle:在Controller方法执行完、视图渲染之前执行。此时ModelAndView还没被渲染,可以修改数据;
  • afterCompletion:整个请求处理完毕之后执行,无论Controller是否抛异常都会执行,适合做日志和资源回收。

我见过一个很经典的坑:有人把日志打印放在postHandle里,结果接口抛异常后日志没打出来,找半天原因。其实afterCompletion才是“无论如何都执行”的钩子,想确保日志不丢,就该放在这里。

关于多个拦截器的执行顺序,规则有点像洋葱,先进来的preHandle顺序执行,postHandle和afterCompletion则逆序执行。画个图就是:

  • 拦截器A preHandle → 拦截器B preHandle → Controller → 拦截器B postHandle → 拦截器A postHandle → 视图渲染 → 拦截器B afterCompletion → 拦截器A afterCompletion

同时要注意,如果拦截器B的preHandle返回了false,那么拦截器A的postHandle不会执行,但afterCompletion会执行A的(如果你配置了的话)。这个行为比较绕,但也恰好体现了afterCompletion的定位:保证清理工作一定会做。

4.2 拦截器注册与路径规则的两种写法

在springmvc.xml里注册拦截器,使用mvc:interceptors标签:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

如果你用的是Spring Boot或者JavaConfig方式,则是实现WebMvcConfigurer接口:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/static/**", "/error"); } }

路径匹配规则注意一下:“/**”匹配所有路径,包括多级子路径;“/*”只匹配一层路径。比如/user/list用/*能匹配,但/user/list/detail需要/**才行。这个规律记错的话,你会发现拦截器要么拦不到深层次的接口,要么连静态资源都被拦了。

顺便提一下,Spring Boot 2.0后静态资源默认在classpath:/static等位置,如果拦截器配了/**又没有排除静态资源路径,页面会直接被拦截成一片空白。这个问题在新手里出现频率非常高,下面第5部分会单独展开。

4.3 完整案例:一个能用的登录态校验拦截器

我直接写一个项目里比较常见的登录校验拦截器,场景是:访问业务接口时,检查Session里有没有登录用户。没有就直接跳到登录页,并且带一个业务提示。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求:跨域时会先来一个OPTIONS请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } Object user = request.getSession().getAttribute("LOGIN_USER"); if (user == null) { String ajaxFlag = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(ajaxFlag)) { // AJAX请求返回401状态码,前端统一处理跳转 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } else { response.sendRedirect(request.getContextPath() + "/login?needLogin=true"); } return false; } return true; } }

这个拦截器的几个业务细节值得说一下:

第一,OPTIONS预检请求必须放行。前后端分离经常遇到跨域,浏览器会先发一个OPTIONS请求探路,如果拦截器把预检请求拦下来,接口会出现“CORS error”,看起来像是跨域配置错了,实际上是拦截器把OPTIONS杀掉了。

第二,AJAX请求不能用重定向。普通页面请求被重定向到登录页,浏览器会正常跳转。但AJAX请求接收302后,浏览器默认会偷偷跟着重定向,再拿回来的可能是登录页的HTML,前端拿到的就是ParseError之类的东西,根本没法处理。所以我在拦截器里判断了请求头X-Requested-With,对AJAX直接返回401状态码,由前端统一拦截401并弹出重新登录的提示,这是一个很常见的实践方案。

第三,handler参数可以判断请求是不是找Controller方法。如果handler不是HandlerMethod类型,说明请求的目标不是Controller,可能是静态资源或别的处理器,可以根据业务决定放行还是拦截。有些团队会用这个机制去区分“真正要校验的接口”,避免静态资源也被权限卡住。

4.4 踩坑记录:多个拦截器不生效,先查顺序

拦截器不生效的问题,我接手的项目里遇到过好几次,最常见的三种情况:

第一种,拦截器Bean没被加载。SpringMVC容器只管理springmvc.xml里扫描到的Bean,如果你把拦截器写在另一个配置文件里而没有被加载,配置自然无效。检查方式很简单:在preHandle里打个日志,启动时看有没有输出。

第二种,路径配置没问题,但过滤器把请求拦了。有时候项目里同时有Filter和Interceptor,Filter的doFilter没放行,请求根本到不了DispatcherServlet,拦截器自然就没机会执行。这种情况排查时,先看请求有没有进入Controller,如果进去了拦截器没触发,那就是拦截器本身问题;如果请求根本没进Controller,就要考虑Filter。

第三种,登录拦截器生效了,但登录后的用户信息取不到。这个多发生在同一Session在不同Servlet容器间切换、或者分布式架构下Session没有统一管理的情况。传统Session机制在多节点部署下很容易出现登录态不一致,拦截器通过request.getSession()取数据不可靠。这个场景的解法一般是用Redis共享Session,或者客户端Token方案。拦截器的逻辑不用变,只是取登录用户的地方换成Redis查询。

5. 常见问题与排错实录:那些年我们踩过的坑

5.1 高发问题速查表

我把项目里和SpringMVC相关的常见问题整理成一张速查表,方便你遇到对应现象时快速定位。

问题现象可能原因解决办法
请求404Controller没被扫描、映射路径写错、DispatcherServlet路径配置不对检查component-scan路径,确认@GetMapping和请求URL一致,确认web.xml的url-pattern
@ResponseBody返回JSON失败缺Jackson依赖、没开mvc:annotation-driven添加jackson-databind依赖,配置 mvc:annotation-driven/
中文乱码请求编码不统一、响应头charset缺失配置CharacterEncodingFilter,设置produces,统一页面charset
静态资源404DispatcherServlet拦截了静态资源配置 mvc:resources/ 或 mvc:default-servlet-handler/
拦截器不生效Bean没被容器加载、路径不匹配、Filter先拦截给preHandle加日志,检查拦截路径写法,检查Filter链
接口参数接收到null请求参数名不匹配、@RequestBody格式不对、没加@RequestParam用required和name属性定位,用日志打印前端实际传的参数
日期类型转换报400JSON中的日期格式和Java类型不兼容加@JsonFormat,或配置全局Jackson日期格式
文件上传拿不到MultipartFile没配置MultipartResolver配置StandardServletMultipartResolver,web.xml里配multipart-config

这张表不是全量,但覆盖了我遇到过的高频问题。下面挑三个展开说说,因为它们在面试和实际工作中反复出现。

5.2 中文乱码问题从根源解决

中文乱码是个老生常谈但就是反复出问题的话题。核心是编码不一致:Tomcat的URI默认用UTF-8解码URL中的参数,但如果前端页面是GBK编码的,POST表单提交过来,后端拿到的自然就是乱码;反过来,后端输出JSON时,如果Content-Type里不带charset=UTF-8,浏览器可能按本地默认编码解析,也会出现“口口口”或者“锟斤拷”。

解决请求乱码,项目里一般配置一个CharacterEncodingFilter:

<filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

注意filter-mapping的url-pattern是“/*”,因为要让所有请求都经过编码过滤器。另外Tomcat 8以上版本,URI编码默认已经是UTF-8,所以GET请求的query参数问题不大,重点是POST和响应的charset。

如果用的是前后端分离,更推荐Controller方法直接指定返回的媒体类型和编码:

@GetMapping(value = "/list", produces = "application/json;charset=UTF-8") @ResponseBody public List<User> list() { ... }

或者用@RequestMapping里的produces统一指定。但在企业级项目里,这种硬编码不便于维护,我会建议在配置层面统一设置消息转换器的默认编码,这样所有接口都走同样的配置,不用每个方法都加一遍。

5.3 静态资源被DispatcherServlet拦截怎么处理

静态资源404的问题是SpringMVC初学者必遇到的经典坑。当url-pattern配成“/”后,所有非JSP请求都会进入DispatcherServlet,但是SpringMVC默认没有处理静态资源的能力,所以浏览器请求css、js、图片文件时,HandlerMapping找不到对应的Controller,直接返回404。

解决办法有两种。第一种是用mvc:resources映射静态资源目录,指定规则,比如:

<mvc:resources mapping="/static/**" location="/static/"/>

第二种是用mvc:default-servlet-handler,把没有处理器的请求交回给容器默认的Servlet:

<mvc:default-servlet-handler/>

两种方案的区别在于可控性。mvc:resources更精细,还能配合缓存头配置;default-servlet-handler偷懒省事,但所有SpringMVC没处理到的请求都交给Servlet容器,灵活性差一些。我建议项目里明确维护静态资源的目录规则,统一用mvc:resources,避免某些深层路径被容器处理导致行为不一致。

使用mvc:resources时要注意一个细节:如果你的拦截器path配了/**,静态资源路径虽然能被Servlet处理,但拦截器照样会执行preHandle。我遇到过做过权限校验的接口,静态资源访问直接跳转登录页,页面样式全丢,就是因为拦截器没排除掉/static/**。正确做法是拦截器配置里加上excludePathPatterns("/static/**"),或者拦截器代码里判断handler类型,非HandlerMethod直接放行。

5.4 请求404的“玄学”排查

最后聊聊404问题。SpringMVC的404,可能发生在三个层面:Servlet容器层面、SpringMVC映射层面、视图解析层面。我一般是按下面这个思路排查的:

先看浏览器Network面板,如果请求状态是404且响应体是Tomcat默认的404页面,说明请求根本就没进SpringMVC,优先检查web.xml的servlet-mapping和项目打包部署情况。如果404页面是Spring的错误页或者返回JSON格式的{timestamp, status, error},说明请求进了SpringMVC但Controller没匹配上,这时看两个地方:Controller类有没有加@Controller并放在组件扫描范围内,@RequestMapping的路径和请求路径是不是完全一致(大小写、参数占位符)。如果这两个都正常,就检查IDEA或Eclipse里是否把Spring配置正确加载,有时配置文件没被识别成源代码目录,导致classpath下没有springmvc.xml,DispatcherServlet启动失败,接口自然全部404。

这种排查习惯,比对着报错乱猜高效得多。我经常跟组里的新人说,任何404先分清“是容器层面的404还是SpringMVC层面的404”,能省下东翻西找的大把时间。

最后再多说几句个人体会

做了一年多SpringMVC项目之后,有个很深的体会:框架看着简单,但真要能游刃有余,靠的是对请求生命周期和容器组件的真正理解。有人习惯把所有注解背一遍、把所有配置模板抄一遍,遇到新版本或者定制需求就卡壳。其实SpringMVC的设计思路是很典型的“分工明确、层层传递”,DispatcherServlet是这个流水线的总控,HandlerMapping负责定位,HandlerAdapter负责调用,ViewResolver负责输出,拦截器则像是流水线上的质检开关。理解这一整套,你再看Spring Boot里的Web开发,基本就是“原来那些复杂配置被自动配置接管了”这么一回事。

最后分享一个小技巧:写Controller时,尽量遵循“POJO接收参数 + 返回统一响应体”的风格,不要把HttpServletRequest直接塞进方法签名里到处传递。参数对象化之后,测试好写,接口文档也好生成,后续要加字段也只需要改实体类。如果你还在用原生的request.getParameter去捞参数,趁早改过来,开发效率和代码可维护性会明显提升。SpringMVC这套底层功夫,花时间研究透,绝对值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询