我第一次接触SpringMVC时,最难受的点不是语法,而是搞不懂一个HTTP请求到底经历了什么。学Servlet阶段很直白:请求进到某个Servlet的doGet或者doPost里,方法自己写,自己处理,自己跳转。到了SpringMVC,我发现整个项目里没有一个类叫“HelloSpringMVC”,请求总是先进一个叫DispatcherServlet的类,然后不知道怎么就跳到了我写的某个Controller方法里。这个黑盒一度让我非常焦虑。
后来项目做多了我才明白,SpringMVC的真正价值,恰恰就是把Servlet阶段那些重复劳动——接收参数、类型转换、分发到具体处理方法、选择视图——全部收编成了一套固定流程。你只需要把“流程中的零件”放到正确位置,请求就会自动找到对应的代码。这篇内容就做两件事:先带你把第一个SpringMVC程序完整跑起来,再一层层拆开DispatcherServlet的调度逻辑。适合刚学完Java Web、准备进入框架阶段的读者,也适合面试前想系统捋一遍执行流程的同学。
1. 为什么第一个程序必须"跑通再说":环境与工程骨架
1.1 环境选型:不是越新越好
身边好几个新手朋友一开始就选了最新版本,结果一个莫名其妙的报错能查一整天。对于第一个SpringMVC程序,我强烈建议用保守、稳定、资料最多的组合,不要追新。原因很简单:框架本身的意义是帮你降低复杂度,如果版本组合引入新的坑,你就分不清到底是代码写错了还是环境不兼容。
我这边推荐的组合是 JDK 8 + Spring 5.3.x + Maven 3.6+ + Tomcat 9。Spring 5.3.x 是Spring 5系列的长期维护版本,官方在兼容性上做得很好,市面上绝大多数教程、博客、面试题都基于这套体系。Tomcat 9对应Servlet 4.0规范,和Spring 5.3配合,不需要处理命名空间迁移的问题。
如果你非要用Spring 6.x,那么JDK必须升到17以上,同时相关依赖包名从javax.servlet变成jakarta.servlet。这不是不行,但第一个程序不建议这么折腾。记住:第一个项目的核心目标是“跑通”和“看懂流程”,不是“尝鲜”。
1.2 Maven依赖:少一个都启动不起来
用Maven管理依赖能省掉大量手动拷贝jar包的工作。创建一个war包结构的工程,核心依赖只有三个:spring-webmvc、Servlet API、JSTL。
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</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</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>注意Servlet API的scope是provided。这意味着部署时这个jar不会被打进war包,因为Tomcat容器本身就提供了Servlet实现。如果你写成默认的compile,Tomcat启动时可能出现类冲突,打印一堆警告。这个细节虽然不致命,但会干扰你排查问题。
JSTL是JSP页面里用来做循环、格式化等操作的标签库。第一个程序用它来渲染页面里的动态数据,所以提前引入。
1.3 工程骨架:把文件放对位置
SpringMVC程序本质上还是一个运行在Servlet容器里的Web应用,所以目录结构必须符合Web应用的约定。一个标准的Maven war项目长这样:
src/main/java Java源码 src/main/resources 配置文件 src/main/webapp 页面资源 src/main/webapp/WEB-INF web.xml和受保护页面这里要特别解释一下WEB-INF目录。Servlet规范规定,WEB-INF下的资源不能被浏览器直接通过URL访问,只有服务端内部转发才能拿到。所以我们的JSP页面放在WEB-INF/views/下面,既能防止用户直接看到JSP源码,也是SpringMVC视图解析器常用的做法。
工程创建好后先不急着写代码,先把Tomcat配置好,让一个空的war包能正常启动。这一步排除了环境故障,后面写代码时才不会把环境问题归咎于配置。
2. 从web.xml到注解驱动:配置DispatcherServlet的两种主流姿势
2.1 经典XML方式:先看懂Servlet容器如何找到SpringMVC
第一个程序最直观的配置方式是在web.xml里注册DispatcherServlet。这种方式虽然啰嗦,但它能清楚地展示SpringMVC和Servlet容器之间的关系。
<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"> <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:spring-mvc.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>这里有两个关键点。
第一个是load-on-startup设置为1,目的是让Tomcat启动时就创建DispatcherServlet实例并调用它的init方法,而不是等第一个请求来了才初始化。SpringMVC的组件扫描、HandlerMapping初始化等工作都在init阶段完成,如果这里设置成0或者不写,你会看到第一次请求特别慢,而且可能把初始化报错推迟到运行期。
第二个是url-pattern设为/。这个斜杠表示让DispatcherServlet接管所有不匹配其他Servlet的请求。它不代表“只处理根路径”,而是“处理几乎所有请求”。这么做的目的是让SpringMVC成为Web层的前端控制器,统一入口。但副作用是CSS、JS、图片等静态资源也会被它接管,这就是后面高频404问题的源头,第5.1节会细说。
使用XML方式时,通常还会配置ContextLoaderListener来加载根容器。这里不展开复杂概念,你只需要先知道:DispatcherServlet自己有一个容器,也就是spring-mvc.xml对应的那个,Controller组件扫描在里面完成。根容器是可选的,用于配置数据源、Service等全局Bean。
2.2 零XML方式:基于Servlet 3.0的自动注册
Servlet 3.0规范引入了一个机制:Web应用启动时,容器会自动扫描实现了特定接口的类,并用它们动态注册Servlet。SpringMVC基于这个机制提供了AbstractAnnotationConfigDispatcherServletInitializer。我们只需要继承它,Tomcat启动时就会自动注册DispatcherServlet。
public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class<?>[]{ RootConfig.class }; } @Override protected Class<?>[] getServletConfigClasses() { return new Class<?>[]{ WebMvcConfig.class }; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } }三个方法各有分工。getServletMappings返回URL映射,这里和XML方式一样是/。getServletConfigClasses对应DispatcherServlet自己容器里的配置类,Controller扫描、视图解析器都放这。getRootConfigClasses对应根容器,放Service、数据源等业务层的Bean。
这个配置会取代web.xml,你可以直接把原来的XML文件删掉。然后把原来的spring-mvc.xml改写成Java配置类:
@Configuration @ComponentScan("com.example.controller") @EnableWebMvc public class WebMvcConfig { }@EnableWebMvc相当于在XML里开启SpringMVC默认配置。如果你不加这个注解,Spring只会把@Controller当普通Bean注册,而不会建立请求映射,访问时永远404。这个注解是新手特别容易漏的。
2.3 两种方式怎么选
我的建议是:新项目用Java注解方式,因为更简洁,也符合现代Spring Boot时代的习惯;但老项目里XML方式依然大量存在,面试更是高频考点。所以你不应该只背一种,而是要能看着XML说出它对应Java配置里的哪个类、哪个方法。
理解这两种配置方式还有个额外好处:当你以后用到其他基于Servlet容器的框架时,比如学习WebSocket或自定义ServletFilter,你就知道Filter和Servlet分别是怎么注册的,不会再被各种配置案例搞晕。
3. 第一个Controller是怎么被"接住"的:请求映射与参数绑定
3.1 最小Controller示例
配好环境后,写第一个Controller其实是很快乐的事。它比Servlet类清爽太多。来看这个经典例子:
@Controller public class HelloController { @GetMapping("/hello") public String hello(@RequestParam(value = "name", defaultValue = "world") String name, Model model) { model.addAttribute("message", "Hello, " + name + "!"); return "hello"; } }整个方法就做三件事:接收参数、往Model里放数据、返回视图名。没有HttpServletRequest的强制依赖,没有response.getWriter()的样板代码,参数通过方法签名自动绑定,Model在方法参数里直接拿到。这就是SpringMVC注解驱动带来的直观感受。
注意这里用的是@Controller而不是@RestController。@Controller表示该类对应页面跳转,方法返回的字符串会被视图解析器解析成视图名。如果你用@RestController,Spring会把返回值直接序列化成JSON写进响应体,根本不会走视图渲染。第一个程序要做页面,所以用@Controller。
Model可以理解成一个专门用来携带页面数据的Map集合。你在方法里model.addAttribute,在JSP页面里就能用EL表达式读取。
3.2 请求映射的两种常见形态
@GetMapping是@RequestMapping(method = RequestMethod.GET)的缩写。除了路径匹配,SpringMVC还支持路径参数和查询参数两种最常见的参数传递方式。
路径参数适合 REST 风格地址,例如:
@GetMapping("/user/{id}") public String user(@PathVariable("id") Long id, Model model) { model.addAttribute("userId", id); return "user"; }访问/user/123时,id变量就被绑定为123。查询参数则对应@RequestParam,比如/hello?name=spring。
这套绑定机制背后是HandlerAdapter在干活。它会先解析方法的参数列表,再根据参数注解和类型,从HttpServletRequest里取出对应值,调用类型转换器转换成Java类型,最后把干净的参数传给方法。所以开发者不用关心servlet层面的数据获取,只需要声明清楚“我要什么”。
3.3 视图解析器:return "hello" 是怎么变成JSP页面的
return "hello"之所以能跳到/WEB-INF/views/hello.jsp,是因为配置了InternalResourceViewResolver。XML方式配置如下:
<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>Java配置方式更简单,在WebMvcConfig里实现WebMvcConfigurer接口,重写configureViewResolvers即可:
@Configuration @EnableWebMvc @ComponentScan("com.example.controller") public class WebMvcConfig implements WebMvcConfigurer { @Override public void configureViewResolvers(ViewResolverRegistry registry) { registry.jsp("/WEB-INF/views/", ".jsp"); } }InternalResourceViewResolver做的事就是字符串拼接:视图名hello加上前缀/WEB-INF/views/,加上后缀.jsp,得到内部目标/WEB-INF/views/hello.jsp,然后使用容器内部的RequestDispatcher把这个请求转发过去。
这里还藏着一个重要约定:当你返回的字符串以redirect:开头时,比如return "redirect:/hello",解析器就不会拼接JSP路径,而是生成一个重定向响应。同理,forward:前缀表示内部转发。这两个前缀是排查跳转问题时经常遇到的,值得记一下。
4. 执行流程拆解:一次HTTP请求在SpringMVC内部的旅行
4.1 DispatcherServlet凭什么当"中央调度员"
SpringMVC采用的是一种叫“前端控制器模式”的设计。DispatcherServlet就是这个前端控制器,它像公司前台:所有请求先到前台,前台不会自己处理业务,而是按规则找到对应的部门,再把客户带过去。
按Servlet规范,任何Servlet都要实现service方法。DispatcherServlet也不例外。具体地说,它继承自FrameworkServlet,FrameworkServlet重写了service方法,把各类HTTP方法统一收敛到processRequest,最终调用doService。doService里会做一些上下文初始化,随后核心方法doDispatch登场。
doDispatch是整个SpringMVC的心脏,大概可以分为三个阶段:查找处理器、执行处理器、处理返回结果。下一节我会结合组件逐个讲。
4.2 参与调度的核心组件
执行流程涉及一批固定组件。先看一张对照表,建立整体印象。
| 组件 | 默认实现 | 职责 |
|---|---|---|
| DispatcherServlet | 自己 | 中央调度,贯穿整个过程 |
| HandlerMapping | RequestMappingHandlerMapping | 根据URL找到对应的Controller方法 |
| HandlerAdapter | RequestMappingHandlerAdapter | 调用Controller方法,完成参数绑定 |
| HandlerExceptionResolver | ExceptionHandlerExceptionResolver | 统一处理异常 |
| ViewResolver | InternalResourceViewResolver | 把逻辑视图名解析成物理视图并渲染 |
这些组件默认都在SpringMVC启动时自动注册。你不需要手动声明RequestMappingHandlerMapping,只需要保证开了@EnableWebMvc或者XML里有<mvc:annotation-driven/>。
4.3 一次请求的完整时间线
现在把前面写的/hello请求完整走一遍。
第一步,浏览器发起GET /hello。Tomcat的Connector收到请求,经过Engine、Host、Context一层层匹配,最终定位到部署的这个Web应用。
第二步,容器按URL匹配Servlet映射。因为DispatcherServlet的映射是/,且没有别的Servlet更精确地匹配/hello,请求进入DispatcherServlet。
第三步,doDispatch开始执行。它调用getHandler,遍历所有HandlerMapping,查询哪个映射能处理/hello。RequestMappingHandlerMapping内部维护着一张映射表,写着/hello对应HelloController.hello方法。匹配成功后会返回一个HandlerExecutionChain,它不光包含Controller方法本身的信息,还包含要执行的拦截器列表。
第四步,DispatcherServlet继续查找哪个HandlerAdapter支持这个处理器。为什么需要Adapter?因为SpringMVC不止支持@Controller这种写法,早期版本还有实现Controller接口的处理器、HttpRequestHandler处理器等。不同处理器调用方式不同,Adapter就是用来统一调用入口的。我们这种注解方法对应的是RequestMappingHandlerAdapter。
第五步,HandlerAdapter执行hello方法。它分析参数列表,发现需要名为name的String参数,就从request里取值,发现值不存在则使用注解里的默认值world。方法执行完返回字符串hello,同时Model里已经放入了message。
第六步,HandlerAdapter把方法返回值包装成ModelAndView对象,视图名是hello,Model数据包含message。随后它会把HandlerExecutionChain里的拦截器的postHandle方法调一遍。
第七步,DispatcherServlet拿到ModelAndView,开始解析视图。InternalResourceViewResolver把hello变成/WEB-INF/views/hello.jsp,创建一个JstlView。
第八步,视图渲染。JstlView使用RequestDispatcher转发到目标JSP,JSP执行后生成HTML,写入响应体。
第九步,DispatcherServlet处理完毕,执行拦截器的afterCompletion,回收资源,一次请求结束。
这条链路其实就构成了SpringMVC的执行流程:请求 → DispatchServlet → HandlerMapping → HandlerAdapter → Controller → ModelAndView → ViewResolver → View → 响应。我自己面试别人时,只要能把这条链路讲清楚,通常就能过框架原理这一关。
4.4 一个容易理解偏的点:Controller方法到底是谁调的
很多初学者认为请求是“Servlet调用了Controller方法”,这个说法不准确。DispatcherServlet是Servlet,但真正执行Controller方法的是RequestMappingHandlerAdapter。
Adapter在调用前会做大量准备工作:处理@RequestBody、@RequestParam、@PathVariable等参数注解,完成类型转换,处理@ExceptionHandler方法,甚至对Controller方法做AOP增强。打个比方,DispatcherServlet是叫号台,Adapter才是把你领到具体工位的部门主管。
理解这一点的重要性在于排查问题时思路更清晰。比如参数绑定失败报400,你要去查的是HandlerAdapter相关的参数解析器,而不是DispatcherServlet;比如拦截器不生效,你要查的是HandlerMapping构建的HandlerExecutionChain,而不是Controller本身。
4.5 执行流程里容易被忽略的附加环节
主链路之外还有两个环节值得知道。
拦截器机制横在主流程上。HandlerMapping在返回执行链时会把匹配的拦截器一并挂上。拦截器的preHandle在Controller执行前调用,postHandle在Controller执行后、视图解析前调用,afterCompletion在请求彻底完成后调用。登录校验、日志记录都适合放在这个环节。
异常处理也渗透在流程中。Controller方法抛出异常后,DispatcherServlet会调用HandlerExceptionResolver链。默认的ExceptionHandlerExceptionResolver能找到@ControllerAdvice类和@ExceptionHandler方法,把你定义好的异常处理逻辑执行一遍,返回错误页面或JSON数据。这套机制让Web层可以做到“业务代码不写try-catch,异常统一收口”。
5. 新手翻车现场:五个高频报错与排查思路
5.1 静态资源404:DispatcherServlet把一切请求都抢走了
第一个让人抓狂的问题是:页面能打开,但CSS样式全没了,控制台复制出来的CSS地址单独访问也是404。
原因前面说过,DispatcherServlet的映射是/,Tomcat默认的DefaultServlet不再处理静态资源。而SpringMVC默认情况下又没有为静态资源配置处理器,所以CSS、JS、图片全部找不到。
解决办法是在配置里开启默认Servlet处理:
@Override public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { configurer.enable(); }enable()的意思是:当SpringMVC的HandlerMapping找不到对应Controller时,就把请求交回Tomcat自己的DefaultServlet处理。这样静态资源就走DefaultServlet,动态请求走SpringMVC,井水不犯河水。
5.2 "No mapping found for HTTP request with URI"的排查思路
这个问题几乎每个新手都遇到过。关键排查链路是三步走。
第一步,确认Controller真的被扫描到了。在WebMvcConfig里,@ComponentScan的包路径必须覆盖Controller所在的包。如果Controller放在com.example.controller,扫描路径写com.example.service,自然映射不上。
第二步,确认URL写法。浏览器地址栏里的完整路径是http://localhost:8080/工程上下文路径/hello。如果发布后的上下文路径是myweb,那应该访问/myweb/hello,而不是/hello。这是新手最容易搞错的一层,常常出现“单独看路径都对,组合起来就不对”的情况。
第三步,看SpringMVC启动日志。启动时如果映射成功,日志里会打印类似Mapped to public java.lang.String com.example.controller.HelloController.hello(...)的内容。连这个都看不到,说明要么扫描没生效,要么@EnableWebMvc没开。
5.3 启动报ClassNotFound/BeanCreationException:多半是javax和jakarta打架
Spring 5.x和Spring 6.x之间最大的兼容性鸿沟就是Servlet API的包名。Spring 5.x使用javax.servlet,Spring 6.x使用jakarta.servlet。如果你把spring-webmvc升级到6.x,但代码和Tomcat还是javax.servlet版本,启动时就会在加载DispatcherServlet的阶段直接抛NoClassDefFoundError。
同样的道理,Tomcat 9对应javax,Tomcat 10.1才对应jakarta。我第一次用Tomcat 10跑Spring 5项目时也踩过这个坑,当时控制台报错信息一直指向Servlet类的创建失败,但实际上就是包名不匹配。看到jakarta或javax关键字时,先检查版本组合,别在一个方法一个方法地排查业务代码。
5.4 页面报错"Could not resolve view with name 'xxx'":视图解析器没配置好
Controller方法正常执行,返回字符串hello,但页面却抛出javax.servlet.ServletException: Could not resolve view with name 'hello'。
绝大多数原因是InternalResourceViewResolver没有配置,或者配置里没有生效。这里要注意:只有配置了视图解析器,return "hello"才有意义。如果项目里连jsp()解析都没注册,Spring会尝试用默认的UrlBasedViewResolver兜底,找不到就抛错。
另外一个隐蔽原因是JSP文件路径拼错。你配置的前缀是/WEB-INF/views/,但JSP文件实际放在/WEB-INF/pages/,同样会解析失败。这种情况的错误信息本身很有迷惑性,因为它说的是“无法解析视图名”,实际上只是物理文件不存在。先检查物理文件,再看解析器配置,顺序很重要。
5.5 400 Bad Request:类型转换失败的真相
Controller方法接收一个LocalDate参数,前端传的是2025-06-18,结果请求直接400。很多人不理解:浏览器地址栏里明明传了值,为什么SpringMVC不给调用方法?
原因是SpringMVC的类型转换服务不知道如何把字符串2025-06-18转成LocalDate。虽然字符串到Integer、Long这些基础类型的转换是内置的,但JSR-310时间类型需要你明确指定格式:
@GetMapping("/date") public String date(@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, Model model) { model.addAttribute("date", date.toString()); return "date"; }加@DateTimeFormat后,Spring的格式化服务就会按照指定模式解析。如果你的工程里大量使用日期参数,建议在@ControllerAdvice里通过addFormatters注册全局日期转换器,避免每个方法上都重复写注解。这也是一个比较好的“第一次程序之后就值得做”的升级点。
最后说几句体己话
做完第一个程序,你可能会觉得SpringMVC也不过如此——配置几步,写个Controller,跑通链路,完了。但恰恰是这种“不过如此”的感觉,说明你已经跨过了Java Web到框架之间的那道坎。
我个人的建议是,跑通之后别急着学更多花哨功能,先用你在本文第4节看到的流程,打开调试模式,在DispatcherServlet.doDispatch方法里打一个断点,然后发一次/hello请求,跟在调试器后面看着HandlerMapping、HandlerAdapter、ViewResolver一个个被调用。这个过程对理解的帮助胜过读三遍源码分析文章。等你清楚地看到ModelAndView对象是在哪一步被创建、在哪一步被消费的,再回来看@ResponseBody、@RequestBody、拦截器这些概念,就会觉得它们只是流程上某个节点的具体实现而已。