1. 从零开发:第一个SpringMVC程序的完整拆解
1.1 为什么SpringMVC值得新手从零手工搭一遍
直接抢答:因为SpringMVC依然是目前多数Java Web项目在用的核心Web层框架,搞清楚它的一次请求到底走过了哪些环节,你后面看任何Spring Boot项目都会顺畅很多。
很多人学SpringMVC会直接套Spring Boot,点几下就出来一个Web项目,Controller写上几行代码就能跑。这样当然快,但原理层面很容易糊。因为Spring Boot把很多东西帮你做完了,DispatcherServlet也自动注册了,配置也不带你看了,执行链路也被封装得干干净净。这就导致一个问题:一旦遇到404、参数没传对、视图解析失败之类的报错,你定位问题的思路是断掉的。
我个人的看法是,第一个SpringMVC程序老老实实用传统方式手搭一遍。不依赖IDE的自动生成,也不用Spring Boot的自动装配,就是最原始的XML配置方式,从依赖、web.xml、Spring配置、Controller、JSP一路自己写下来。整个过程慢不了多少,但你能亲手摸到SpringMVC的核心脉络,后面再切Spring Boot,你的理解深度是别人拿配置换不来的。
这篇文章要讲的内容就是两条主线:第一行代码怎么落地,请求进来之后到底经历了什么。我会把配置、代码、执行流程全部摊开讲,标注清楚哪些是必须的,哪些是约定俗成的,哪些是新手最容易踩坑的。
1.2 先弄清DispatcherServlet的角色定位
SpringMVC的所有请求处理都围绕一个核心组件展开:DispatcherServlet。你可以把它理解成一个前台调度员,所有人的请求先到它这里,再由它分发给后台各个部门处理。
传统的Servlet项目里,你要自己写Servlet并在web.xml里配置映射,请求一来直接进到你写的Servlet逻辑里,处理完再自己决定跳转到哪里。SpringMVC不是这个思路,它把所有Servlet的处理工作抽象成一个统一的前端控制器,你写的Controller只是这个流水线上的一个环节,不是请求的入口。
这种设计最大的好处是请求处理链路被统一管理了。参数绑定、校验、异常处理、视图解析这些功能全部沉淀到框架层,你写业务代码的时候不用关心这些琐碎逻辑。第一个程序用XML配置的方式搭建,你需要在web.xml里显式声明DispatcherServlet,指定它拦截哪些请求。这一步你能直观感受到SpringMVC和原生Servlet的分界点在哪里。
2. 环境准备:写第一个程序之前要备齐什么
2.1 开发环境和依赖清单
我这边用的全套组合是JDK 8、Tomcat 8.5、Maven 3.6,IDE用最常见的版本即可。综合来看这是最稳的搭配,Spring 5.x的框架要求JDK最低是8,Tomcat 8.5支持Java 8也很成熟。如果你用JDK 11或17也没问题,但注意Tomcat版本也要跟上,JDK版本和Tomcat版本错配经常会导致一些莫名其妙的问题。
核心依赖只有两个,Spring Web MVC和Servlet API,Maven依赖配置如下:
<dependencies> <!-- Spring MVC核心依赖 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <!-- Servlet API,编译和运行时需要,Tomcat自带,所以scope设为provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP页面需要的JSTL标签库依赖 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependency>注意spring-webmvc依赖会把spring-core、spring-web、spring-context这些核心组件一起带进来,不需要额外一个一个加。Servlet API必须加provided,就是为了避免和Tomcat自带的servlet-api冲突。我刚学的时候没加provided,一直报NoSuchMethodError,后来才发现是依赖重复带了。
2.2 项目目录结构规划
推荐先规划好目录再动手写代码,Java Web项目的结构约定很重要,你按约定来写,很多配置的默认值才能对上。
我用的目录结构是标准的Maven war包结构:
springmvc-first-demo/ ├── pom.xml └── src └── main ├── java │ └── com │ └── demo │ └── controller │ └── HelloController.java ├── resources │ └── spring-mvc.xml └── webapp ├── WEB-INF │ └── web.xml └── jsp └── hello.jsp这里用一个虚构的项目名,你实际开发时可以换掉。重点记住webapp目录和WEB-INF/web.xml的位置不能改,这是Servlet规范约定的,改了Tomcat就找不到你的Web应用。我把JSP放在WEB-INF下面,这个细节等下说。
3. 手写第一个SpringMVC程序:配置与代码逐一落地
3.1 web.xml:声明DispatcherServlet并配置拦截规则
第一个SpringMVC程序的起点是web.xml。这里要做的核心操作是注册DispatcherServlet,同时指定它加载哪个Spring配置文件,以及拦截哪些URL。
我贴一份最简但完整的web.xml配置:
<?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容器加载的配置文件位置,init-param会被DispatcherServlet使用 --> <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> <!-- 值越大优先级越低,也代表启动顺序越靠后,这里设为1表示Tomcat启动时尽早初始化 --> <load-on-startup>1</load-on-startup> </servlet> <!-- 拦截所有请求,但不拦截JSP --> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>有几点我实际踩过的细节要说明:
第一,co-textConfigLocation如果不配置,DispatcherServlet会默认去WEB-INF找 -servlet.xml,也就是dispatcher-servlet.xml。新手常常忘了配置这个init-param,结果DispatcherServlet一启动就报找不到文件的错。我这里显式指定到classpath下的spring-mvc.xml,文件位置更自由,也方便管理。
第二,url-pattern配成/和配成/的效果差别很大。配/是拦截所有请求但不包括JSP,JSP还是交给Tomcat默认的JspServlet处理,这样页面渲染才正常。如果配成/,所有请求包括JSP都被DispatcherServlet接管,你的静态资源、页面跳转都会出问题。这是我见过最经典的新手坑之一。
第三,load-on-startup设为1,让DispatcherServlet在Tomcat启动时就完成初始化。如果不设置或设为0,等第一个请求进来才初始化,你会发现启动日志没有任何Spring输出,首次请求却异常缓慢,而且后续的报错排查会多一层判断成本。
3.2 spring-mvc.xml:开启组件扫描、注解驱动与视图解析器
接下来是SpringMVC的配置文件,我一般命名为spring-mvc.xml,放在src/main/resources下。这个文件里要配置三件事:Controller的扫描路径、注解驱动的支持、视图解析器的前缀和后缀。
最小可运行的配置如下:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 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"> <!-- 开启SpringMVC注解驱动,@RequestMapping才能被识别 --> <mvc:annotation-driven /> <!-- 扫码Controller所在的包 --> <context:component-scan base-package="com.demo.controller" /> <!-- 视图解析器,把Controller返回的逻辑视图名转换为真正的JSP路径 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/" /> <property name="suffix" value=".jsp" /> </bean> </beans>这个配置有三个关键点:
annotation-driven之前是初学者最容易忽略的。它注册了一堆默认的处理器适配器、消息转换器、参数解析器。不写的话,你Controller里的@Controller和@RequestMapping虽然能被扫描识别,但没有对应的HandlerAdapter来执行你的方法,启动不报错,一请求就白屏或者报404。
component-scan的base-package你最好精确到controller包,不要直接扫根包。原因后面说,而且扫的范围越大,启动越慢,还会把@Configuration、@Service这类不该由Web层配置管理的类混进来,容易造成事务、切面配置的混乱。
InternalResourceViewResolver是我这里重点解释的一个组件。它的prefix和suffix配合起来做什么呢?Controller返回一个字符串"hello",它就把视图名变成/WEB-INF/jsp/hello.jsp,然后交给内部资源视图类去转发到那个JSP。这种逻辑视图名和物理视图路径解耦的设计,是你后面对接模板引擎、前后端分离的时候也会用到的思路。
3.3 HelloController:注解让代码极简
Controller是这出戏的主角。SpringMVC最有吸引力的地方就在这里,你不用继承任何类,不用实现任何接口,加几个注解,写一个普通方法就可以处理HTTP请求。
package com.demo.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; @Controller public class HelloController { @RequestMapping("/hello") public ModelAndView hello() { // 创建ModelAndView对象,setViewName指定逻辑视图名 ModelAndView mv = new ModelAndView(); // 向模型中放入数据,最终能在JSP页面上通过${message}取到 mv.addObject("message", "Hello SpringMVC World!"); // 逻辑视图名,视图解析器会解析为/WEB-INF/jsp/hello.jsp mv.setViewName("hello"); return mv; } }注意@Controller只是把HelloController标记成Spring容器里的一个Bean,它本身并不绑定任何URL。绑定URL是通过@Controller和@RequestMapping联合完成的:类级别可以加@RequestMapping指定一个基础路径,方法级再加一个/hello,最终拼出完整访问路径。SpringMVC扫描到这个方法后,会把它注册到HandlerMapping里,等着请求来匹配。
返回值我用到ModelAndView,同时携带数据和视图名。这是最标准的MVC风格:Model是数据,View是展示层。极简的写法也可以直接返回String,然后在方法参数里加Model,把数据塞进Model里,效果一样。第一版程序用ModelAndView,概念更好理解。
3.4 JSP页面:把数据渲染给用户
JSP页面放在/WEB-INF/jsp/hello.jsp,我特地放到WEB-INF目录下,是因为WEB-INF下是Web容器的受保护目录,浏览器不能直接URL访问,只能通过服务端转发过去。这样设计的好处是防止用户绕过Controller直接访问视图,多了一层安全边界。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>SpringMVC第一个程序</title> </head> <body> <h2>SpringMVC 运行成功</h2> <p>后端传来的消息:${message}</p> </body> </html>JSP的EL表达式${message}会从请求域中取出HelloController里addObject进去的数据并输出。这里要注意,页面的编码设置成UTF-8,不然中文会乱码。JSP的contentType和web.xml里的字符编码过滤器配合,前后端编码保持一致才不会出现一堆问号。
我在hello.jsp里还写过一个小细节:加了一行显示访问时间的代码,方便你直观感受每一次请求都在动态渲染。不过这个属于锦上添花的用法,核心还是要先把链路跑通。
3.5 部署运行:从Tomcat启动到看到页面
到这里,第一个SpringMVC程序已经具备全部了,剩下的就是打包部署。Maven项目直接在IDEA里执行clean package,生成war包后丢进Tomcat的webapps目录,启动Tomcat,浏览器访问http://localhost:8080/项目名/hello。
有一点我必须单独说:如果你是第一次手写这个流程,启动日志一定要耐心看。看到类似Started Application in X seconds的信息不代表你的项目没问题,你要找的是SpringMVC的初始化日志,会打出一行关于DispatcherServlet和HandlerMapping的启动提示。我见过有人跑起来之后页面直接404,一看日志发现HandlerMapping根本没有注册成功,原因是扫描路径写错。
部署这一步的验证标准是:访问/hello能跳转到hello.jsp页面,并且页面上能看到message的数据。如果只是白屏或者404,就是哪个环节配置有问题,下面第5部分专门说排查思路。
4. 执行流程详解:一个请求从进入到响应到底发生了什么
4.1 请求生命周期总览
你输入http://localhost:8080/项目名/hello按下回车,首先由Tomcat接收这个HTTP请求,识别出它对应你部署的Web应用,然后根据web.xml里的servlet-mapping映射,找到DispatcherServlet,把请求交给它。从这一刻起到响应返回,整个流程串联了SpringMVC的六个核心组件。很多初学者直接把MVC理解成"请求Controller转页面",其实远不止这些。
我先把完整流程列出来,再逐一拆开讲,你可以按这个顺序对着源码或者debug去验证:
第一步,DispatcherServlet接收请求。 第二步,DispatcherServlet调用HandlerMapping,找到处理这个请求的Handler(也就是Controller里的方法)。 第三步,DispatcherServlet调用HandlerAdapter,执行这个Handler,真正进入你的业务代码。 第四步,你的方法执行完毕,返回ModelAndView,包含数据和视图名。 第五步,DispatcherServlet把ModelAndView交给ViewResolver,ViewResolver解析出真正的View对象。 第六步,View渲染数据到页面,返回HTTP响应。
这里给一个流程图,用文字方式表示(实际写文章时做成表格或代码块):
请求 -> DispatcherServlet -> HandlerMapping -> HandlerAdapter -> Controller -> ModelAndView -> ViewResolver -> View渲染 -> 响应核心要记住的是DispatcherServlet是调度中心,其他组件都围绕它运转。
4.2 为什么HandlerMapping和HandlerAdapter不是同一个东西
这是整个执行流程里最绕也最关键的一环,值得单独展开。SpringMVC把"找到能处理的Handler"和"执行这个Handler"分成了两件事,分别由HandlerMapping和HandlerAdapter负责。
我的理解是这样:HandlerMapping做的事情是,根据请求的URL(比如/hello)去查一个注册表,这个注册表里存的是@Controller方法上的@RequestMapping与方法的映射关系。找到之后返回一个HandlerExecutionChain,里面不只是方法本身,还包括拦获这个请求的拦截器列表。你把Controller比作一个服务柜台,HandlerMapping就是柜台的导览员,告诉你这个业务该找几号窗口。
HandlerAdapter做的就是真正去执行找到的那个方法。为什么要单独一个Adapter?因为适配不同种类的Handler。比如有的Handler是注解式的@Controller方法,有的可能是实现特定接口的类,还有后面可能配置REST风格的控制器。HandlerAdapter相当于给不同接口形态的Handler做了统一适配,DispatcherServlet不用关心这个Handler的形态,只需要通过Adapter来调用,从而解耦。
看完这段你就明白,为什么旧版本SSH框架整合中有人只配置HandlerMapping不配HandlerAdapter会出现"请求能找到但执行不了"的现象。我们这里的<mvc:annotation-driven />其实同时注册了RequestMappingHandlerMapping和RequestMappingHandlerAdapter,这就是注解开发能跑通的前提。
4.3 ModelAndView的流转与视图解析
从Controller返回ModelAndView之后,它回到了DispatcherServlet手里。DispatcherServlet拿到它之后并没有直接把JSP渲染出来,而是先交给ViewResolver处理。ViewResolver基于返回的逻辑视图名,结合我们在spring-mvc.xml里配置的前缀和后缀,解析出物理视图路径,并创建对应的View对象。
我用的XML配置里配置的URL视图解析器,最终把hello解析成/WEB-INF/jsp/hello.jsp。ViewResolver只负责把逻辑名翻译成View,不负责渲染。真正的渲染动作由View完成:View从ModelAndView中拿到message数据,通过RequestDispatcher转发到那个JSP,JSP经过Servlet容器编译后输出成HTML,最后返回给浏览器。
很多人在这个环节会犯糊涂:既然ModelAndView已经包含了数据和视图名,为什么还要转发一次?因为SpringMVC要保证视图渲染逻辑和数据处理逻辑完全解耦。Controller返回的不一定是一个具体的JSP路径,它可以是一个视图名,也可以是一个RedirectView,甚至可以由内容协商来决定返回JSON还是HTML。ViewResolver把这种灵活性承担了下来,让后续扩展变得容易。
4.4 一次完整的Debug追踪记录
语言描述终归抽象,我建议你亲自用Debug模式跑一遍。我在IDEA里断点打在DispatcherServlet的doDispatch方法,然后访问/hello。你会看到这个方法的核心处理过程:
请求进入后发现HandlerMapping返回了HelloController.hello方法对应的HandlerExecutionChain,然后HandlerAdapter.invokeHandlerMethod被调用,进入HelloController.hello方法内部。通过变量面板查看Method参数的ReturnValue,能看到addObject进去的message数据和视图名hello。方法返回后,继续往后走,视图名被传入ViewResolver,resolveViewName方法解析出最终视图对象。继续走下去,render方法被调用,RequestDispatcher.forward到JSP,模拟器里能看到最终响应的body就是渲染好的HTML。
这个Debug走下来,你对流程的理解会远超任何文章的描述。如果debug过程中发现某些断点没被触发,说明那个环节根本没被调用,那就回去检查对应配置。
4.5 串起来理解:为什么叫"前后端控制器模式"
SpringMVC这种把所有请求集中到一个前端控制器(DispatcherServlet)再分发处理的模型,学名叫Front Controller模式。它和传统多个Servlet互相跳转最大的区别在于,可扩展点全部被集中在中央处理器的处理链路里。
在传统Servlet项目里,如果你的需求是在每个请求前后都记录日志,你得在每个Servlet的代码里重复实现。在SpringMVC里,只需要注册一个HandlerInterceptor,加进HandlerMapping的拦截器链,就能在所有请求的处理前后执行统一逻辑。如果你接到一个需求,要把所有异常统一处理成友好提示,传统Servlet你要在基类里写或者每个Servlet单独处理,SpringMVC直接用@ControllerAdvice配合@ExceptionHandler就能全局搞定。
这些能力之所以存在,就是因为DispatcherServlet把执行流程沉淀成了固化的链路,每个环节都留下了扩展口子。这也是为什么现在Spring Boot虽然自动配置了DispatcherServlet,但本质上仍是这套流程,只是把配置隐藏起来而已。
5. 常见问题与排查技巧实录
5.1 访问/hello一直404,但项目能正常启动
这个问题在第一个SpringMVC程序里出现频率最高。可能的原因有好几个,按概率排序来排查:
第一排查URL路径。你的项目上下文根路径对不对,Tomcat访问地址应该是http://localhost:8080/项目名/hello,如果你漏掉了项目名,访问的就是根路径,自然404。
第二看HandlerMapping有没有把你写的Controller注册进去。在启动日志里找HandlerMapping的映射信息,看到提示有Mapped "{[/hello]}"方法才能确定映射成功。如果完全看不到你Controller的任何映射,问题大概率在组件扫描的base-package写错了,或者Controller类上没加@Controller注解。
第三查看web.xml是不是没被编译到classes目录,或者Tomcat加载的是一个旧的war包。我遇到过几次,改了代码重新打包没生效,清一下Tomcat的临时目录或重新部署就好。
404排查顺序建议是:URL路径确认,映射日志查找,项目部署确认,逐层验证,不要一上来就乱改配置。
5.2 启动报ClassNotFound或NoClassDefFoundError
这种错误基本上都是依赖没有完整引入或者版本冲突。SpringMVC要求至少引入spring-webmvc,同时需要spring-core等基础组件。如果你用spring-webmvc的依赖没有带上spring-jcl,有时也会因为缺日志组件而启动失败。
另一种典型情况是Servlet API依赖冲突。你用Maven直接引入了Tomcat自带的版本不写成artifactId的servlet-api,一般都会出事。解决办法就是严格按我前面的写法,servlet-api的scope设置为provided,并且统一用Tomcat自带的版本。
如果是JDK版本问题导致NoClassDefFoundError,检查你的项目编译级别是否和JDK一致,IDEA的设置默认会跟Maven编译级别,但有时你模块的Language level被单独改过,就会编译和运行不一致。
5.3 页面返回500,提示视图解析失败
Controller一旦return一个视图名,SpringMVC会调用ViewResolver去解析。如果报的是javax.servlet.ServletException或者找不到/WEB-INF/jsp/hello.jsp,原因通常是前缀和后缀配置不正确,或者JSP文件没有放在WEB-INF/jsp/hello.jsp这个位置。
我建议你在JSP文件创建时核对一下路径的大小写,Linux服务器的大小写敏感,Windows不敏感,本地跑没问题部署到Linux就404,大多是这种大小写不一致导致的。
另外一种情况,你Controller返回的视图名带路径,比如return "hello/world",那么ViewResolver会解析为/WEB-INF/jsp/hello/world.jsp。如果你没那么建目录,就会报找不到。视图名的路径结构和实际jsp路径必须一致。
5.4 页面中文乱码
SpringMVC程序要正确显示中文,需要保证三个环节编码一致:请求编码、响应编码、JSP文件编码。
解决方案是两个地方同时设置。JSP顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>,确保JSP文件本身用UTF-8保存。另外在web.xml中配置一个CharacterEncodingFilter,强制请求和响应都用UTF-8:
<filter> <filter-name>encodingFilter</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>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这个过滤器的作用是,在请求进入DispatcherServlet之前就把编码设置好,否则你通过POST提交的中文在Controller里取出来就可能是乱码。
5.5 常见问题速查表
我针对第一个SpringMVC程序最常见的几个错误,整理成一张排查表,方便你以后遇到问题直接对照:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 启动找不到spring-mvc.xml | 配置了错误的contextConfigLocation路径,或配置文件没在classpath下 | 检查classpath:路径和resources目录是否被标记为资源目录 |
| 访问/hello直接404,无任何Spring日志 | DispatcherServlet没有拦截请求,或项目未部署成功 | 检查url-pattern配置为/,并确认war包部署位置正确 |
| 访问/hello能到Controller但页面404 | 视图解析器前缀后缀不对,或JSP没放对位置 | 核对/WEB-INF/jsp/路径和hello.jsp文件名 |
| Controller的@Autowired注入报空指针 | 组件扫描范围没扫到相关Bean | 扩大component-scan范围或检查是否遗漏@Service/@Repository |
| 页面能打开但没有任何样式 | 静态资源被DispatcherServlet拦截了 | 在spring-mvc.xml中配置静态资源放行 |
| 请求发到Controller时中文乱码 | 缺少CharacterEncodingFilter,或编码不一致 | 配置编码过滤器,统一全链路UTF-8 |
6. 关于HandlerMapping和HandlerAdapter的深入理解
6.1 两个组件各自负责什么
前面流程部分已经透了大概,这里再把它说得更深入一点。HandlerMapping的核心实现类有好几个,我们当前场景用到的是RequestMappingHandlerMapping。它维护的映射表数据来源,就是Spring容器启动过程中,扫描Controller类后收集的@RequestMapping信息。
在第一次请求来之前,HandlerMapping其实已经把映射关系建立完毕了。它不需要等请求来了现找,更像是一本提前编好的索引目录。查找的过程,按照URL路径做精确匹配,假如有多条路径匹配,还会根据优先级排序。这个"提前建立映射"的过程,如果你看Spring的启动日志,就是看到大量Mapping {[/hello]...}输出的那一幕。
HandlerAdapter执行Handler的机制也值得聊。RequestMappingHandlerAdapter内部有一堆参数解析器和返回值处理器,这决定了你的Controller方法可以写各种形状:可以写成无参方法,也可以写@RequestParam接收参数,可以接收@RequestBody的JSON,也可以返回String、ModelAndView、ResponseEntity等等。Adapter在调用你的方法之前,会遍历所有参数解析器,看哪个能处理当前方法的参数列表。这就是为什么你在Controller方法里随手声明一个HttpServletRequest参数,Spring会往里塞值,不需要你做任何事。
6.2 没有annotation-driven会不会死
很多人会好奇,如果spring-mvc.xml里漏掉了<mvc:annotation-driven />,会是什么状态。答案是:你的Controller可以被扫描并被注册为Bean,但RequestMappingHandlerMapping不会自动创建,所以没有任何映射被记录。结果就是,你的类上虽然有@Controller,但SpringMVC完全不知道哪些方法对应哪些URL,访问任何一个路径都404。
如果用的是早期Spring版本,情况还会有细微差别,但本质相同,这行配置就是为了启用注解驱动机制。从Spring 3.1开始,它的底层把默认的HandlerAdapter都注册好,包括支持@RequestMapping的RequestMappingHandlerAdapter、支持基于方法名解析的BeanNameUrlHandlerMapping等额外组件。
所以我的建议是,如果你在做XML配置版SpringMVC,annotation-driven这一行是标配,直接写上,不用犹豫。等你换成Spring Boot之后,这行配置由自动配置替代,你反过头来再看这个早期版本的配置,会更有体会。
6.3 DispatcherServlet在父子容器里的边界
还有个比较容易混淆的问题:SpringMVC的DispatcherServlet创建的WebApplicationContext,会以Spring根的ApplicationContext为父容器。如果你的项目里引入了Spring的根容器监听器(ContextLoaderListener),就会存在父子容器两套Bean定义。
这个设计带来的影响是:子容器里可以引用父容器的Bean,但父容器不能引用子容器的Bean,或者说各层有自己的组件扫描范围。在XML时代,一个常见做法是父容器扫Service、Dao,子容器只扫Controller。严格遵循这个边界,事务管理器放在父容器,Controller放在子容器。
不过我给的第一个程序里没有引入ContextLoaderListener,也没有双容器配置,所以Controller、Service、Dao都在同一个容器里也可以工作。初学者建议先从单容器入手,把主流程跑通,再去研究父子容器设计。否则双容器扫描边界配置不当,会出现Controller注入Service为null或者事务不生效的问题。
7. 扩展第一步:从XML配置向更工程化的方向升级
7.1 引入统一异常处理和静态资源放行
第一个程序跑通之后,基本上已经证明了SpringMVC整个链路是通的。接下来你可以接着做两件微创扩展,成本不高,但价值非常明显。
一是处理静态资源。如果你直接访问项目里的一张图片,你会发现404,因为DispatcherServlet拦截了所有URL。你需要在spring-mvc.xml里加上资源映射的配置:
<mvc:resources mapping="/static/**" location="/static/" />这个配置的作用是把/static/目录下的静态资源直接放行,DispatcherServlet不拦截。这个知识点在你做前后端对接、页面引入CSS或JS文件时必用,是第一个程序之后我建议你尽早掌握的配置。
二是集中处理异常。在HelloController之外加一个全局异常处理类,用@ControllerAdvice标注,里面用一个@ExceptionHandler处理所有可能出现的运行时异常,返回一个统一错误页。这样做的好处不仅代码更整洁,而且也能加深你对ControllerAdvice基于AOP发布机制的理解。
7.2 用Restful方式返回JSON而不是页面
SpringMVC最早是面向服务端页面渲染的,但现代开发中大量场景是后端返回JSON,前端自己渲染。把第一个程序升级一下:在Controller里加一个方法,返回一个携带@ResponseBody注解的对象,然后设置produces为application/json,请求这个地址时就可以看到JSON字符串返回,而不是跳转JSP。
现代版的更推荐直接用@RestController来标注类,相当于@Controller加每个方法都带@ResponseBody。理解了第一个程序以后,你来看@RestController其实没有新概念,仅仅是一个组合注解。
这一步扩展我希望大家都做一下,因为它是你从"传统MVC页面开发"跨向"前后端分离API开发"的认知桥梁。从这里开始,视图解析器的作用在你的API项目中逐渐淡出,MessageConverter开始成为主角。
7.3 转向Spring Boot:配置如何被自动接管
当你理解了XML版SpringMVC的手动配置方式,再去升级到Spring Boot会有一个很顺滑的认知比照。Spring Boot的Web自动配置做的事包括:自动创建DispatcherServlet并注册到嵌入式的Tomcat上,自动注册RequestMappingHandlerMapping和RequestMappingHandlerAdapter,自动配置ViewResolver的默认规则,不需要你写web.xml。
这也就是为什么我从一开始就坚持让你手搭XML版的第一个SpringMVC程序。现在很多新同学直接学Spring Boot,遇到问题的时候不知道HandlerMapping存在,也说不清DispatcherServlet在哪里注册的,排查起来无从下手。你先手动配一遍,再对比Spring Boot的自动配置,这种解密式的学习路径,比直接背Spring Boot注解要扎实得多。
8. 我的实操体会与最后一个技巧
第一个SpringMVC程序我前前后后带人写过很多遍,每次自己重新搭一遍都有一些新体会。最大的感受是,这套框架的复杂性不在代码本身,而在配置文件的配合关系里。三个文件,web.xml、spring-mvc.xml、HelloController,加上一个JSP,缺一环就断链。当年我自己学的时候,因为漏了annotation-driven那行配置,整整卡了一下午。后来每次搭项目,都习惯了先确认这行配置在不在,再往下走。
再分享一个小技巧。你要是觉得自己对流程还不够通透,可以试着把DispatcherServlet的doDispatch方法里几个关键点各打一个断点:handlerMappings被调用的地方,handlerAdapter执行的地方,viewResolver解析的地方。每经过一个断点,看一眼调用栈,你会发现整个框架的执行脉络清晰到仿佛在看好朋友写的代码。
最后说一句,SpringMVC的流程一旦用一个最小程序亲手打通,后面你学Spring Boot或者看别人的项目,底层的这套执行逻辑是通用的。所以不要嫌传统XML方式麻烦,这恰恰是理解框架本质成本最低的一条路。如果你在配置过程中碰到了我上面没有提到的问题,欢迎带着你的现象和日志过来一起分析,那种卡住的瞬间,往往就是真正理解的开始。