如果你也跟我一样,最早是靠 Servlet 往浏览器里拼 HTML 写 Web 项目的人,那你一定对“一个登录接口写了两百行”这件事深有体会。我当年在做一个演示系统时,光是把表单里十几个字段从 request 里取出来、转类型、判空、再手动塞回去,就写了一百多行。那段时间我一度怀疑:这到底是写业务,还是在做字符串搬运工?
后来我用了一天时间,照着旧项目里的配置把 SpringMVC 的一个入门程序跑了起来,第一次感受到“原来请求参数可以直接落到方法参数里”这件事。这篇东西不是官方文档的复读,而是把我从依赖配置、web.xml 写法、Controller 返回值到内部请求链路踩过的坑全部摊开来讲,尤其适合刚学完 Servlet、准备投进 SpringMVC 怀抱的同学。
1. 从Servlet手写Web到SpringMVC:一个看似简单却救命的框架
1.1 传统Servlet开发的核心痛点
用纯 Servlet 写一个表单提交接口,流程大概是:先在 doPost 里拿到 request,再用 request.getParameter 挨个取字段,然后手动做 Integer 转换、字符串判空,最后把数据 setAttribute 到 request 域里,再 getRequestDispatcher(...).forward 到下一个 JSP。这个过程的每一步都不难,但组合起来就是灾难。
我印象特别深刻的一次是做一个用户注册接口,字段有十几个,包括普通字符串、整数、日期、邮箱。为了处理日期格式,我在 Servlet 里写了 SmallDateFormat 的 parse 和 try-catch;为了处理必填项,又写了一大堆 if 判断。代码里还穿插着 request.getParameter 和 response.getWriter,写完以后我自己都不想回看。更要命的是,页面跳转和业务逻辑混在一起,参数校验、类型转换、数据封装这些跟“注册用户”本身没关系的琐碎工作占据了主要篇幅。
这就是传统 Servlet 的真实痛点:不是不能写,而是写起来太啰嗦、职责太混乱、复用太差。同一个表单参数,在这个 Servlet 里转一遍,换到另一个 Servlet 又要重新转;同一个页面的跳转逻辑,在 A 接口里 forward,在 B 接口里又 redirect,完全没有统一规范。这样的代码别说做大型项目,就是维护三个月前的自己都费劲。
1.2 SpringMVC的解题思路:谁帮你把零件装好的
SpringMVC 的解法可以简单概括为一句话:把“找谁处理”“怎么处理”“处理完去哪”这三件事从你的业务代码里抽出来,交给框架的固定组件来干。它最核心的入口是 DispatcherServlet,所有请求先到这个“前台接待”,再由它分发给各个 Controller 方法。
这里我用一个公司办事的比喻来解释:你到某公司办事,不用知道合同部具体在哪个房间、谁负责对接,只需要在前台说一句“我要办合同盖章”,前台会查询部门分机号、找到负责人、然后把结果告诉你。DispatcherServlet 就是这个前台;HandlerMapping 相当于前台手里的“部门分机表”,负责根据 URL 找到对应的 Controller 方法;HandlerAdapter 相当于“合同部工作人员”,负责真正调用你的方法;ViewResolver 则是“文档排版员”,把方法返回的逻辑视图名翻译成物理 JSP 路径。
这套设计带来的直接好处是:作为普通开发,你只需要关注 Controller 里那个方法怎么写,其余流程由框架统一调度。经过 Module 演进,SpringMVC 几乎是 Java 后端 Web 项目的默认选项,因为它把一个请求从 URL 到响应页面的全过程都规约化了,大家写出来的代码结构高度一致,后期接手别人的项目也不会像打开盲盒一样痛苦。
2. 搭建第一个SpringMVC项目——依赖、web.xml、Spring配置文件的每步细节
2.1 pom.xml里最少需要哪些依赖
我见过不少新手在搭建项目时,恨不得把 dependency 都来一份,结果引入了一堆版本冲突。实际上一个最基础的 SpringMVC 入门程序只需要两类依赖:spring-webmvc 和 servlet-api/jsp-api(其中 servlet-api 必须是 provided 范围)。再配一个 JSTL,方便 JSP 里使用标签输出。下面这是我本地验证过的一组配置,以 Maven 的 war 包结构为例:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>springmvc-first</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <spring.version>5.3.23</spring.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</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>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies> <build> <finalName>springmvc-first</finalName> </build> </project>这里有三个容易踩的坑。第一,servlet-api 必须加 provided,因为 Tomcat 自己的 lib 目录里已经有实现了,如果按默认 compile 范围打包进 war,运行时会跟 Tomcat 自带版本冲突,轻则警告,重则 NoSuchMethodError。第二,spring-webmvc 本身会带出 spring-core、spring-context 等一堆传递依赖,你不需要全部显式写出来。第三,如果用的是 Tomcat 9,建议配合 javax.servlet-api 4.0.1;如果还在用 Tomcat 7,那 servlet-api 3.0 以下版本更合适。版本号不是越高越好,匹配你的运行环境才最重要。
2.2 web.xml:注册DispatcherServlet的两种方式与“/”和“/*”的区别
在纯注解配置流行之前,web.xml 是注册 DispatcherServlet 的最标准方式。我建议入门阶段就从 web.xml 写起,因为你能看得见 Servlet 和 Spring 容器之间的关系,不会被内置 Tomcat 那些花哨配置蒙在鼓里。
<?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"> <display-name>springmvc-first</display-name> <servlet> <servlet-name>dispatcherServlet</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>dispatcherServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>这里有个核心知识点:<url-pattern>/</url-pattern>和/*不是一回事。/表示只拦截“路径映射”的请求,不会拦截 JSP 请求,Tomcat 内置的 DefaultServlet 依然可以用它来加载静态资源;而/*会把所有请求全部拦进 DispatcherServlet,包括 JSP,结果就是你通过浏览器访问 hello.jsp 时,请求会先被 DispatcherServlet 截胡,而 DispatcherServlet 又没有对应的 Handler 去处理这个 JSP,最后只能返回 404。我看到太多新手把这里写成/*,然后在访问 JSP 页面时抓狂。
另外,contextConfigLocation这个 init-param 指定了 Spring MVC 容器加载哪些配置文件,如果不写,框架会默认去加载 /WEB-INF/dispatcherServlet-servlet.xml 这种名字的配置文件。我们自己写 spring-mvc.xml 放在 src/main/resources 下,所以要通过这个参数显式指向它。
load-on-startup设置为 1,是为了让 Tomcat 在启动时就初始化 DispatcherServlet,而不需要等第一个请求来了才初始化。这在入门阶段的意义是:如果配置有问题,启动时就会报错,而不是等你访问页面才发现。如果你想确认项目启动时到底加载了哪些 Bean,这行配置能帮你早一点发现异常。
2.3 spring-mvc.xml:扫描、注解驱动、视图解析器为什么缺一不可
这是整个入门程序里最容易被忽略、也最容易配置错的文件。很多同学照抄网上的配置,少了某一个标签,项目启动不报错,但一访问 Controller 就是 404,最后排查半天,发现是组件扫描没写。
<?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>第一行context:component-scan是告诉 Spring 去哪个包底下找带@Controller、@Service、@Repository注解的类。如果你的 Controller 写在com.example.controller包下,这里就写这个 base-package;如果写错了,比如写成了com.example.service,Spring 就不会扫描到你的 Controller,请求自然找不到对应处理器,表现就是访问/hello返回 404。
第二行mvc:annotation-driven是最容易让人疑惑的一行。它的作用是注册一套基于注解的处理器映射和适配器,也就是让@RequestMapping注解真正生效。不写这行,虽然你的类上有@Controller,但框架不知道把 URL 和方法绑定到一起,很多效果都会失效。在 Spring 4 之后的版本里,这行几乎是标准标配,不要省。
第三行是视图解析器。它的 prefix 和 suffix 加起来,会把 Controller 返回的逻辑视图名拼接成物理路径。比如方法里写好return "hello",那么拼接后就是/WEB-INF/views/hello.jsp。为什么放在/WEB-INF/views/下面?因为 WEB-INF 目录下的文件不能通过 URL 直接访问,只能经服务端转发,相比直接放在 webapp 根目录更安全,也能避免 JSP 内容被浏览器直接拉到。
看到这里,你应该已经明白:入门程序的骨架由两个文件组成——web.xml 负责启用 DispatcherServlet,spring-mvc.xml 负责告诉 Spring 扫哪些包、开哪些功能、视图去哪找。
3. 第一个Controller:从请求到视图的完整链路
3.1 超简Controller类的正确写法
在配置全部就绪后,我们来写第一个 Controller。先别急着搞花活,一个能跑通的“hello world”才是最重要的。
package com.example.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @Controller public class HelloController { @RequestMapping("/hello") public String hello() { return "hello"; } }同时,在src/main/webapp/WEB-INF/views/hello.jsp中添加:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <body> <h2>Hello Spring MVC!</h2> </body> </html>为什么用@Controller而不是@Component?因为@Controller是 SpringMVC 专门标记“控制器层”的注解,会被处理器映射识别为 Web 层的 Bean。如果你用@Component也能被扫描到,但它缺少 MVC 层的语义化处理,某些情况下还会让请求映射失效。入门阶段不必纠结太深,记住:处理控用的就是@Controller。
@RequestMapping("/hello")表示这个方法处理/hello这个 URL 的 GET 和 POST 请求。方法返回的字符串"hello"不是直接输给浏览器的内容,而是一个逻辑视图名。视图解析器拿到它以后,会结合配置好的 prefix 和 suffix,拼出真实物理路径/WEB-INF/views/hello.jsp,然后通过服务端转发把 hello.jsp 的内容渲染成 HTML 响应给你。
3.2 接收请求参数的三种常用方式
入门程序只返回一个固定字符串肯定不够,实际项目中一定会接到请求参数。SpringMVC 最常见的有三种参数接收方式,我强烈建议你一开始就区分清楚。
第一种是@RequestParam,适合 URL 后面的查询参数,例如/hello?name=zhang:
@RequestMapping("/hello") public String hello(@RequestParam("name") String name, @RequestParam(value = "age", required = false) Integer age, Model model) { model.addAttribute("name", name); model.addAttribute("age", age); return "hello"; }required = false表示这个参数可以没有,如果没有,age就是 null。如果不设默认值,一旦请求缺少必填参数,框架会直接抛 400。这种显式接收方式很安全,也容易读代码。
第二种是@PathVariable,适合 REST 风格 URL,例如/user/123里的 123 作为 id:
@RequestMapping("/user/{id}") public String user(@PathVariable("id") Long id, Model model) { model.addAttribute("id", id); return "user"; }这里{id}占位符会自动绑定到方法参数上,不用自己再用 request.getParameter 去拼。
第三种是对象绑定,适合表单字段特别多的情况。假设有一个 User 类,包含 name、age 两个属性,你只需要让表单里的 name/age 和 JavaBean 属性名一致,方法参数直接写 User:
@RequestMapping("/register") public String register(User user, Model model) { model.addAttribute("user", user); return "register"; }框架会把 request 里的参数按照属性名自动填进 User 对象的字段。这种方式的代码量最少,但我自己在团队内会强调:复杂表单可以对象绑定,但涉及金额、状态码这种需要强校验的字段,还是要手动检查,别完全依赖自动绑定。
3.3 视图返回与重定向:逻辑视图名背后的解析逻辑
理解了 return "hello" 的含义,接下来就要弄明白 return 的其他写法。最常见的三种:返回逻辑视图名、加forward:前缀、加redirect:前缀。
返回逻辑视图名是我们前面写的,内部使用请求转发,地址栏 URL 不变,可以携带 request 域数据。加forward:前缀的效果跟直接返回逻辑视图名本质上是一样的,但它写起来更明确,比如return "forward:/hello"表示转发到另一个 Controller 的/hello路径,而不是找某个 JSP。加redirect:前缀则代表重定向,比如return "redirect:/index",浏览器地址栏会变成/index,同时也是一次新的请求,request 域里的属性不会带过去,如果要传数据,一般用 URL 参数或者 Session。
如果你在入门程序里看到了return "hello"却死活找不到 hello.jsp,最常见的原因就是视图解析器的 prefix 配错了,比如配成了/WEB-INF/而不是/WEB-INF/views/,或者把后缀配成了.html而实际文件后缀是.jsp。这种错误启动时一般不会报错,只有访问页面时才以 404 的形式告诉你。
4. DispatcherServlet内部到底做了什么:一次GET的完整旅程
4.1 Tomcat启动时的初始化准备
很多人把 SpringMVC 看作黑盒,反正配置好就能蹦出页面。实际上,一个 JSP 页面的背后有一条非常明确的执行链路,理解它之后,排查错误会清晰得多。
Tomcat 启动时,会读取 web.xml,创建 DispatcherServlet 实例。由于我们配置了load-on-startup=1,这一步不是在第一个请求才发生,而是容器启动即执行。DispatcherServlet 初始化时会根据contextConfigLocation加载 spring-mvc.xml,建立 Spring 的 WebApplicationContext。在这个容器里,<context:component-scan>会把指定包下带@Controller注解的类注册为 Bean,同时@RequestMapping注解会被解析成一组“URL 路径到处理器方法”的映射关系。
这里我踩过一个印象很深的坑:有一次我改了 Controller 里的@RequestMapping路径,刷新页面发现还是旧地址,折腾半天才意识到我没重启 Tomcat。因为注解映射关系是在 Servlet 初始化时建立的,Spring 不会在运行期热更新这些映射。IDE 自动重部署有时候会帮你重启,但如果你只改了代码没触发重部署,那问题就还在。
4.2 doDispatch方法的前半段:找HandlerMapping与HandlerAdapter
当一个 GET 请求来到/hello时,网络层会把请求交给 DispatcherServlet。它内部最核心的方法是 doDispatch,整个流程可以拆成下面几步。
第一步,DispatcherServlet 会遍历容器里注册过的所有 HandlerMapping,逐个询问“你能处理这个请求吗?”默认的RequestMappingHandlerMapping会根据 URL/hello找到HelloController.hello()方法,并包装成一个 HandlerExecutionChain。这个链条里不仅有所匹配的处理器,还包括一堆对该 URL 生效的拦截器。如果这里有任何一个 HandlerMapping 都匹配不上,框架就会抛 NoHandlerFoundException,最终给你 404。
第二步,拿到 HandlerExecutionChain 之后,DispatcherServlet 需要找到一个能执行这个处理器的 HandlerAdapter。为什么不用反射直接调?因为不同处理器类型需要不同的调用规则:基于注解的方法和继承接口的处理器,参数解析、返回值处理都不一样。RequestMappingHandlerAdapter 专门负责反射调用方法,它内部有一堆参数解析器,比如@RequestParam、@PathVariable等注解都由这些解析器逐个处理。
如果你去掉 spring-mvc.xml 里的<mvc:annotation-driven/>,那么容器里就不会注册 RequestMappingHandlerMapping 和 RequestMappingHandlerAdapter,此时即使你的类上有@Controller和@RequestMapping,框架也找不到能匹配的处理器,表现就是访问/hello直接 404。这就是我前文说它“缺一不可”的原因。
4.3 doDispatch方法的后半段:调用Controller并渲染视图
确定 HandlerAdapter 后,框架按顺序执行拦截器的 preHandle 方法。如果 preHandle 返回 true,才会进入真正的业务逻辑;如果某个拦截器返回 false,请求就在这里断掉,后面的 Controller 不会执行。这也是 SpringMVC 实现登录校验、权限控制的底层原理。
接下来 HandlerAdapter 调用我们的hello()方法,方法返回字符串"hello"。在调用过程中,SpringMVC 会把 Model 和 HttpServletRequest 等做数据绑定,并收集所有通过model.addAttribute添加的数据,最终封装成一个 ModelAndView 对象。注意,方法参数里的Model不是用来直接传值的,它更像一个包裹容器,注入到方法里然后携带数据返回。
ModelAndView 生成后,doDispatch 进入后半程。首先执行拦截器的 postHandle;然后 DispatcherServlet 根据 ModelAndView 里的视图名,遍历注册的 ViewResolver,找到 InternalResourceViewResolver,并使用 prefix 和 suffix 拼出真实 JSP 路径/WEB-INF/views/hello.jsp。最后执行视图渲染,把 JSP 输出成 HTML,再通过 response 写回浏览器。渲染完后,还会执行拦截器里的 afterCompletion,用来做资源清理。
所以整个过程可以记成一句话:请求先找 HandlerMapping 定位方法,再让 HandlerAdapter 调用方法,最后让 ViewResolver 把返回值翻译成页面。以后看到 404,你就要按这三层去排查:是不是映射没配上?是不是适配器没注册?是不是视图路径拼错了?
5. 入门阶段最容易翻车的六个坑(含排查路径)
5.1 404第一坑:web.xml的URL映射不是“/*”
我见过太多入门程序的第一行错误就出在<url-pattern>写成/*。它会让 DispatcherServlet 接管 JSP 请求,导致访问 hello.jsp 时找不到处理器。排查路径很简单:如果你发现静态资源、JSP 页面统统 404,但登录页的动态 Controller 又能通,那十有八九是这里写错了。改成<url-pattern>/</url-pattern>再重启一次,问题立刻消失。
5.2 404第二坑:视图解析器前缀后缀配错
如果访问/hello后 URL 没有报 500,但最终 404,大概率是前缀和后缀拼出来的路径不对。比如 prefix 少写了一个/,或者后缀写了.jsp而 JSP 文件是hello.html,都会导致找不到文件。排查时可以看日志里有没有类似“Could not resolve view with name 'hello’”的提示,或者直接在浏览器地址栏尝试访问拼接出来的完整路径,看看文件是否真实存在。修改配置后,记得强制重启 Tomcat。
5.3 500第一坑:servlet-api依赖冲突
如果你把 javax.servlet-api 的 scope 写成了 compile,并打包进 war,部署到 Tomcat 后常常会出现java.lang.NoSuchMethodError或者ClassCastException。这是因为你的代码在编译期用的接口方法、类版本和 Tomcat 运行时的不一致。排查方式:打开依赖树看有没有重复的 servlet-api,确认 scope 是否为 provided。如果用的是 Maven,mvn dependency:tree能帮你看到哪些依赖把 servlet-api 带成了 compile 范围。遇到这种问题,建议统一在 pom.xml 里显式声明 provided 的 servlet-api,让版本前置。
5.4 500第二坑:忘了加mvc:annotation-driven
只写了<context:component-scan>,没写<mvc:annotation-driven/>,Controller 类能被扫描到,但@RequestMapping等注解无法生效。常见的报错包括No mapping found for HTTP request with URI,或者直接 404。这种情况的排查要点是:检查 spring-mvc.xml 的 namespace 是否引入了 mvc,同时看看有没有多写空格导致 XML 解析失败。如果你看到日志里报 UnexpectedTokenException,多半是 XML schema 位置写错了。
5.5 404第三坑:包扫描路径不对
你的 Controller 放到了com.example.somepackage,但 XML 里 base-package 写成了com.example.otherpackage,框架会认为是两个完全隔离的世界。这个错误入门的隐蔽性很强,因为它不报错,Spring 正常启动,访问 URL 也进入了 DispatcherServlet,但 handler 永远是 null。排查时可以先确认:你的类上是否有@Controller,然后看 Spring 启动日志里有没有扫描到该包,或者在路径旁写一个空的 CommandLineRunner 测试 Bean 看看有没有被加载。如果确认包路径没问题,再看是不是类名拼错了。
5.6 静态资源被拦截:css/js 全部变成404
把 DispatcherServlet 映射到/后,默认情况下所有请求都会被它接管,包括 /static/css/app.css、/js/app.js 这些静态资源。因为 DispatcherServlet 没有专门的 Handler 去处理它们,于是你发现网页样式掉了,资源请求全 404。解决方式是在 spring-mvc.xml 里加:
<mvc:resources mapping="/static/**" location="/static/"/>这样/static/**模式的请求会直接定位到 webapp 下的/static/目录,由框架的静态资源处理器返回文件内容。只要记住映射和 location 一一对应,资源目录不要放在 WEB-INF 下,就不会走偏。
6. 跑通入门程序后,下一步该练什么
如果你顺利跑完了这个入门程序,看到了页面上的 Hello Spring MVC,那么后面按什么顺序进阶,我根据自己的经验给你四条路。第一条,把请求参数接收练熟,包括表单绑定、JSON 提交、文件上传,这是日常开发最高频的部分。第二条,掌握@ResponseBody和@RestController,把接口返回从 JSP 切换到 JSON,这会让你从“写页面”过渡到“写接口”。第三条,研究拦截器与参数解析器的原理,能理解请求是怎么从 DispatcherServlet 绕到你的方法里的,再来做登录拦截就不会无从下手。第四条,试着用纯 Java Config 替代 XML 配置,不是为了追新,而是为了理解 Spring 容器初始化的本质。
我第一次跑通这个入门程序时,最意外的并不是屏幕上那行 Hello Spring MVC,而是为了这一行 hello,我居然改了三次配置文件:第一次 web.xml 写成了/*,第二次忘了配视图解析器,第三次因为 jar 包冲突花掉半小时。现在想想,这些坑都属于“不知道就怎么都想不通、知道了就再也不会犯”的类型。希望这篇实战拆解能帮你把这三步合并成一步,让你在 SpringMVC 上少折腾几个夜晚。