WD
Classnote Docs课程课件
14

SpringMVC - Web 框架核心

学习目标:

  • 理解 SpringMVC 和 Servlet 之间的关系,知道它解决了什么问题
  • 熟悉 Controller 中 Handler 方法和 URL 之间的映射关系
  • 掌握 Handler 方法的常见返回值与 JSON 响应方式
  • 掌握 Handler 方法接收 key=value、JSON、路径参数等请求数据的方式
  • 理解 SpringMVC 的核心流程、静态资源处理、拦截器与异常处理

本章重点:

  • DispatcherServletHandlerMappingHandlerAdapter 的协作关系
  • @RequestMappingvaluemethodparamsheadersconsumesproduces
  • @ResponseBody@RestController@RequestBody@PathVariable
  • 静态资源映射、拦截器执行时机、全局异常处理
01 / Section

前置知识

回顾一下内容

  • 抽象类与模板方法式写法
  • ApplicationContext 的基本概念
  • Servlet 中 URL 和处理方法之间的关系
  • Servlet 中如何通过 request.getParameter() 获取请求参数
  • Servlet 中如何通过 response 响应字符串或 JSON
  • 注解、反射、Filter、Listener 的基本作用

如果这些内容还不熟,建议先回看前面的 Servlet、Request/Response、会话管理章节。

02 / Section

1. SpringMVC 介绍

1.1 什么是 SpringMVC

SpringMVC 是 Spring 框架提供的 Web MVC 解决方案。它并不是抛弃 Servlet,而是建立在 Servlet 之上,通过一个统一的前端控制器接收请求,再把请求分发到具体的方法上处理。

和手写 Servlet 相比,SpringMVC 的核心价值不是“少写代码”这么简单,而是把这些重复工作交给框架:

  • URL 到处理逻辑的映射
  • 请求参数的提取与类型转换
  • JSON 序列化与响应输出
  • 异常处理与统一返回
  • 拦截器、静态资源、校验等通用能力

1.2 SpringMVC 和 JavaEE 开发方式的差异

SpringMVC 出现的一个直接原因,就是 JavaEE 原始开发方式里“请求处理逻辑分散、样板代码过多”。

下面这张图可以直观看到两种开发方式的差异:

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 和反射,只是把这套流程框架化了。

SpringMVC 核心流程图
SpringMVC 核心流程图

完整主流程可以概括为:

  1. 客户端发起请求。
  2. DispatcherServlet 统一接收请求。
  3. HandlerMapping 根据 URL、请求方法等信息找到目标 Handler。
  4. HandlerAdapter 调用对应的 Handler 方法。
  5. Handler 返回 ModelAndView 或普通对象。
  6. 如果是视图结果,交给视图解析器;如果是 JSON,交给消息转换器写回响应体。

DispatcherServlet 分发流程示意

SpringMVC 请求分发与响应结果全景图

1.4 本节小结

  • SpringMVC 不是替代 Servlet,而是构建在 Servlet 之上的 Web 框架。
  • 它的灵魂是“统一入口 + 方法级分发”。
  • 它最大的收益,是把请求处理中的通用重复劳动抽离给框架完成。
03 / Section

2. SpringMVC 入门案例

这一节先建立最小可运行链路,知道 SpringMVC 项目是怎样启动起来的。

2.1 基础依赖

xml
<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:

xml
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Spring和Servlet之间的关系

先看图里表达的层次关系,再理解这几个概念:

  • 最外层是 DispatcherServlet,说明 SpringMVC 对外接收请求的统一入口,本质上它自己就是一个 Servlet。
  • 中间这一层可以理解为 SpringMVC 的 Web 容器,里面主要放 Controller 这类直接处理请求的组件。
  • 更里面这一层表达的是业务基础容器,通常放 datasourceservicemapper 这类数据访问和业务处理组件。
  • 也就是说,请求先进入 DispatcherServlet,再由它去 Web 层容器里找合适的 Controller 方法;而 Controller 在执行过程中,又可以继续调用下层容器中的 servicemapperdatasource

如果把这张图翻译成一句更好记的话,就是:

DispatcherServlet 负责接请求,Web 层容器负责放 Controller,业务基础容器负责放 service / mapper / datasource,三者协作完成一次 Web 请求。

这张图还想说明一个非常重要的关系:

  • SpringMVC 不是脱离 Servlet 存在的,它是建立在 Servlet 之上的。
  • DispatcherServlet 作为 Servlet,负责把底层 Servlet 请求桥接到 Spring 容器中的 Controller 方法。
  • 所以在 SpringMVC 里,我们平时虽然主要写的是 Controller、Service、Mapper,但请求真正进入应用的第一站,仍然是 Servlet。
AbstractAnnotationConfigDispatcherServletInitializer 结构示意
AbstractAnnotationConfigDispatcherServletInitializer 结构示意

2.2 JavaConfig 启动方式

SpringMVC 里最常见的启动入口之一,是继承抽象类 AbstractAnnotationConfigDispatcherServletInitializer

它本质上是一种模板方法式的写法:

  • 框架已经把启动主流程写好了
  • 你只需要补充几个关键配置点
  • 框架内部会在合适时机调用你重写的方法
java
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 容器适合放 ServiceDao、数据源等
  • Web 容器适合放 Controller、视图解析、拦截器、资源映射等
AbstractAnnotationConfigDispatcherServletInitializer 结构示意
AbstractAnnotationConfigDispatcherServletInitializer 结构示意

这里建议把它理解成“分层管理”:

  • 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 容器的拆分,本质上既是容器组织方式,也是系统分层边界。

典型写法如下:

java
@Configuration
@ComponentScan(
        value = "com.cskaoyan",
        excludeFilters = @ComponentScan.Filter({Controller.class, EnableWebMvc.class})
)
public class RootConfiguration {
}
java
@Configuration
@ComponentScan("com.cskaoyan.controller")
@EnableWebMvc
public class WebConfiguration implements WebMvcConfigurer {
}

2.4 @EnableWebMvc 做了什么

@EnableWebMvc 可以理解为“启用 SpringMVC 的一套核心基础设施”,例如:

  • RequestMappingHandlerMapping
  • RequestMappingHandlerAdapter
  • 消息转换器
  • 参数绑定相关支持
  • 校验器、类型转换器等

也就是说,光有 Controller 类还不够,SpringMVC 还需要一整套“识别注解并调用方法”的运行机制,@EnableWebMvc 就是在接通这部分能力。

2.5 第一个 Controller

java
@Controller
public class HelloController {

    @RequestMapping("/hello")
    @ResponseBody
    public String hello() {
        return "Hello SpringMVC";
    }
}

如果访问 /hello,请求会交给这个方法处理,最终直接把返回值写到响应体中。

2.6 本节小结

最小 SpringMVC 链路可以概括为:

  1. 引入 spring-webmvc
  2. 注册 DispatcherServlet
  3. 准备 Root / Web 两套配置
  4. 启用 @EnableWebMvc
  5. 编写 Controller + @RequestMapping
04 / Section

3. @RequestMapping 注解的使用

@RequestMapping 是 SpringMVC 中最核心的注解之一。可以直接说:

SpringMVC 的灵魂是请求映射,而请求映射的核心就是 @RequestMapping

3.1 注解定义要点

java
@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-Type
  • produces:响应类型限定,关注 Accept

3.2 URL 路径映射 value

最常用的属性就是 value。如果只写这一个属性,可以省略属性名。

java
@RequestMapping("hello")
@ResponseBody
public String hello(String username) {
    return "hello " + username;
}

@RequestMapping("goodbye")
@ResponseBody
public String goodbye(String username) {
    return "goodbye " + username;
}

value 的类型是 String[],所以也可以映射多个地址:

java
@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 窄化请求

如果一组接口都属于同一类业务,可以把共同前缀写在类上:

java
@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 阶段改成在注解里声明。

java
@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)
java
@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 参数。

java
@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。

请求头位置示意
请求头位置示意
java
@Controller
@RequestMapping("header")
public class HeaderLimitController {

    @RequestMapping(value = "limit", headers = {"abc", "def"})
    @ResponseBody
    public String headerLimit() {
        return "ok";
    }
}

3.8 consumesproduces

consumes 限定的是 Content-Type,也就是“请求体是什么类型”。

java
@RequestMapping(value = "consumes", consumes = "application/json")
@ResponseBody
public String contentTypeLimit() {
    return "ok";
}

produces 限定的是 Accept,也就是“客户端希望得到什么类型的响应体”。

Accept 请求头示意
Accept 请求头示意
java
@RequestMapping(value = "produces", produces = "application/json")
@ResponseBody
public String acceptLimit() {
    return "ok";
}

如果要显式处理字符串响应字符集,也可以这样写:

java
@RequestMapping(value = "chinese", produces = "text/html;charset=utf-8")
@ResponseBody
public String chinese() {
    return "这是中文";
}
字符集响应效果示意
字符集响应效果示意

3.9 本节小结

  • @RequestMapping 最核心的属性是 value
  • 其他属性是在“路径匹配”的基础上继续做更细的限制
  • 日常开发高频使用的是 valuemethod@GetMapping@PostMapping
05 / Section

4. Handler 方法的返回值

前面的案例里我们大多返回的是 String。但在 SpringMVC 中,Handler 方法的返回值远不止这一种。 这个返回值最终会被谁拿到?是 DispatcherServlet。它会根据返回值类型决定后续处理方式:

  • 如果是视图相关结果,就走视图解析
  • 如果是字符串或对象并配合 @ResponseBody,就写入响应体
  • 如果是 JSON 需要的对象结构,就交给消息转换器序列化

4.1 ModelAndView(了解)

ModelAndView 更偏传统 MVC 页面开发。现在前后端分离场景下不是主流,但一定要知道它解决的是什么问题。

它主要面向这样的场景:

  • 服务端渲染 JSP 或模板页
  • 需要把数据放到视图中展示
  • 前后端没有彻底分离

比如有一个 hello.jsp

jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
  hello ${username}
</body>
</html>

如果现在希望访问:

text
http://localhost:8080/hello?username=xxx

并让页面中输出 hello xxx,就可以使用 ModelAndView

java
@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,也可以直接返回一个字符串作为视图名:

java
@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,当然也能直接响应:

java
@GetMapping("/string")
@ResponseBody
public String string() {
    return "hello";
}

但后续并不推荐大量使用这种方式,原因有两个:

  • 字符串响应容易引出字符集问题
  • 一旦响应结构稍微复杂一点,又得自己手工拼 JSON

所以在真实开发里,一般会优先返回对象、集合或统一 VO。

4.5 直接返回对象

现在最常见的是直接返回普通对象:

java
@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 可以写在方法上,也可以写在类上。

写在方法上:

java
@Controller
public class DemoController {

    @RequestMapping("user")
    @ResponseBody
    public User user() {
        return new User("张三", "123456", 20);
    }
}

写在类上:

java
@RequestMapping("json")
@Controller
@ResponseBody
public class JsonController {

    @RequestMapping("user")
    public User user() {
        return new User("薛佳", "雪茄", 30);
    }
}

写在类上意味着:当前类下所有方法的返回值都直接写到响应体中。

这也是组合注解 @RestController 的来源:

java
@RestController = @Controller + @ResponseBody

4.7 统一响应对象

开发过程中很少直接裸返回数据,更常见的是封装成统一结构,例如:

java
@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;
    }
}
java
@RequestMapping("vo")
public BaseRespVo<String> vo() {
    return BaseRespVo.ok("hello vo");
}

这样做的好处是:

  • 前端更容易统一处理
  • 成功、失败、消息、数据结构稳定
  • 后续扩展分页、错误码、追踪 ID 更方便

4.8 返回值使用注意

无论你使用 Jackson、Gson 还是其他 JSON 工具,都要注意一个非常实际的问题:

引用类型对象通常都应该提供无参构造方法,以及 Getter / Setter。

原因是很多框架内部都依赖:

  • 反射创建对象
  • 按属性名读写字段

如果这些基础能力缺失,序列化和反序列化过程很容易出问题。

4.9 本节小结

  • ModelAndView 和返回视图名属于传统页面开发方式
  • @ResponseBody / @RestController 解决的是“把返回值直接写到响应体”
  • 真实项目里更主流的是返回对象、集合或统一响应体
06 / Section

5. Handler 方法的形参

Handler 方法的形参主要负责两件事:

  • 接收请求参数
  • 接收其他上下文信息

从开发体验上看,SpringMVC 的一个巨大提升就是:你不必再大量手写 request.getParameter() 了。

5.1 key=value 形式的请求参数

先看最基本的例子:

java
@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();
    }
}

请求示例:

text
localhost:8080/user/register1?username=songge&password=niupi&age=40

控制台会输出:

text
username = songge
password = niupi
age = 40

这个过程中最核心的规则是:

请求参数名和方法形参名一致。

SpringMVC 内部帮你做了这些事:

  1. 通过请求对象获取参数值
  2. 根据参数名与形参名建立对应关系
  3. 把拿到的值传给方法执行

5.2 String、基本类型与包装类

上面的案例里全部用的是 String,因为 request.getParameter() 本身拿到的就是字符串。

但 SpringMVC 并不只会处理字符串。它还内置了大量类型转换器,所以我们完全可以这样写:

java
@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 内部会做类似这样的事情:

java
String ageStr = request.getParameter("age");
Integer age = Integer.parseInt(ageStr);

当然实际底层不是你手写 parseInt,而是交给 SpringMVC 的类型转换器体系处理。

开发建议:

  • 能用包装类时尽量优先用包装类,如 IntegerLong
  • 包装类可以为 null
  • 基本类型不能为 null,在参数缺失时更容易出问题

5.3 Date 类型接收

日期是一个非常典型的“需要格式信息”的场景。

比如这些请求:

text
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-21

SpringMVC 能否直接把字符串转成 Date,取决于它是否知道日期格式。

它通常有两种处理方式:

  • 使用默认格式,例如 yyyy/MM/dd
  • 使用 @DateTimeFormat 指定格式
java
@RequestMapping("register3")
public BaseRespVo<Void> register3(
        String username,
        String password,
        Integer age,
        Date birthday) {
    System.out.println("birthday = " + birthday);
    return BaseRespVo.ok();
}
java
@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 数组接收

如果请求里一个参数出现多次,也可以直接使用数组接收。

java
@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();
}

请求示例:

text
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"
html
<form action="/upload/file" enctype="multipart/form-data" method="post">
    <input type="file" name="myfile"/><br>
    <input type="submit"/>
</form>

多文件上传:

html
<form action="/upload/files" enctype="multipart/form-data" method="post">
    <input type="file" multiple name="myfiles"/><br>
    <input type="submit"/>
</form>

ApiFox里也可以构造文件上传请求:

如果要让 SpringMVC 正常处理文件上传,通常要做三件事:

  1. 在 Servlet 层启用 multipart-config
  2. 注册 multipartResolver
  3. 在方法形参中使用 MultipartFile

从 Spring 6 / Jakarta Servlet 这套环境往后看,更推荐使用 StandardServletMultipartResolver。 它直接基于 Servlet 规范处理上传,不再依赖 commons-fileupload

也要注意一点:使用 StandardServletMultipartResolver 时,仍然需要由 Servlet 容器提供 multipart 支持;在传统 SpringMVC 中,通常就是通过 multipart-config 配置完成。

先在 SpringMVC 配置类中注册解析器:

java
@Configuration
public class MvcConfiguration {

    @Bean("multipartResolver")
    public StandardServletMultipartResolver multipartResolver() {
        return new StandardServletMultipartResolver();
    }
}

注意:这个组件的名字必须是 multipartResolver

然后在 DispatcherServlet 注册阶段补上 multipart-config

java
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 决定。

接收单文件:

java
@RestController
@RequestMapping("upload")
public class UploadController {

    @RequestMapping("file")
    public BaseRespVo<Void> file(MultipartFile myfile) {
        return BaseRespVo.ok();
    }
}

同样满足命名规则:

请求参数名和形参名一致。

MultipartFile 常用方法:

方法 作用
getOriginalFilename() 上传时原始文件名
getContentType() 文件类型
getName() 请求参数名
getSize() 文件大小
transferTo(File) 把文件保存到指定位置

例如保存到本地:

java
@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();
}

多文件上传则可以用数组接收:

java
@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 使用引用类型接收表单参数

如果参数越来越多,全部写在方法形参里会越来越乱。

比如这个请求:

text
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

完全展开会是这样:

java
@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();
}

这时候更合适的方式是封装为对象:

java
@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;
}
java
@RequestMapping("register6")
public BaseRespVo<Void> register6(User user) {
    return BaseRespVo.ok();
}

SpringMVC 会根据:

  • 成员变量名
  • 成员变量类型
  • Setter 方法

自动完成封装。

这里的核心规则升级为:

请求参数名和引用类型的成员变量名一致。

5.7 什么时候直接用形参,什么时候用对象

可以用一个很实用的判断方式:

  • 参数较少:直接写方法形参
  • 参数较多:封装成对象
  • 多个接口都重复使用的一组参数:封装成公共对象

比如查询接口:

text
localhost:8080/user/query?username=song&sort=add_time&order=asc&page=1&limit=10

其中:

  • username 是业务筛选条件
  • sortorderpagelimit 是可复用的分页排序参数

可以这样设计:

java
@RequestMapping("query")
public BaseRespVo<Void> query(String username, CommonParameter commonParameter) {
    return BaseRespVo.ok();
}
java
@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

java
@RequestMapping("login")
public BaseRespVo<Void> login(@RequestBody String result) {
    System.out.println(result);
    return BaseRespVo.ok();
}

这种方式能接,但一般不推荐。因为接到字符串以后,你还得自己再解析。

2. 接收为对象

java
@RequestMapping("login2")
public BaseRespVo<Void> login2(@RequestBody User user) {
    System.out.println(user);
    return BaseRespVo.ok();
}

这通常是最常用的方式。适合参数比较多、结构清晰、需要类型约束的场景。

3. 接收为 Map

java
@RequestMapping("login3")
public BaseRespVo<Void> login3(@RequestBody Map map) {
    System.out.println(map);
    return BaseRespVo.ok();
}

这适合参数较少、结构简单、字段不稳定的场景。

5.9 JSON 日期格式

接收 key=value 形式的请求参数时,我们用 @DateTimeFormat。 但接收 JSON 时,日期解析不再走这一套,而是走 JSON 工具的反序列化规则。

这时应该使用:

java
@JsonFormat(pattern = "yyyy-MM-dd")

例如:

java
@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 其他信息:HttpServletRequestHttpServletResponse

这两个对象也可以直接写在形参中:

java
@RequestMapping("reqAndResp")
public BaseRespVo<Void> reqAndResp(
        HttpServletRequest request,
        HttpServletResponse response) {
    return BaseRespVo.ok();
}

虽然能用,但不建议滥用。除非确实需要访问原始 Servlet API,否则优先用 SpringMVC 已经提供好的参数绑定能力。

5.11 Cookie 和 Session

如果使用原始方式获取 Cookie,需要从 request 中拿:

java
@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:

浏览器 Cookie 示例
浏览器 Cookie 示例

ApiFox 也可以构造 Cookie:

Session 可以通过两种方式获取:

1. 从 HttpServletRequest 获取

java
@RequestMapping("session1")
public BaseRespVo<Void> session1(HttpServletRequest request, String username) {
    HttpSession session = request.getSession();
    session.setAttribute("username", username);
    return BaseRespVo.ok();
}

2. 直接在形参中写 HttpSession

java
@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
07 / Section

6. RESTful 风格接口

REST 是 Representational State Transfer 的缩写。 如果只站在当前课程语境里理解,可以先把它抓成一句话:

它强调通过更清晰的资源路径和请求语义来设计接口。

6.1 传统写法和 RESTful 写法

过去很多人会这样设计用户的增删改查:

text
/user/query
/user/remove
/user/create
/user/modify

更偏 RESTful 的设计会更强调资源本身:

text
GET    /users/1
POST   /users
PUT    /users/1
DELETE /users/1

但在当前很多企业项目里,并不一定会严格纯 REST。大家更常见的是:

  • 请求体用 JSON
  • 响应体用 JSON
  • URL 尽量清晰表达业务语义

也就是说,RESTful 在实际工作里经常是“理念影响设计”,而不是所有项目都按教科书完全落地。

6.2 @PathVariable 获取 URI 信息

@PathVariable 用来获取 URL 路径中的变量值。

java
@RequestMapping("path/{username}")
public BaseRespVo<Void> path(@PathVariable("username") String name) {
    System.out.println("name = " + name);
    return BaseRespVo.ok();
}

比如访问:

text
localhost:8080/path/songge

就可以把 songge 这一段取出来。

这种方式的价值是:可以把部分请求参数写到 URL 路径中,而不是全部放到 query string 中。

6.3 @RequestParam 获取请求参数

虽然开发里很多场景直接依靠“形参名 = 参数名”就够了,但 @RequestParam 依然很有价值,尤其是在以下场景:

  • 形参与请求参数名不同
  • 需要显式声明必传
  • 需要默认值
java
@RequestMapping("param")
public BaseRespVo<Void> param(
        @RequestParam("username") String username,
        @RequestParam("password") String password) {
    return BaseRespVo.ok();
}

6.4 @RequestHeader 获取请求头

java
@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();
}

这适合读取:

  • Accept
  • Host
  • Authorization
  • 自定义请求头

6.5 @CookieValue 获取 Cookie

java
@RequestMapping("cookie")
public BaseRespVo<Void> cookie(@CookieValue("songge") String value) {
    System.out.println(value);
    return BaseRespVo.ok();
}

相比手工遍历 Cookie[],这种写法简洁很多。

6.6 @SessionAttribute 获取 Session

java
@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
08 / Section

7. SpringMVC 核心流程(拔高)

这一部分是本章最适合拿来面试拔高的内容。 前面所有注解、参数绑定、JSON 转换,背后都依赖这条总流程。

先建立两个共识:

  • DispatcherServlet 处理几乎全部请求
  • 真实处理业务的是 Controller 里的 Handler 方法

7.1 为什么只开发 Controller 方法就够了

为什么我们平时开发时只要去写 Controller 里的方法?

因为:

  1. Controller 是 Spring 容器中的组件
  2. Handler 方法存在于 Controller 组件里
  3. DispatcherServlet 会在请求到来时找到容器里的目标 Handler 方法并执行
DispatcherServlet 与 Handler 的关系示意
DispatcherServlet 与 Handler 的关系示意

这里的 Handler,其实通常就是 HandlerMethod,也就是“某个 Controller 中的某个方法”。

只要 DispatcherServlet 能找到 ApplicationContext,它就有机会进一步找到容器中的 Controller 与 Handler。

DispatcherServlet 找到容器后执行 Handler 的示意
DispatcherServlet 找到容器后执行 Handler 的示意

7.2 DispatcherServletApplicationContext 的关系

SpringMVC 阶段使用的是 WebApplicationContext,它本质上仍然是 ApplicationContext,只是额外具备 Web 环境能力。

可以粗略理解为:

  • ApplicationContext:通用 Spring 容器
  • WebApplicationContext:面向 Web 环境的 Spring 容器

源码层面可以粗略理解成:

java
public abstract class FrameworkServlet {
    private WebApplicationContext webApplicationContext;

    @Nullable
    public final WebApplicationContext getWebApplicationContext() {
        return this.webApplicationContext;
    }
}

public class DispatcherServlet extends FrameworkServlet {
}

这意味着 DispatcherServlet 本身就能够持有 WebApplicationContext

7.3 WebApplicationContextServletContext

WebApplicationContext 比普通容器多了一个很关键的能力:它能接触 ServletContext

java
public interface WebApplicationContext extends ApplicationContext {

    @Nullable
    ServletContext getServletContext();
}

这点很重要,因为:

DispatcherServletWebApplicationContext 可以借助 ServletContext 共享数据。

WebApplicationContext 与 ServletContext 关系示意
WebApplicationContext 与 ServletContext 关系示意

7.4 容器在什么时候初始化

既然 DispatcherServlet 要在处理请求之前就拿到容器,那这个容器肯定不能等到 service() 时才临时创建。

通常会在请求处理之前完成初始化,比如:

  • Listener 初始化阶段
  • Servlet 的 init() 阶段

SpringMVC 里,ContextLoaderListener 会参与这个过程:

java
public class ContextLoaderListener extends ContextLoader implements ServletContextListener {
    public void contextInitialized(ServletContextEvent event) {
        this.initWebApplicationContext(event.getServletContext());
    }
}

执行完 Listener 后,可以理解为:一个 WebApplicationContext 已经初始化完毕,并且放进了 ServletContext

Listener 初始化容器示意
Listener 初始化容器示意

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

DispatcherServlet 初始化时与容器建立联系示意
DispatcherServlet 初始化时与容器建立联系示意

7.5 发送请求时如何找到对应方法

核心问题来了:

从 URL 到方法,这个分发过程是怎么完成的?

看这张流程图:

SpringMVC 核心流程总图
SpringMVC 核心流程总图

可以抓住这些关键节点:

  1. 请求进入 DispatcherServlet
  2. DispatcherServlet 调用 doDispatch
  3. doDispatch 中通过 HandlerMapping 找到匹配的 Handler
  4. 通过 HandlerAdapter 调用目标 Handler 方法
  5. 处理返回值,再生成最终响应

如果把这条链再拆细一点,可以理解成下面这个过程:

  1. 浏览器把请求发送到服务器。
  2. 这个请求先进入 DispatcherServlet
  3. DispatcherServlet 不会自己直接写业务,而是进入核心分发方法 doDispatch()
  4. doDispatch() 里,先根据当前请求去找“谁能处理它”,也就是找 Handler。
  5. 找到 Handler 后,再去找“谁会调这个 Handler”,也就是找 HandlerAdapter。
  6. HandlerAdapter 负责把请求参数整理成方法执行需要的参数列表。
  7. 最后通过反射调用目标 Handler 方法。
  8. 方法执行完后,再处理返回值,决定是走视图还是写回 JSON。

所以这里真正的关键,不只是“找方法”,而是分成了两层:

  • HandlerMapping:负责找 Handler
  • HandlerAdapter:负责调 Handler

这两个角色为什么要拆开?

  • 因为“找到谁处理”是一类问题
  • “找到之后怎么调用”是另一类问题

这样拆开之后,SpringMVC 才能把 URL 映射、参数绑定、返回值处理这些能力组织得更灵活。

7.5.1 DispatcherServlet 为什么不直接调用方法

很多同学第一次学到这里会下意识想:

既然最后就是执行 Controller 方法,为什么不直接在 DispatcherServlet 里写死调用逻辑?

因为 DispatcherServlet 是总调度中心,它要处理的是“各种请求、各种 Handler、各种参数、各种返回值”的统一调度。 它更适合负责流程控制,而不是直接耦合到某一个具体 Controller 方法。

所以它做的事情更像:

  • 收请求
  • 查映射
  • 找适配器
  • 组织调用
  • 处理结果

而不是:

  • 自己知道每个 URL 对应哪个类哪个方法

7.5.2 HandlerMapping 在做什么

HandlerMapping 的职责可以理解成一句话:

根据当前请求,找到与之匹配的 Handler。

这个“匹配”依据的就是前面学过的那些映射信息,例如:

  • @RequestMapping
  • @GetMapping
  • URL 路径
  • 请求方法
  • 请求头
  • 请求参数限制

也就是说,当前请求能不能进入某个 Controller 方法,前提是先通过 HandlerMapping 这一关。

在注解驱动的开发模式里,最典型的处理器映射器就是:

java
RequestMappingHandlerMapping

它会把 Controller 中标注了映射注解的方法整理成一张“请求条件 -> HandlerMethod”的映射表。 请求进来时,再根据当前请求去查这张表,最终找到一个 HandlerMethod

这里的 HandlerMethod 本质上就是:

  • 哪个 Controller 对象
  • 哪个方法

也就是后续反射调用所需要的依据。

7.5.3 HandlerAdapter 在做什么

找到了 Handler 之后,还不能立刻执行。 因为 SpringMVC 还要解决一个更麻烦的问题:

这个 Handler 该怎么调?

比如一个方法可能长这样:

java
public BaseRespVo<User> save(
        @RequestBody User user,
        @RequestParam Integer page,
        HttpServletRequest request) {
    ...
}

要真正执行这个方法,SpringMVC 还得先准备好:

  • user 这个对象怎么从请求体里转出来
  • page 这个参数怎么从请求参数里取出来
  • request 这个对象从哪里拿

这部分工作就是 HandlerAdapter 负责的。

它的职责不是“找方法”,而是:

  • 识别当前 Handler 属于哪种类型
  • 解析方法参数
  • 组装执行参数
  • 调用目标方法
  • 接住返回值

在注解驱动开发里,最核心的适配器就是:

java
RequestMappingHandlerAdapter

7.5.4 为什么最后能执行到 Controller 方法

可以把最终调用过程粗略理解成:

java
Object[] args = ...; // 由适配器组装好的参数
method.invoke(controllerInstance, args);

也就是说,SpringMVC 并不是“魔法调用”到方法上,而是:

  1. 先找到方法
  2. 再准备参数
  3. 最后通过反射执行

所以你前面学过的这些知识,到这里就真正串起来了:

  • 注解:用于声明映射关系和参数来源
  • 反射:用于最终执行方法
  • 容器:用于找到 Controller 实例
  • Servlet:用于承接最外层请求

7.5.5 一句话串起来

这一小节如果要用一句最像课堂板书的话来概括,可以记成:

DispatcherServlet 负责总调度,HandlerMapping 负责找方法,HandlerAdapter 负责凑参数并执行方法。

7.6 HandlerExecutionChain

请求真正执行时,不只是单独找到一个 Handler,通常拿到的是一个 HandlerExecutionChain

它封装了:

  • Handler 本身
  • 与这个 Handler 关联的一组拦截器
java
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 才能把请求执行过程组织成:

text
请求
 -> preHandle
 -> Handler
 -> postHandle
 -> afterCompletion

也就是说,拦截器之所以能“围着” Handler 工作,本质上就是因为 Handler 不再单独存在,而是被放进了执行链里。

7.6.5 这一节要记住什么

这一节最重要的不是类名,而是这个思想:

SpringMVC 找到的不是单独一个处理方法,而是一整条“处理方法 + 拦截器”的执行链。

7.7 AbstractAnnotationConfigDispatcherServletInitializer 到底做了什么

前面我们继承了这个抽象类,但它真正的价值是什么? 这一节不要只从“它是个抽象类”去理解,而要从“SpringMVC 启动时它参与了什么流程”去看。

它实现了一个接口:

java
public interface WebApplicationInitializer {
    void onStartup(ServletContext servletContext) throws ServletException;
}

这里最关键的结论是:

只要一个类实现了 WebApplicationInitializer,Web 容器启动时,它的 onStartup() 方法就会被自动执行。

也就是说,Tomcat 启动 Web 应用时,会去扫描这类实现,并回调 onStartup(ServletContext servletContext)。 所以这个方法本身就是 Web 应用启动入口的一部分。

AbstractAnnotationConfigDispatcherServletInitializer 的意义,就在于它把这条启动链路组织好了。 真正要记住的,不是抽象类语法,而是 onStartup() 自动执行之后发生了什么。

可以按下面这条顺序理解:

  1. Web 容器启动。
  2. 发现当前类实现了 WebApplicationInitializer
  3. 自动执行 onStartup(ServletContext servletContext)
  4. 在这个过程中,SpringMVC 会先注册 ContextLoaderListener
  5. ContextLoaderListener 初始化一个 WebApplicationContext,并把它放到 ServletContext 中。
  6. 然后再注册 DispatcherServlet
  7. DispatcherServlet 在自己的生命周期 init() 方法执行时,也会初始化一个属于自己的 WebApplicationContext
  8. 这个新的 WebApplicationContext 会把前面通过 ContextLoaderListener 放进 ServletContext 的那个 WebApplicationContext 作为父容器。

把这个过程翻译成更容易记的话,就是:

  • ContextLoaderListener 先初始化父容器
  • DispatcherServlet 再初始化子容器
  • 父容器先通过 ServletContext 共享出来
  • 子容器初始化时再把这个父容器接上

所以这个抽象类在启动流程里串起来的,不只是“少写代码”这件事,而是下面这一整套初始化动作:

  • Root 容器初始化
  • ContextLoaderListener 注册
  • DispatcherServlet 注册
  • Web 容器初始化
  • 父子容器关系建立

因此我们平时在子类里重写的几个方法,真正决定的是:

  • getRootConfigClasses():父容器加载哪些配置类
  • getServletConfigClasses():子容器加载哪些配置类
  • getServletMappings()DispatcherServlet 映射到哪个路径

所以这一节最值得背住的是这句话:

SpringMVC 借助 WebApplicationInitializer -> onStartup() -> ContextLoaderListener -> DispatcherServlet.init() 这条链路,把两个 WebApplicationContext 初始化出来,并建立了父子容器关系。

为了方便课堂记忆,可以把启动顺序再压缩成 4 句话:

  1. 实现了 WebApplicationInitializer,容器启动时就会自动调用 onStartup()
  2. onStartup() 里先注册 ContextLoaderListener,初始化父容器。
  3. 然后注册 DispatcherServlet,在它的 init() 里初始化子容器。
  4. 子容器初始化时,会把父容器接上,最终形成父子容器关系。

7.8 本节小结

如果用一句话概括 SpringMVC 的核心流程,可以这样说:

DispatcherServlet 作为统一入口,从容器中找到与请求匹配的 Handler 及其拦截器,再通过适配器完成参数绑定、方法调用和返回值处理。

这是整章最值得背熟的一句话。

09 / Section

8. 静态资源处理

8.1 为什么整合 SpringMVC 之后图片访问不到了

在 JavaEE 阶段,如果把图片放在 webapp 下,通常可以直接访问。 但整合 SpringMVC 后,你可能会发现原本能访问的图片、JS、CSS 突然访问不到了。

原因在于“默认处理者”变了:

  • JavaEE 阶段:缺省 Servlet 是 default
  • 整合 SpringMVC 后:缺省入口变成了 DispatcherServlet

也就是说,原本应该由默认静态资源 Servlet 处理的请求,先被 SpringMVC 拦走了。

静态资源被 DispatcherServlet 接走示意
静态资源被 DispatcherServlet 接走示意

8.2 静态资源处理的本质

如果一个请求不是要交给 Controller,而是应该直接映射到某个物理文件,就要配置资源处理器。

SpringMVC 提供的是 ResourceHandler 机制,我们通常通过 WebMvcConfigureraddResourceHandlers() 进行配置。

java
@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. 文件系统路径

java
"file:d:/tmp/"

2. 类路径

java
"classpath:/"

3. Web 资源根路径

java
"/"

其中最容易写错的是:

  • file: 前缀不能漏
  • classpath: 前缀不能漏
  • location 最后通常要带 /

8.4 访问静态资源的 URL 怎么拼

访问规则可以概括为:

映射路径 + 资源相对于 location 的路径

例如:

java
registry.addResourceHandler("/pic3/**").addResourceLocations("/");

如果资源真实位置是:

text
/a/e/logo.png

那么访问路径就是:

text
localhost:8080/pic3/a/e/logo.png
静态资源路径拼接示意
静态资源路径拼接示意

8.5 为什么更推荐文件路径

课程里更建议大家优先考虑文件系统路径,主要是因为:

  • 静态文件和应用打包产物解耦
  • 后续上传文件管理更方便
  • 部署到服务器时更符合常见目录管理方式

尤其后面使用 Spring Boot 打包为 jar 时,把大文件或上传文件直接放在文件系统目录里通常更合理。

10 / Section

9. Filter 和 HandlerInterceptor

这两个组件都能实现“拦截”的效果,但不是一个层次的东西。

9.1 Filter

Filter 就是 JavaEE 阶段学过的 Filter,本质上仍然是 Servlet 规范组件。

它和 SpringMVC 的关系,本质上仍然是:

Filter 在 Servlet 前后工作,而 SpringMVC 的核心入口就是 DispatcherServlet 这个 Servlet。

所以通常是先执行 Filter,再执行 SpringMVC 的主流程。

9.2 OncePerRequestFilter

在 SpringMVC 中,如果直接用普通 Filter,有时会遇到一次请求中被多次触发的问题。 Spring 提供了一个更常用的抽象类:

java
OncePerRequestFilter

它保证在同一次请求中,核心过滤逻辑只执行一次。

java
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);
    }
}

注册方式:

java
@Override
protected Filter[] getServletFilters() {
    return new Filter[]{new CustomFilter()};
}

9.3 字符编码过滤器

Spring 还提供了一个非常常见的 Filter:

java
CharacterEncodingFilter
java
@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 时一起决定。

HandlerMapping 参与匹配拦截器示意
HandlerMapping 参与匹配拦截器示意

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

不同请求匹配不同拦截器示意
不同请求匹配不同拦截器示意

最终会形成一个 HandlerExecutionChain

  • 里面有 Handler
  • 里面有多个 HandlerInterceptor
HandlerExecutionChain 结构示意
HandlerExecutionChain 结构示意

9.6 HandlerInterceptor 的三个方法

实现拦截器时,最重要的是这三个方法:

preHandle

  • 在 Handler 执行之前执行
  • 返回 true:继续流程
  • 返回 false:中断流程

postHandle

  • 在 Handler 执行之后执行
  • 只有真正执行到了 Handler 才会触发

afterCompletion

  • 请求完成后执行
  • 即便某个 preHandle 返回 false,前面已经执行成功的那部分拦截器,依然会进入 afterCompletion

9.7 一个简单拦截器示例

java
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 注册多个拦截器

java
@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:倒序

例如:

text
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
11 / Section

10. 异常处理

如果在 Handler 中抛出了异常,而我们完全不处理,通常会有两个问题:

  • 对用户不友好
  • 可能泄露服务端实现细节

因此异常处理必须做统一设计。

10.1 HandlerExceptionResolver(了解)

这是一种比较底层、偏老的全局异常处理方式。

它的返回值通常是 ModelAndView

java
@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 响应体

例如返回视图名:

java
@ControllerAdvice
public class CustomExceptionControllerAdvice {

    @ExceptionHandler(ArithmeticException.class)
    public String resolveArithmeticException() {
        return "/exception.jsp";
    }
}

10.3 返回 JSON:@RestControllerAdvice

如果希望异常处理方法统一响应 JSON,最方便的写法是:

java
@RestControllerAdvice
public class CustomExceptionControllerAdvice {

    @ExceptionHandler(ArithmeticException.class)
    public BaseRespVo<Void> resolveArithmeticException() {
        BaseRespVo<Void> vo = new BaseRespVo<>();
        vo.setStatus(500);
        vo.setMsg("算术异常!");
        return vo;
    }
}

@RestControllerAdvice 本质上等于:

java
@ControllerAdvice + @ResponseBody

这意味着这里面的方法返回值都会直接写入响应体。

10.4 @ExceptionHandler 的值是什么

@ExceptionHandlervalue 本质上是一个异常类型数组:

java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExceptionHandler {
    Class<? extends Throwable>[] value() default {};
}

也就是说,一个方法理论上可以处理多个异常类型。

10.5 推荐的异常处理思路

项目里更推荐这样拆:

  • 业务异常:单独处理,返回业务码和业务提示
  • 参数异常:单独处理,返回明确参数错误信息
  • 未知异常:统一兜底,避免把堆栈细节暴露给前端

例如:

java
@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 日期格式解析与响应 字段

本章总结

这一章真正要吃透的,不是零散注解,而是整条链路:

  1. 请求先进入 DispatcherServlet
  2. 再通过 HandlerMapping 找到对应 Handler
  3. HandlerAdapter 完成参数绑定与方法调用
  4. 返回值再交给视图解析器或消息转换器处理
  5. 如果配置了拦截器和异常处理器,它们也会进入这条统一流程

如果你能把下面这些问题说清楚,SpringMVC 这一章就算真的掌握了:

  • 为什么 SpringMVC 比手写 Servlet 更适合做业务开发?
  • @RequestMapping 的核心属性有哪些?最重要的是哪个?
  • key=value 参数、JSON 请求体、路径变量分别该怎么接?
  • @ResponseBody@RestController 到底是什么关系?
  • DispatcherServletHandlerMappingHandlerAdapter 各干什么?
  • Filter 和 Interceptor 的边界是什么?
  • 静态资源为什么会被 SpringMVC 拦走?怎么放行?
  • 全局异常处理为什么值得做?

实战练习

<!-- 实战练习内容已分离到 ../practices/markdown/14-spring-mvc-practice.md -->

建议先完成本章重点内容复习,再进入配套练习。 练习顺序建议按:基础 -> 进阶 -> 综合挑战。