学习目标:
- 理解 SpringMVC 和 Servlet 之间的关系,知道它解决了什么问题
- 熟悉 Controller 中 Handler 方法和 URL 之间的映射关系
- 掌握 Handler 方法的常见返回值与 JSON 响应方式
- 掌握 Handler 方法接收 key=value、JSON、路径参数等请求数据的方式
- 理解 SpringMVC 的核心流程、静态资源处理、拦截器与异常处理
本章重点:
DispatcherServlet、HandlerMapping、HandlerAdapter的协作关系@RequestMapping的value、method、params、headers、consumes、produces@ResponseBody、@RestController、@RequestBody、@PathVariable- 静态资源映射、拦截器执行时机、全局异常处理
前置知识
回顾一下内容
- 抽象类与模板方法式写法
ApplicationContext的基本概念- Servlet 中 URL 和处理方法之间的关系
- Servlet 中如何通过
request.getParameter()获取请求参数 - Servlet 中如何通过
response响应字符串或 JSON - 注解、反射、Filter、Listener 的基本作用
如果这些内容还不熟,建议先回看前面的 Servlet、Request/Response、会话管理章节。
1. SpringMVC 介绍
1.1 什么是 SpringMVC
SpringMVC 是 Spring 框架提供的 Web MVC 解决方案。它并不是抛弃 Servlet,而是建立在 Servlet 之上,通过一个统一的前端控制器接收请求,再把请求分发到具体的方法上处理。
和手写 Servlet 相比,SpringMVC 的核心价值不是“少写代码”这么简单,而是把这些重复工作交给框架:
- URL 到处理逻辑的映射
- 请求参数的提取与类型转换
- JSON 序列化与响应输出
- 异常处理与统一返回
- 拦截器、静态资源、校验等通用能力
1.2 SpringMVC 和 JavaEE 开发方式的差异
SpringMVC 出现的一个直接原因,就是 JavaEE 原始开发方式里“请求处理逻辑分散、样板代码过多”。
下面这张图可以直观看到两种开发方式的差异:

在传统 Servlet 中,我们的关注点经常混在一起:
| 对比项 | Servlet 阶段 | SpringMVC 阶段 |
|---|---|---|
| 请求入口 | 一个个 Servlet | 一个 DispatcherServlet + 多个 Handler 方法 |
| URL 映射 | 靠 web.xml 或@WebServlet注解配 Servlet |
靠 @RequestMapping 系列注解 |
| 参数处理 | 手动 request.getParameter() |
自动绑定到形参或对象 |
| JSON 响应 | 手动序列化后写回 | @ResponseBody 自动转换 |
| 异常处理 | 每个 Servlet 自己处理 | 可集中到全局异常处理器 |
| 与 Spring 集成 | 需要额外接线 | 天然集成 |
1.3 SpringMVC 的核心流程
SpringMVC 的底层仍然离不开 Servlet 和反射,只是把这套流程框架化了。

完整主流程可以概括为:
- 客户端发起请求。
DispatcherServlet统一接收请求。HandlerMapping根据 URL、请求方法等信息找到目标 Handler。HandlerAdapter调用对应的 Handler 方法。- Handler 返回
ModelAndView或普通对象。 - 如果是视图结果,交给视图解析器;如果是 JSON,交给消息转换器写回响应体。


1.4 本节小结
- SpringMVC 不是替代 Servlet,而是构建在 Servlet 之上的 Web 框架。
- 它的灵魂是“统一入口 + 方法级分发”。
- 它最大的收益,是把请求处理中的通用重复劳动抽离给框架完成。
2. SpringMVC 入门案例
这一节先建立最小可运行链路,知道 SpringMVC 项目是怎样启动起来的。
2.1 基础依赖
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>如果后续要直接返回 JSON,还需要 Jackson:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>Spring和Servlet之间的关系
先看图里表达的层次关系,再理解这几个概念:
- 最外层是
DispatcherServlet,说明 SpringMVC 对外接收请求的统一入口,本质上它自己就是一个 Servlet。 - 中间这一层可以理解为 SpringMVC 的 Web 容器,里面主要放
Controller这类直接处理请求的组件。 - 更里面这一层表达的是业务基础容器,通常放
datasource、service、mapper这类数据访问和业务处理组件。 - 也就是说,请求先进入
DispatcherServlet,再由它去 Web 层容器里找合适的Controller方法;而Controller在执行过程中,又可以继续调用下层容器中的service、mapper、datasource。
如果把这张图翻译成一句更好记的话,就是:
DispatcherServlet负责接请求,Web 层容器负责放 Controller,业务基础容器负责放 service / mapper / datasource,三者协作完成一次 Web 请求。
这张图还想说明一个非常重要的关系:
- SpringMVC 不是脱离 Servlet 存在的,它是建立在 Servlet 之上的。
DispatcherServlet作为 Servlet,负责把底层 Servlet 请求桥接到 Spring 容器中的 Controller 方法。- 所以在 SpringMVC 里,我们平时虽然主要写的是 Controller、Service、Mapper,但请求真正进入应用的第一站,仍然是 Servlet。

2.2 JavaConfig 启动方式
SpringMVC 里最常见的启动入口之一,是继承抽象类 AbstractAnnotationConfigDispatcherServletInitializer。
它本质上是一种模板方法式的写法:
- 框架已经把启动主流程写好了
- 你只需要补充几个关键配置点
- 框架内部会在合适时机调用你重写的方法
public class ApplicationInitialization
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[]{RootConfiguration.class};
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[]{WebConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}这里的三个方法分别解决三件事:
getRootConfigClasses():加载业务层、持久层相关配置getServletConfigClasses():加载 SpringMVC 自己的 Web 配置getServletMappings():把DispatcherServlet映射到哪个路径
2.3 Root 容器与 Web 容器
这个类启动时,通常会构建两个上下文:
- Root
ApplicationContext - Web
WebApplicationContext
两者不是平级无关,而是“父子容器”关系:
- Root 容器适合放
Service、Dao、数据源等 - Web 容器适合放
Controller、视图解析、拦截器、资源映射等

这里建议把它理解成“分层管理”:
- Root 容器更偏应用基础层,负责放业务和数据访问相关组件
- Web 容器更偏表现层,负责放直接面向 HTTP 请求的组件
这样拆分的好处不是为了概念好看,而是为了职责清晰:
Controller负责接请求、收参数、给响应Service负责业务逻辑Mapper/Dao负责数据访问- 数据源、事务管理器这类基础设施放在更底层的容器中
如果把所有组件都混在一个容器里,虽然很多时候也能跑,但层次会变得很模糊;而 SpringMVC 的这套拆分,天然就鼓励你按 Web 层和业务层分开组织。
父子容器关系最重要的不是“有两个容器”这句话,而是它们的可见性规则:
- 子容器可以访问父容器中的 Bean
- 父容器不能直接访问子容器中的 Bean
翻译到当前这个场景里,就是:
- Web 容器中的
Controller可以注入 Root 容器中的Service - Root 容器中的
Service不应该依赖 Web 容器中的Controller
这也正好解释了一个开发中非常高频的问题:
为什么
Controller能注入Service?
因为 Controller 在子容器里,Service 在父容器里,子容器查不到 Bean 时,可以继续向父容器查找,所以能够注入成功。
因此,Root 容器和 Web 容器的拆分,本质上既是容器组织方式,也是系统分层边界。
典型写法如下:
@Configuration
@ComponentScan(
value = "com.cskaoyan",
excludeFilters = @ComponentScan.Filter({Controller.class, EnableWebMvc.class})
)
public class RootConfiguration {
}@Configuration
@ComponentScan("com.cskaoyan.controller")
@EnableWebMvc
public class WebConfiguration implements WebMvcConfigurer {
}2.4 @EnableWebMvc 做了什么
@EnableWebMvc 可以理解为“启用 SpringMVC 的一套核心基础设施”,例如:
RequestMappingHandlerMappingRequestMappingHandlerAdapter- 消息转换器
- 参数绑定相关支持
- 校验器、类型转换器等
也就是说,光有 Controller 类还不够,SpringMVC 还需要一整套“识别注解并调用方法”的运行机制,@EnableWebMvc 就是在接通这部分能力。
2.5 第一个 Controller
@Controller
public class HelloController {
@RequestMapping("/hello")
@ResponseBody
public String hello() {
return "Hello SpringMVC";
}
}如果访问 /hello,请求会交给这个方法处理,最终直接把返回值写到响应体中。
2.6 本节小结
最小 SpringMVC 链路可以概括为:
- 引入
spring-webmvc - 注册
DispatcherServlet - 准备 Root / Web 两套配置
- 启用
@EnableWebMvc - 编写
Controller + @RequestMapping
3. @RequestMapping 注解的使用
@RequestMapping 是 SpringMVC 中最核心的注解之一。可以直接说:
SpringMVC 的灵魂是请求映射,而请求映射的核心就是
@RequestMapping。
3.1 注解定义要点
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String name() default "";
@AliasFor("path")
String[] value() default {};
@AliasFor("value")
String[] path() default {};
RequestMethod[] method() default {};
String[] params() default {};
String[] headers() default {};
String[] consumes() default {};
String[] produces() default {};
}这些属性的作用分别是:
value/path:URL 路径映射method:请求方法限定params:请求参数限定headers:请求头限定consumes:请求体类型限定,关注Content-Typeproduces:响应类型限定,关注Accept
3.2 URL 路径映射 value
最常用的属性就是 value。如果只写这一个属性,可以省略属性名。
@RequestMapping("hello")
@ResponseBody
public String hello(String username) {
return "hello " + username;
}
@RequestMapping("goodbye")
@ResponseBody
public String goodbye(String username) {
return "goodbye " + username;
}value 的类型是 String[],所以也可以映射多个地址:
@Controller
public class UrlMappingController {
@RequestMapping({"hello", "hi", "nihao"})
@ResponseBody
public String hello() {
return "hello demo";
}
@RequestMapping({"goodbye*", "goodbye/*"})
@ResponseBody
public String goodbye() {
return "byebye";
}
}这里要注意两点:
- 可以映射多个 URL
- 可以使用通配符,但日常业务里更推荐清晰、可维护的路径设计
这里和Servlet不一样,URL最前面可以不写“/”
3.3 窄化请求
如果一组接口都属于同一类业务,可以把共同前缀写在类上:
@Controller
@RequestMapping("user")
public class UserController {
@RequestMapping("query")
@ResponseBody
public String query() {
return "user/query";
}
@RequestMapping("create")
@ResponseBody
public String create() {
return "user/create";
}
@RequestMapping("remove")
@ResponseBody
public String remove() {
return "user/remove";
}
@RequestMapping("modify")
@ResponseBody
public String modify() {
return "user/modify";
}
}最终路径 = 类上的前缀 + 方法上的路径。
这种写法的价值在于:
- URL 更有层次
- Controller 更按业务聚合
- 后续维护时更容易看出职责边界
3.4 请求方法限定 method
Servlet 阶段我们用 doGet()、doPost() 区分请求方法;SpringMVC 阶段改成在注解里声明。
@Controller
@RequestMapping("method")
public class RequestMethodLimitController {
@RequestMapping(value = "get", method = RequestMethod.GET)
@ResponseBody
public String methodGet() {
return "Method GET";
}
@RequestMapping(value = "post", method = RequestMethod.POST)
@ResponseBody
public String methodPost() {
return "Method POST";
}
@RequestMapping(value = "double", method = {RequestMethod.GET, RequestMethod.POST})
@ResponseBody
public String methodDouble() {
return "Method GET OR Method POST";
}
}3.5 快捷注解
在实际开发中,更常见的是这些派生注解:
| 注解 | 等价形式 |
|---|---|
@GetMapping |
@RequestMapping(method = RequestMethod.GET) |
@PostMapping |
@RequestMapping(method = RequestMethod.POST) |
@PutMapping |
@RequestMapping(method = RequestMethod.PUT) |
@DeleteMapping |
@RequestMapping(method = RequestMethod.DELETE) |
@RestController
@RequestMapping("/api/users")
public class UserApiController {
@GetMapping
public List<User> list() {
return userService.list();
}
@GetMapping("/{id}")
public User detail(@PathVariable Integer id) {
return userService.getById(id);
}
@PostMapping
public User create(@RequestBody User user) {
return userService.save(user);
}
}3.6 请求参数限定 params
params 适合限制必须携带哪些 key=value 参数。
@Controller
@RequestMapping("params")
public class ParameterLimitController {
@RequestMapping(value = "login", params = {"username", "password"})
@ResponseBody
public String login() {
return "ok";
}
}这表示请求里既要有 username,也要有 password。
3.7 请求头限定 headers
headers 限制的是请求头里的 key。

@Controller
@RequestMapping("header")
public class HeaderLimitController {
@RequestMapping(value = "limit", headers = {"abc", "def"})
@ResponseBody
public String headerLimit() {
return "ok";
}
}3.8 consumes 和 produces
consumes 限定的是 Content-Type,也就是“请求体是什么类型”。
@RequestMapping(value = "consumes", consumes = "application/json")
@ResponseBody
public String contentTypeLimit() {
return "ok";
}produces 限定的是 Accept,也就是“客户端希望得到什么类型的响应体”。

@RequestMapping(value = "produces", produces = "application/json")
@ResponseBody
public String acceptLimit() {
return "ok";
}如果要显式处理字符串响应字符集,也可以这样写:
@RequestMapping(value = "chinese", produces = "text/html;charset=utf-8")
@ResponseBody
public String chinese() {
return "这是中文";
}
3.9 本节小结
@RequestMapping最核心的属性是value- 其他属性是在“路径匹配”的基础上继续做更细的限制
- 日常开发高频使用的是
value、method、@GetMapping、@PostMapping
4. Handler 方法的返回值
前面的案例里我们大多返回的是 String。但在 SpringMVC 中,Handler 方法的返回值远不止这一种。 这个返回值最终会被谁拿到?是 DispatcherServlet。它会根据返回值类型决定后续处理方式:
- 如果是视图相关结果,就走视图解析
- 如果是字符串或对象并配合
@ResponseBody,就写入响应体 - 如果是 JSON 需要的对象结构,就交给消息转换器序列化
4.1 ModelAndView(了解)
ModelAndView 更偏传统 MVC 页面开发。现在前后端分离场景下不是主流,但一定要知道它解决的是什么问题。
它主要面向这样的场景:
- 服务端渲染 JSP 或模板页
- 需要把数据放到视图中展示
- 前后端没有彻底分离
比如有一个 hello.jsp:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
hello ${username}
</body>
</html>如果现在希望访问:
http://localhost:8080/hello?username=xxx并让页面中输出 hello xxx,就可以使用 ModelAndView:
@Controller
public class ModelAndViewController {
@RequestMapping("hello")
public ModelAndView hello(String username) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("/hello.jsp");
modelAndView.addObject("username", username);
return modelAndView;
}
}这里要抓住两个点:
View:告诉 SpringMVC 去展示哪个页面Model:告诉 SpringMVC 页面渲染需要哪些数据
4.2 返回 String 作为视图名
除了显式返回 ModelAndView,也可以直接返回一个字符串作为视图名:
@RequestMapping("hello2")
public String hello2(String username, Model model) {
model.addAttribute("username", username);
return "/hello.jsp";
}这个写法和前面“直接把字符串响应给客户端”是两回事,差别就在于有没有 @ResponseBody:
- 没有
@ResponseBody:返回的String被当成视图名 - 有
@ResponseBody:返回的String被当成响应体内容
这也是 SpringMVC 里一个非常容易混淆的点。
4.3 返回 JSON 的前提
主流开发里,Handler 方法更多是返回 JSON,而不是返回 JSP。
如果要让 SpringMVC 自动把对象转换成 JSON,通常需要具备这几个条件:
- 引入 Jackson 相关依赖,比如
jackson-databind - 启用
@EnableWebMvc - 在方法上或类上增加
@ResponseBody
如果这些条件齐备,SpringMVC 就会把对象、集合、统一响应对象自动序列化为 JSON。
4.4 String 返回值为什么不建议继续裸用
如果方法上写了 @ResponseBody,返回值是 String,当然也能直接响应:
@GetMapping("/string")
@ResponseBody
public String string() {
return "hello";
}但后续并不推荐大量使用这种方式,原因有两个:
- 字符串响应容易引出字符集问题
- 一旦响应结构稍微复杂一点,又得自己手工拼 JSON
所以在真实开发里,一般会优先返回对象、集合或统一 VO。
4.5 直接返回对象
现在最常见的是直接返回普通对象:
@RequestMapping("json")
@RestController
public class JsonController {
@RequestMapping("user")
public User user() {
User user = new User("薛佳", "雪茄", 32);
return user;
}
}SpringMVC 会自动把这个 User 转成 JSON 返回给客户端。
这也是为什么 @RestController 在后续开发里会非常常见:因为大部分接口最终都是响应 JSON。
4.6 @ResponseBody 和 @RestController
@ResponseBody 可以写在方法上,也可以写在类上。
写在方法上:
@Controller
public class DemoController {
@RequestMapping("user")
@ResponseBody
public User user() {
return new User("张三", "123456", 20);
}
}写在类上:
@RequestMapping("json")
@Controller
@ResponseBody
public class JsonController {
@RequestMapping("user")
public User user() {
return new User("薛佳", "雪茄", 30);
}
}写在类上意味着:当前类下所有方法的返回值都直接写到响应体中。
这也是组合注解 @RestController 的来源:
@RestController = @Controller + @ResponseBody4.7 统一响应对象
开发过程中很少直接裸返回数据,更常见的是封装成统一结构,例如:
@Data
public class BaseRespVo<T> {
private T data;
private String msg;
private int status;
public static BaseRespVo<Void> ok() {
BaseRespVo<Void> vo = new BaseRespVo<>();
vo.setMsg("成功");
vo.setStatus(200);
return vo;
}
public static <T> BaseRespVo<T> ok(T data) {
BaseRespVo<T> vo = new BaseRespVo<>();
vo.setData(data);
vo.setMsg("成功");
vo.setStatus(200);
return vo;
}
}@RequestMapping("vo")
public BaseRespVo<String> vo() {
return BaseRespVo.ok("hello vo");
}这样做的好处是:
- 前端更容易统一处理
- 成功、失败、消息、数据结构稳定
- 后续扩展分页、错误码、追踪 ID 更方便
4.8 返回值使用注意
无论你使用 Jackson、Gson 还是其他 JSON 工具,都要注意一个非常实际的问题:
引用类型对象通常都应该提供无参构造方法,以及 Getter / Setter。
原因是很多框架内部都依赖:
- 反射创建对象
- 按属性名读写字段
如果这些基础能力缺失,序列化和反序列化过程很容易出问题。
4.9 本节小结
ModelAndView和返回视图名属于传统页面开发方式@ResponseBody/@RestController解决的是“把返回值直接写到响应体”- 真实项目里更主流的是返回对象、集合或统一响应体
5. Handler 方法的形参
Handler 方法的形参主要负责两件事:
- 接收请求参数
- 接收其他上下文信息
从开发体验上看,SpringMVC 的一个巨大提升就是:你不必再大量手写 request.getParameter() 了。
5.1 key=value 形式的请求参数
先看最基本的例子:
@RequestMapping("user")
@RestController
public class UserController {
@RequestMapping("register1")
public BaseRespVo<Void> register1(String username, String password, String age) {
System.out.println("username = " + username);
System.out.println("password = " + password);
System.out.println("age = " + age);
return BaseRespVo.ok();
}
}请求示例:
localhost:8080/user/register1?username=songge&password=niupi&age=40控制台会输出:
username = songge
password = niupi
age = 40这个过程中最核心的规则是:
请求参数名和方法形参名一致。
SpringMVC 内部帮你做了这些事:
- 通过请求对象获取参数值
- 根据参数名与形参名建立对应关系
- 把拿到的值传给方法执行
5.2 String、基本类型与包装类
上面的案例里全部用的是 String,因为 request.getParameter() 本身拿到的就是字符串。
但 SpringMVC 并不只会处理字符串。它还内置了大量类型转换器,所以我们完全可以这样写:
@RequestMapping("register2")
public BaseRespVo<Void> register2(String username, String password, Integer age) {
System.out.println("username = " + username);
System.out.println("password = " + password);
System.out.println("age = " + age);
return BaseRespVo.ok();
}为什么 Integer 也能接?
因为 SpringMVC 内部会做类似这样的事情:
String ageStr = request.getParameter("age");
Integer age = Integer.parseInt(ageStr);当然实际底层不是你手写 parseInt,而是交给 SpringMVC 的类型转换器体系处理。
开发建议:
- 能用包装类时尽量优先用包装类,如
Integer、Long - 包装类可以为
null - 基本类型不能为
null,在参数缺失时更容易出问题
5.3 Date 类型接收
日期是一个非常典型的“需要格式信息”的场景。
比如这些请求:
localhost:8080/user/register3?username=songge&password=niupi&age=30&birthday=2022/06/21
localhost:8080/user/register4?username=songge&password=niupi&age=30&birthday=2022-06-21SpringMVC 能否直接把字符串转成 Date,取决于它是否知道日期格式。
它通常有两种处理方式:
- 使用默认格式,例如
yyyy/MM/dd - 使用
@DateTimeFormat指定格式
@RequestMapping("register3")
public BaseRespVo<Void> register3(
String username,
String password,
Integer age,
Date birthday) {
System.out.println("birthday = " + birthday);
return BaseRespVo.ok();
}@RequestMapping("register4")
public BaseRespVo<Void> register4(
String username,
String password,
Integer age,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date birthday) {
System.out.println("birthday = " + birthday);
return BaseRespVo.ok();
}这里仍然满足那条老规则:
请求参数名和方法形参名一致。
5.4 数组接收
如果请求里一个参数出现多次,也可以直接使用数组接收。
@RequestMapping("register5")
public BaseRespVo<Void> register5(
String username,
String password,
Integer age,
String[] hobbies,
Integer[] ids) {
System.out.println("hobbies = " + Arrays.asList(hobbies));
System.out.println("ids = " + Arrays.asList(ids));
return BaseRespVo.ok();
}请求示例:
localhost:8080/user/register5?username=songge&password=niupi&age=30
&hobbies=sing&hobbies=dance&hobbies=rap&ids=1&ids=2&ids=3这类场景在业务里并不少见,比如:
- 多选框
- 批量删除
- 批量查询
- SQL 中
IN (...)这类条件
5.5 文件上传 MultipartFile
SpringMVC 对文件上传也做了很好封装,不需要你回到 Servlet 阶段手工处理 multipart 报文。
构造表单时,关键点有两个:
method="post"enctype="multipart/form-data"
<form action="/upload/file" enctype="multipart/form-data" method="post">
<input type="file" name="myfile"/><br>
<input type="submit"/>
</form>多文件上传:
<form action="/upload/files" enctype="multipart/form-data" method="post">
<input type="file" multiple name="myfiles"/><br>
<input type="submit"/>
</form>ApiFox里也可以构造文件上传请求:
如果要让 SpringMVC 正常处理文件上传,通常要做三件事:
- 在 Servlet 层启用
multipart-config - 注册
multipartResolver - 在方法形参中使用
MultipartFile
从 Spring 6 / Jakarta Servlet 这套环境往后看,更推荐使用 StandardServletMultipartResolver。 它直接基于 Servlet 规范处理上传,不再依赖 commons-fileupload。
也要注意一点:使用 StandardServletMultipartResolver 时,仍然需要由 Servlet 容器提供 multipart 支持;在传统 SpringMVC 中,通常就是通过 multipart-config 配置完成。
先在 SpringMVC 配置类中注册解析器:
@Configuration
public class MvcConfiguration {
@Bean("multipartResolver")
public StandardServletMultipartResolver multipartResolver() {
return new StandardServletMultipartResolver();
}
}注意:这个组件的名字必须是 multipartResolver。
然后在 DispatcherServlet 注册阶段补上 multipart-config:
public class ApplicationInitialization
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[]{RootConfiguration.class};
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class[]{WebConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
MultipartConfigElement multipartConfig = new MultipartConfigElement(
"D:\\\\tmp",
20 * 1024 * 1024,
40 * 1024 * 1024,
0
);
registration.setMultipartConfig(multipartConfig);
}
}这里几个参数可以先这样理解:
- 第 1 个参数:上传临时目录
- 第 2 个参数:单个文件最大大小
- 第 3 个参数:整个请求最大大小
- 第 4 个参数:文件写入磁盘阈值
也就是说,StandardServletMultipartResolver 负责“交给 SpringMVC 解析”,而上传大小、临时目录这类底层规则,还是由 Servlet 容器的 multipart-config 决定。
接收单文件:
@RestController
@RequestMapping("upload")
public class UploadController {
@RequestMapping("file")
public BaseRespVo<Void> file(MultipartFile myfile) {
return BaseRespVo.ok();
}
}同样满足命名规则:
请求参数名和形参名一致。
MultipartFile 常用方法:
| 方法 | 作用 |
|---|---|
getOriginalFilename() |
上传时原始文件名 |
getContentType() |
文件类型 |
getName() |
请求参数名 |
getSize() |
文件大小 |
transferTo(File) |
把文件保存到指定位置 |
例如保存到本地:
@RequestMapping("file")
public BaseRespVo<Void> file(MultipartFile myfile) {
String originalFilename = myfile.getOriginalFilename();
File file = new File("D:\\tmp\\", originalFilename);
try {
myfile.transferTo(file);
} catch (IOException e) {
e.printStackTrace();
}
return BaseRespVo.ok();
}多文件上传则可以用数组接收:
@RequestMapping("files")
public BaseRespVo<Void> files(MultipartFile[] myfiles) {
for (MultipartFile myfile : myfiles) {
String originalFilename = myfile.getOriginalFilename();
File file = new File("D:\\tmp\\", originalFilename);
try {
myfile.transferTo(file);
} catch (IOException e) {
e.printStackTrace();
}
}
return BaseRespVo.ok();
}5.6 使用引用类型接收表单参数
如果参数越来越多,全部写在方法形参里会越来越乱。
比如这个请求:
localhost:8080/user/register6?username=songge&password=niupi&age=30
&hobbies=sing&hobbies=dance&hobbies=rap&ids=1&ids=2&ids=3&birthday=2022-06-21完全展开会是这样:
@RequestMapping("register6")
public BaseRespVo<Void> register6(
String username,
String password,
Integer age,
String[] hobbies,
Integer[] ids,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date birthday) {
return BaseRespVo.ok();
}这时候更合适的方式是封装为对象:
@Data
public class User {
private String username;
private String password;
private Integer age;
private String[] hobbies;
private Integer[] ids;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date birthday;
}@RequestMapping("register6")
public BaseRespVo<Void> register6(User user) {
return BaseRespVo.ok();
}SpringMVC 会根据:
- 成员变量名
- 成员变量类型
- Setter 方法
自动完成封装。
这里的核心规则升级为:
请求参数名和引用类型的成员变量名一致。
5.7 什么时候直接用形参,什么时候用对象
可以用一个很实用的判断方式:
- 参数较少:直接写方法形参
- 参数较多:封装成对象
- 多个接口都重复使用的一组参数:封装成公共对象
比如查询接口:
localhost:8080/user/query?username=song&sort=add_time&order=asc&page=1&limit=10其中:
username是业务筛选条件sort、order、page、limit是可复用的分页排序参数
可以这样设计:
@RequestMapping("query")
public BaseRespVo<Void> query(String username, CommonParameter commonParameter) {
return BaseRespVo.ok();
}@Data
public class CommonParameter {
private String sort;
private String order;
private Integer page;
private Integer limit;
}5.8 JSON 请求参数
如果请求携带的是 JSON,请求会有几个明显特征:
- 请求方法通常是
POST Content-Type通常是application/json- 请求体中放的是 JSON 字符串
可以在ApiFox中构造JSON请求
接收 JSON 时,重点是:
形参前增加
@RequestBody
它和 @ResponseBody 刚好一对:
@RequestBody:接收 JSON@ResponseBody:响应 JSON
可以接成三种常见形式:
1. 接收为 String
@RequestMapping("login")
public BaseRespVo<Void> login(@RequestBody String result) {
System.out.println(result);
return BaseRespVo.ok();
}这种方式能接,但一般不推荐。因为接到字符串以后,你还得自己再解析。
2. 接收为对象
@RequestMapping("login2")
public BaseRespVo<Void> login2(@RequestBody User user) {
System.out.println(user);
return BaseRespVo.ok();
}这通常是最常用的方式。适合参数比较多、结构清晰、需要类型约束的场景。
3. 接收为 Map
@RequestMapping("login3")
public BaseRespVo<Void> login3(@RequestBody Map map) {
System.out.println(map);
return BaseRespVo.ok();
}这适合参数较少、结构简单、字段不稳定的场景。
5.9 JSON 日期格式
接收 key=value 形式的请求参数时,我们用 @DateTimeFormat。 但接收 JSON 时,日期解析不再走这一套,而是走 JSON 工具的反序列化规则。
这时应该使用:
@JsonFormat(pattern = "yyyy-MM-dd")例如:
@Data
@NoArgsConstructor
public class User {
private String username;
private String password;
private Integer age;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date birthday;
}这个注解既会影响 JSON 接收,也会影响 JSON 响应时的日期格式。
5.10 其他信息:HttpServletRequest、HttpServletResponse
这两个对象也可以直接写在形参中:
@RequestMapping("reqAndResp")
public BaseRespVo<Void> reqAndResp(
HttpServletRequest request,
HttpServletResponse response) {
return BaseRespVo.ok();
}虽然能用,但不建议滥用。除非确实需要访问原始 Servlet API,否则优先用 SpringMVC 已经提供好的参数绑定能力。
5.11 Cookie 和 Session
如果使用原始方式获取 Cookie,需要从 request 中拿:
@RequestMapping("cookies")
public BaseRespVo<Void> cookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
System.out.println(cookie.getName() + " = " + cookie.getValue());
}
return BaseRespVo.ok();
}浏览器构造 Cookie:

ApiFox 也可以构造 Cookie:
Session 可以通过两种方式获取:
1. 从 HttpServletRequest 获取
@RequestMapping("session1")
public BaseRespVo<Void> session1(HttpServletRequest request, String username) {
HttpSession session = request.getSession();
session.setAttribute("username", username);
return BaseRespVo.ok();
}2. 直接在形参中写 HttpSession
@RequestMapping("session2")
public BaseRespVo<Object> session2(HttpSession session) {
Object username = session.getAttribute("username");
System.out.println("username = " + username);
return BaseRespVo.ok(username);
}5.12 本节小结
- 形参绑定的核心,是“名字匹配 + 类型转换”
- key=value、JSON、数组、文件上传、对象绑定,SpringMVC 都提供了现成机制
- 参数少直接写形参,参数多用对象封装,JSON 用
@RequestBody
6. RESTful 风格接口
REST 是 Representational State Transfer 的缩写。 如果只站在当前课程语境里理解,可以先把它抓成一句话:
它强调通过更清晰的资源路径和请求语义来设计接口。
6.1 传统写法和 RESTful 写法
过去很多人会这样设计用户的增删改查:
/user/query
/user/remove
/user/create
/user/modify更偏 RESTful 的设计会更强调资源本身:
GET /users/1
POST /users
PUT /users/1
DELETE /users/1但在当前很多企业项目里,并不一定会严格纯 REST。大家更常见的是:
- 请求体用 JSON
- 响应体用 JSON
- URL 尽量清晰表达业务语义
也就是说,RESTful 在实际工作里经常是“理念影响设计”,而不是所有项目都按教科书完全落地。
6.2 @PathVariable 获取 URI 信息
@PathVariable 用来获取 URL 路径中的变量值。
@RequestMapping("path/{username}")
public BaseRespVo<Void> path(@PathVariable("username") String name) {
System.out.println("name = " + name);
return BaseRespVo.ok();
}比如访问:
localhost:8080/path/songge就可以把 songge 这一段取出来。
这种方式的价值是:可以把部分请求参数写到 URL 路径中,而不是全部放到 query string 中。
6.3 @RequestParam 获取请求参数
虽然开发里很多场景直接依靠“形参名 = 参数名”就够了,但 @RequestParam 依然很有价值,尤其是在以下场景:
- 形参与请求参数名不同
- 需要显式声明必传
- 需要默认值
@RequestMapping("param")
public BaseRespVo<Void> param(
@RequestParam("username") String username,
@RequestParam("password") String password) {
return BaseRespVo.ok();
}6.4 @RequestHeader 获取请求头
@RequestMapping("header")
public BaseRespVo<Void> header(
@RequestHeader("Accept") String[] accept,
@RequestHeader("Host") String host) {
System.out.println(Arrays.asList(accept));
System.out.println(host);
return BaseRespVo.ok();
}这适合读取:
AcceptHostAuthorization- 自定义请求头
6.5 @CookieValue 获取 Cookie
@RequestMapping("cookie")
public BaseRespVo<Void> cookie(@CookieValue("songge") String value) {
System.out.println(value);
return BaseRespVo.ok();
}相比手工遍历 Cookie[],这种写法简洁很多。
6.6 @SessionAttribute 获取 Session
@RequestMapping("session")
public BaseRespVo<Void> session(@SessionAttribute("username") String username) {
return BaseRespVo.ok();
}
@RequestMapping("put/{username}")
public BaseRespVo<Void> sessionPut(
@PathVariable("username") String username,
HttpSession session) {
session.setAttribute("username", username);
return BaseRespVo.ok();
}6.7 本节小结
RESTful 这一节不要只记注解,更要知道它们分别在拿什么信息:
@PathVariable:路径变量@RequestParam:请求参数@RequestHeader:请求头@CookieValue:Cookie@SessionAttribute:Session
7. SpringMVC 核心流程(拔高)
这一部分是本章最适合拿来面试拔高的内容。 前面所有注解、参数绑定、JSON 转换,背后都依赖这条总流程。
先建立两个共识:
DispatcherServlet处理几乎全部请求- 真实处理业务的是 Controller 里的 Handler 方法
7.1 为什么只开发 Controller 方法就够了
为什么我们平时开发时只要去写 Controller 里的方法?
因为:
- Controller 是 Spring 容器中的组件
- Handler 方法存在于 Controller 组件里
DispatcherServlet会在请求到来时找到容器里的目标 Handler 方法并执行

这里的 Handler,其实通常就是 HandlerMethod,也就是“某个 Controller 中的某个方法”。
只要 DispatcherServlet 能找到 ApplicationContext,它就有机会进一步找到容器中的 Controller 与 Handler。

7.2 DispatcherServlet 和 ApplicationContext 的关系
SpringMVC 阶段使用的是 WebApplicationContext,它本质上仍然是 ApplicationContext,只是额外具备 Web 环境能力。
可以粗略理解为:
ApplicationContext:通用 Spring 容器WebApplicationContext:面向 Web 环境的 Spring 容器
源码层面可以粗略理解成:
public abstract class FrameworkServlet {
private WebApplicationContext webApplicationContext;
@Nullable
public final WebApplicationContext getWebApplicationContext() {
return this.webApplicationContext;
}
}
public class DispatcherServlet extends FrameworkServlet {
}这意味着 DispatcherServlet 本身就能够持有 WebApplicationContext。
7.3 WebApplicationContext 和 ServletContext
WebApplicationContext 比普通容器多了一个很关键的能力:它能接触 ServletContext。
public interface WebApplicationContext extends ApplicationContext {
@Nullable
ServletContext getServletContext();
}这点很重要,因为:
DispatcherServlet和WebApplicationContext可以借助ServletContext共享数据。

7.4 容器在什么时候初始化
既然 DispatcherServlet 要在处理请求之前就拿到容器,那这个容器肯定不能等到 service() 时才临时创建。
通常会在请求处理之前完成初始化,比如:
- Listener 初始化阶段
- Servlet 的
init()阶段
SpringMVC 里,ContextLoaderListener 会参与这个过程:
public class ContextLoaderListener extends ContextLoader implements ServletContextListener {
public void contextInitialized(ServletContextEvent event) {
this.initWebApplicationContext(event.getServletContext());
}
}执行完 Listener 后,可以理解为:一个 WebApplicationContext 已经初始化完毕,并且放进了 ServletContext。

随后,DispatcherServlet 在自己的 init() 生命周期阶段,也会初始化一个属于自己的 WebApplicationContext。 这个新的 WebApplicationContext 不会和前面的容器平级割裂,而是会把前面通过 ContextLoaderListener 放进 ServletContext 的那个 WebApplicationContext 作为父容器。

7.5 发送请求时如何找到对应方法
核心问题来了:
从 URL 到方法,这个分发过程是怎么完成的?
看这张流程图:

可以抓住这些关键节点:
- 请求进入
DispatcherServlet DispatcherServlet调用doDispatch- 在
doDispatch中通过HandlerMapping找到匹配的 Handler - 通过
HandlerAdapter调用目标 Handler 方法 - 处理返回值,再生成最终响应
如果把这条链再拆细一点,可以理解成下面这个过程:
- 浏览器把请求发送到服务器。
- 这个请求先进入
DispatcherServlet。 DispatcherServlet不会自己直接写业务,而是进入核心分发方法doDispatch()。- 在
doDispatch()里,先根据当前请求去找“谁能处理它”,也就是找 Handler。 - 找到 Handler 后,再去找“谁会调这个 Handler”,也就是找 HandlerAdapter。
HandlerAdapter负责把请求参数整理成方法执行需要的参数列表。- 最后通过反射调用目标 Handler 方法。
- 方法执行完后,再处理返回值,决定是走视图还是写回 JSON。
所以这里真正的关键,不只是“找方法”,而是分成了两层:
HandlerMapping:负责找 HandlerHandlerAdapter:负责调 Handler
这两个角色为什么要拆开?
- 因为“找到谁处理”是一类问题
- “找到之后怎么调用”是另一类问题
这样拆开之后,SpringMVC 才能把 URL 映射、参数绑定、返回值处理这些能力组织得更灵活。
7.5.1 DispatcherServlet 为什么不直接调用方法
很多同学第一次学到这里会下意识想:
既然最后就是执行 Controller 方法,为什么不直接在
DispatcherServlet里写死调用逻辑?
因为 DispatcherServlet 是总调度中心,它要处理的是“各种请求、各种 Handler、各种参数、各种返回值”的统一调度。 它更适合负责流程控制,而不是直接耦合到某一个具体 Controller 方法。
所以它做的事情更像:
- 收请求
- 查映射
- 找适配器
- 组织调用
- 处理结果
而不是:
- 自己知道每个 URL 对应哪个类哪个方法
7.5.2 HandlerMapping 在做什么
HandlerMapping 的职责可以理解成一句话:
根据当前请求,找到与之匹配的 Handler。
这个“匹配”依据的就是前面学过的那些映射信息,例如:
@RequestMapping@GetMapping- URL 路径
- 请求方法
- 请求头
- 请求参数限制
也就是说,当前请求能不能进入某个 Controller 方法,前提是先通过 HandlerMapping 这一关。
在注解驱动的开发模式里,最典型的处理器映射器就是:
RequestMappingHandlerMapping它会把 Controller 中标注了映射注解的方法整理成一张“请求条件 -> HandlerMethod”的映射表。 请求进来时,再根据当前请求去查这张表,最终找到一个 HandlerMethod。
这里的 HandlerMethod 本质上就是:
- 哪个 Controller 对象
- 哪个方法
也就是后续反射调用所需要的依据。
7.5.3 HandlerAdapter 在做什么
找到了 Handler 之后,还不能立刻执行。 因为 SpringMVC 还要解决一个更麻烦的问题:
这个 Handler 该怎么调?
比如一个方法可能长这样:
public BaseRespVo<User> save(
@RequestBody User user,
@RequestParam Integer page,
HttpServletRequest request) {
...
}要真正执行这个方法,SpringMVC 还得先准备好:
user这个对象怎么从请求体里转出来page这个参数怎么从请求参数里取出来request这个对象从哪里拿
这部分工作就是 HandlerAdapter 负责的。
它的职责不是“找方法”,而是:
- 识别当前 Handler 属于哪种类型
- 解析方法参数
- 组装执行参数
- 调用目标方法
- 接住返回值
在注解驱动开发里,最核心的适配器就是:
RequestMappingHandlerAdapter7.5.4 为什么最后能执行到 Controller 方法
可以把最终调用过程粗略理解成:
Object[] args = ...; // 由适配器组装好的参数
method.invoke(controllerInstance, args);也就是说,SpringMVC 并不是“魔法调用”到方法上,而是:
- 先找到方法
- 再准备参数
- 最后通过反射执行
所以你前面学过的这些知识,到这里就真正串起来了:
- 注解:用于声明映射关系和参数来源
- 反射:用于最终执行方法
- 容器:用于找到 Controller 实例
- Servlet:用于承接最外层请求
7.5.5 一句话串起来
这一小节如果要用一句最像课堂板书的话来概括,可以记成:
DispatcherServlet负责总调度,HandlerMapping负责找方法,HandlerAdapter负责凑参数并执行方法。
7.6 HandlerExecutionChain
请求真正执行时,不只是单独找到一个 Handler,通常拿到的是一个 HandlerExecutionChain。
它封装了:
- Handler 本身
- 与这个 Handler 关联的一组拦截器
public class HandlerExecutionChain {
private final Object handler;
@Nullable
private HandlerInterceptor[] interceptors;
@Nullable
private List<HandlerInterceptor> interceptorList;
private int interceptorIndex;
}也就是说,一次请求实际上会得到一个“执行链”。
7.6.1 为什么不是只返回一个 Handler
如果 SpringMVC 只是简单返回一个 Handler,那么 Controller 方法当然也能执行。 但问题是,实际项目里一条请求往往还需要额外的横切处理,比如:
- 登录校验
- 权限控制
- 请求日志
- 统一前置检查
这些逻辑如果都写进 Controller 方法里,会导致:
- 业务代码和通用逻辑混在一起
- 重复代码很多
- 方法越来越重
所以 SpringMVC 在“找到 Handler”时,并不是只返回一个方法,而是返回一个执行链。
7.6.2 执行链里装了什么
HandlerExecutionChain 里最核心的是两部分:
handler:真正处理请求的方法interceptors:这次请求会经过的拦截器列表
也就是说,HandlerMapping 找到的不是孤零零一个方法,而是:
这个请求最终要执行哪个 Handler,以及在执行它之前和之后要经过哪些拦截器。
这就解释了为什么拦截器能和某些 URL 绑定,而不是对所有请求无差别生效。
7.6.3 执行链是怎么来的
每次请求到来时,SpringMVC 都会根据当前请求去计算一条新的执行链。 这个执行链不是整个应用共用一份固定对象,而是“按当前请求匹配结果生成”的。
所以不同请求拿到的执行链可能不同,例如:
/hello匹配一组拦截器/admin/**匹配另一组拦截器/static/**甚至可能不进入这一套链路
这也是为什么拦截器配置里经常会看到:
addPathPatterns(...)excludePathPatterns(...)
7.6.4 执行链为什么重要
有了 HandlerExecutionChain,SpringMVC 才能把请求执行过程组织成:
请求
-> preHandle
-> Handler
-> postHandle
-> afterCompletion也就是说,拦截器之所以能“围着” Handler 工作,本质上就是因为 Handler 不再单独存在,而是被放进了执行链里。
7.6.5 这一节要记住什么
这一节最重要的不是类名,而是这个思想:
SpringMVC 找到的不是单独一个处理方法,而是一整条“处理方法 + 拦截器”的执行链。
7.7 AbstractAnnotationConfigDispatcherServletInitializer 到底做了什么
前面我们继承了这个抽象类,但它真正的价值是什么? 这一节不要只从“它是个抽象类”去理解,而要从“SpringMVC 启动时它参与了什么流程”去看。
它实现了一个接口:
public interface WebApplicationInitializer {
void onStartup(ServletContext servletContext) throws ServletException;
}这里最关键的结论是:
只要一个类实现了
WebApplicationInitializer,Web 容器启动时,它的onStartup()方法就会被自动执行。
也就是说,Tomcat 启动 Web 应用时,会去扫描这类实现,并回调 onStartup(ServletContext servletContext)。 所以这个方法本身就是 Web 应用启动入口的一部分。
AbstractAnnotationConfigDispatcherServletInitializer 的意义,就在于它把这条启动链路组织好了。 真正要记住的,不是抽象类语法,而是 onStartup() 自动执行之后发生了什么。
可以按下面这条顺序理解:
- Web 容器启动。
- 发现当前类实现了
WebApplicationInitializer。 - 自动执行
onStartup(ServletContext servletContext)。 - 在这个过程中,SpringMVC 会先注册
ContextLoaderListener。 ContextLoaderListener初始化一个WebApplicationContext,并把它放到ServletContext中。- 然后再注册
DispatcherServlet。 DispatcherServlet在自己的生命周期init()方法执行时,也会初始化一个属于自己的WebApplicationContext。- 这个新的
WebApplicationContext会把前面通过ContextLoaderListener放进ServletContext的那个WebApplicationContext作为父容器。
把这个过程翻译成更容易记的话,就是:
ContextLoaderListener先初始化父容器DispatcherServlet再初始化子容器- 父容器先通过
ServletContext共享出来 - 子容器初始化时再把这个父容器接上
所以这个抽象类在启动流程里串起来的,不只是“少写代码”这件事,而是下面这一整套初始化动作:
- Root 容器初始化
ContextLoaderListener注册DispatcherServlet注册- Web 容器初始化
- 父子容器关系建立
因此我们平时在子类里重写的几个方法,真正决定的是:
getRootConfigClasses():父容器加载哪些配置类getServletConfigClasses():子容器加载哪些配置类getServletMappings():DispatcherServlet映射到哪个路径
所以这一节最值得背住的是这句话:
SpringMVC 借助
WebApplicationInitializer -> onStartup() -> ContextLoaderListener -> DispatcherServlet.init()这条链路,把两个WebApplicationContext初始化出来,并建立了父子容器关系。
为了方便课堂记忆,可以把启动顺序再压缩成 4 句话:
- 实现了
WebApplicationInitializer,容器启动时就会自动调用onStartup()。 onStartup()里先注册ContextLoaderListener,初始化父容器。- 然后注册
DispatcherServlet,在它的init()里初始化子容器。 - 子容器初始化时,会把父容器接上,最终形成父子容器关系。
7.8 本节小结
如果用一句话概括 SpringMVC 的核心流程,可以这样说:
DispatcherServlet作为统一入口,从容器中找到与请求匹配的 Handler 及其拦截器,再通过适配器完成参数绑定、方法调用和返回值处理。
这是整章最值得背熟的一句话。
8. 静态资源处理
8.1 为什么整合 SpringMVC 之后图片访问不到了
在 JavaEE 阶段,如果把图片放在 webapp 下,通常可以直接访问。 但整合 SpringMVC 后,你可能会发现原本能访问的图片、JS、CSS 突然访问不到了。
原因在于“默认处理者”变了:
- JavaEE 阶段:缺省 Servlet 是
default - 整合 SpringMVC 后:缺省入口变成了
DispatcherServlet
也就是说,原本应该由默认静态资源 Servlet 处理的请求,先被 SpringMVC 拦走了。

8.2 静态资源处理的本质
如果一个请求不是要交给 Controller,而是应该直接映射到某个物理文件,就要配置资源处理器。
SpringMVC 提供的是 ResourceHandler 机制,我们通常通过 WebMvcConfigurer 的 addResourceHandlers() 进行配置。
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/pic/**")
.addResourceLocations("file:d:/tmp/");
registry.addResourceHandler("/pic2/**")
.addResourceLocations("classpath:/");
registry.addResourceHandler("/pic3/**")
.addResourceLocations("/");
registry.addResourceHandler("/wx/storage/fetch/**")
.addResourceLocations("file:d:/tmp/");
}这里几个点一定要记住:
addResourceHandler():配置访问路径映射范围addResourceLocations():配置真实资源所在位置**:表示多级任意路径
8.3 三种常见资源位置
最常见的资源位置有三类:
1. 文件系统路径
"file:d:/tmp/"2. 类路径
"classpath:/"3. Web 资源根路径
"/"其中最容易写错的是:
file:前缀不能漏classpath:前缀不能漏- location 最后通常要带
/
8.4 访问静态资源的 URL 怎么拼
访问规则可以概括为:
映射路径+资源相对于 location 的路径
例如:
registry.addResourceHandler("/pic3/**").addResourceLocations("/");如果资源真实位置是:
/a/e/logo.png那么访问路径就是:
localhost:8080/pic3/a/e/logo.png
8.5 为什么更推荐文件路径
课程里更建议大家优先考虑文件系统路径,主要是因为:
- 静态文件和应用打包产物解耦
- 后续上传文件管理更方便
- 部署到服务器时更符合常见目录管理方式
尤其后面使用 Spring Boot 打包为 jar 时,把大文件或上传文件直接放在文件系统目录里通常更合理。
9. Filter 和 HandlerInterceptor
这两个组件都能实现“拦截”的效果,但不是一个层次的东西。
9.1 Filter
Filter 就是 JavaEE 阶段学过的 Filter,本质上仍然是 Servlet 规范组件。
它和 SpringMVC 的关系,本质上仍然是:
Filter 在 Servlet 前后工作,而 SpringMVC 的核心入口就是
DispatcherServlet这个 Servlet。
所以通常是先执行 Filter,再执行 SpringMVC 的主流程。
9.2 OncePerRequestFilter
在 SpringMVC 中,如果直接用普通 Filter,有时会遇到一次请求中被多次触发的问题。 Spring 提供了一个更常用的抽象类:
OncePerRequestFilter它保证在同一次请求中,核心过滤逻辑只执行一次。
public class CustomFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
System.out.println("custom filter");
filterChain.doFilter(request, response);
}
}注册方式:
@Override
protected Filter[] getServletFilters() {
return new Filter[]{new CustomFilter()};
}9.3 字符编码过滤器
Spring 还提供了一个非常常见的 Filter:
CharacterEncodingFilter@Override
protected Filter[] getServletFilters() {
CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
characterEncodingFilter.setEncoding("utf-8");
characterEncodingFilter.setForceEncoding(true);
return new Filter[]{characterEncodingFilter};
}它主要解决字符编码相关问题。
9.4 HandlerInterceptor
HandlerInterceptor 是面向 Handler 方法的拦截器,作用点更贴近 Controller。
它不是拦 Servlet,而是拦 Handler 的执行过程。
9.5 HandlerInterceptor 和 HandlerMapping 的关系
一个请求会对应哪些拦截器,不是随便拍脑袋定的,而是由 HandlerMapping 在匹配 Handler 时一起决定。

不同请求,能匹配到的 Handler 和拦截器集合也会不同:

最终会形成一个 HandlerExecutionChain:
- 里面有 Handler
- 里面有多个
HandlerInterceptor

9.6 HandlerInterceptor 的三个方法
实现拦截器时,最重要的是这三个方法:
preHandle
- 在 Handler 执行之前执行
- 返回
true:继续流程 - 返回
false:中断流程
postHandle
- 在 Handler 执行之后执行
- 只有真正执行到了 Handler 才会触发
afterCompletion
- 请求完成后执行
- 即便某个
preHandle返回false,前面已经执行成功的那部分拦截器,依然会进入afterCompletion
9.7 一个简单拦截器示例
public class HandlerInterceptor1 implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) {
System.out.println("preHandle1");
return true;
}
@Override
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) {
System.out.println("postHandle1");
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
System.out.println("afterCompletion1");
}
}9.8 注册多个拦截器
@Autowired
HandlerInterceptor handlerInterceptor1;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(handlerInterceptor1).addPathPatterns("/hello");
registry.addInterceptor(new HandlerInterceptor2()).addPathPatterns("/hello/**");
registry.addInterceptor(new HandlerInterceptor3()).addPathPatterns("/goodbye");
registry.addInterceptor(new HandlerInterceptor4()).addPathPatterns("/goodbye/**");
registry.addInterceptor(new HandlerInterceptor5());
}示意图:

9.9 多个拦截器的执行顺序
如果多个拦截器都匹配成功,顺序是:
preHandle:正序postHandle:倒序afterCompletion:倒序
例如:
preHandle4
preHandle5
Handler4
postHandle5
postHandle4
afterCompletion5
afterCompletion4如果中间某个 preHandle 返回 false,那么:
- 后面的
preHandle不再执行 postHandle都不会执行- 已经执行成功的那部分拦截器会执行
afterCompletion
这也是源码里 interceptorIndex 这个标记存在的意义。
9.10 Filter 和 Interceptor 的边界
面试或实际设计里,这个问题非常常见。
可以这样理解:
| 对比项 | Filter | HandlerInterceptor |
|---|---|---|
| 所属层次 | Servlet 规范 | SpringMVC |
| 拦截对象 | Servlet 请求链 | Handler 方法执行链 |
| 找不到 Handler 时是否还能执行 | 可以 | 不可以 |
| 典型用途 | 编码、跨域、统一过滤 | 登录校验、权限校验、业务日志 |
很实用的一条判断:
- 只要请求进了 Web 应用,Filter 基本都有机会工作
- 只有找到了对应 Handler,请求才会进入
HandlerInterceptor
10. 异常处理
如果在 Handler 中抛出了异常,而我们完全不处理,通常会有两个问题:
- 对用户不友好
- 可能泄露服务端实现细节
因此异常处理必须做统一设计。
10.1 HandlerExceptionResolver(了解)
这是一种比较底层、偏老的全局异常处理方式。
它的返回值通常是 ModelAndView:
@Component
public class CustomHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception exception) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("/exception.jsp");
return modelAndView;
}
}它只要注册到容器中就能生效,但现在业务开发里更推荐基于注解的方式。
10.2 @ExceptionHandler
@ExceptionHandler 用来处理指定类型的异常。
它既可以返回:
ModelAndView- 视图名字符串
- 字符串 / JSON 响应体
例如返回视图名:
@ControllerAdvice
public class CustomExceptionControllerAdvice {
@ExceptionHandler(ArithmeticException.class)
public String resolveArithmeticException() {
return "/exception.jsp";
}
}10.3 返回 JSON:@RestControllerAdvice
如果希望异常处理方法统一响应 JSON,最方便的写法是:
@RestControllerAdvice
public class CustomExceptionControllerAdvice {
@ExceptionHandler(ArithmeticException.class)
public BaseRespVo<Void> resolveArithmeticException() {
BaseRespVo<Void> vo = new BaseRespVo<>();
vo.setStatus(500);
vo.setMsg("算术异常!");
return vo;
}
}@RestControllerAdvice 本质上等于:
@ControllerAdvice + @ResponseBody这意味着这里面的方法返回值都会直接写入响应体。
10.4 @ExceptionHandler 的值是什么
@ExceptionHandler 的 value 本质上是一个异常类型数组:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExceptionHandler {
Class<? extends Throwable>[] value() default {};
}也就是说,一个方法理论上可以处理多个异常类型。
10.5 推荐的异常处理思路
项目里更推荐这样拆:
- 业务异常:单独处理,返回业务码和业务提示
- 参数异常:单独处理,返回明确参数错误信息
- 未知异常:统一兜底,避免把堆栈细节暴露给前端
例如:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseRespVo<Void> handleBusinessException(BusinessException e) {
BaseRespVo<Void> vo = new BaseRespVo<>();
vo.setStatus(e.getCode());
vo.setMsg(e.getMessage());
return vo;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseRespVo<String> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
BaseRespVo<String> vo = new BaseRespVo<>();
vo.setStatus(400);
vo.setMsg(message);
return vo;
}
@ExceptionHandler(Exception.class)
public BaseRespVo<Void> handleException(Exception e) {
BaseRespVo<Void> vo = new BaseRespVo<>();
vo.setStatus(500);
vo.setMsg("系统繁忙,请稍后重试");
return vo;
}
}10.6 本节小结
- 老方式有
HandlerExceptionResolver - 更推荐的是
@ExceptionHandler + @ControllerAdvice - 如果全部响应 JSON,优先使用
@RestControllerAdvice
附录:核心注解速查
| 注解 | 作用 | 常见位置 |
|---|---|---|
@Controller |
标记控制器组件 | 类 |
@RestController |
@Controller + @ResponseBody |
类 |
@RequestMapping |
请求映射 | 类 / 方法 |
@GetMapping |
GET 请求映射 | 方法 |
@PostMapping |
POST 请求映射 | 方法 |
@RequestParam |
接收请求参数 | 形参 |
@PathVariable |
接收路径变量 | 形参 |
@RequestBody |
接收 JSON 请求体 | 形参 |
@RequestHeader |
接收请求头 | 形参 |
@CookieValue |
接收 Cookie | 形参 |
@SessionAttribute |
接收 Session 属性 | 形参 |
@ResponseBody |
直接响应正文 | 类 / 方法 |
@ControllerAdvice |
全局异常处理 | 类 |
@RestControllerAdvice |
全局 JSON 异常处理 | 类 |
@ExceptionHandler |
指定异常处理方法 | 方法 |
@DateTimeFormat |
key=value 日期格式解析 | 字段 / 形参 |
@JsonFormat |
JSON 日期格式解析与响应 | 字段 |
本章总结
这一章真正要吃透的,不是零散注解,而是整条链路:
- 请求先进入
DispatcherServlet - 再通过
HandlerMapping找到对应 Handler - 由
HandlerAdapter完成参数绑定与方法调用 - 返回值再交给视图解析器或消息转换器处理
- 如果配置了拦截器和异常处理器,它们也会进入这条统一流程
如果你能把下面这些问题说清楚,SpringMVC 这一章就算真的掌握了:
- 为什么 SpringMVC 比手写 Servlet 更适合做业务开发?
@RequestMapping的核心属性有哪些?最重要的是哪个?- key=value 参数、JSON 请求体、路径变量分别该怎么接?
@ResponseBody和@RestController到底是什么关系?DispatcherServlet、HandlerMapping、HandlerAdapter各干什么?- Filter 和 Interceptor 的边界是什么?
- 静态资源为什么会被 SpringMVC 拦走?怎么放行?
- 全局异常处理为什么值得做?
实战练习
<!-- 实战练习内容已分离到 ../practices/markdown/14-spring-mvc-practice.md -->
建议先完成本章重点内容复习,再进入配套练习。 练习顺序建议按:基础 -> 进阶 -> 综合挑战。