Note 07. Spring MVC 初探:从 HTTP 请求到控制器注解详解


Note 07. Spring MVC 初探:从 HTTP 请求到控制器

摘要:本章是我们从后端逻辑走向前台交互的起点。我们将暂时搁置底层原理,聚焦于一个核心任务:如何让我们的 Spring Boot 应用能够接收来自浏览器的请求,并给予响应。我们将通过构建一个用户管理的 API,亲手实践 @RestController@RequestMapping 及其衍生注解,并掌握处理 URL 路径变量和查询参数的各种技巧,为你真正进入 Web 开发领域打下坚实的基础。

本章学习路径

  1. 第一个 Web API:我们将创建第一个 Controller,使用 @RestController@GetMapping 注解,让我们的应用能通过浏览器访问并返回一句 “Hello, World!”,建立对 Web 请求最直观的认识。
  2. HTTP 请求方法详解:我们将系统学习 @GetMapping@PostMapping@PutMapping@DeleteMapping 这四个核心注解,理解它们分别对应的 HTTP 操作(查询、创建、更新、删除),并明确各自的使用场景。
  3. URL 设计的艺术:我们将深入探索 @RequestMapping 的强大功能,学习如何通过类级别注解组织接口、如何使用 @PathVariable 设计出优雅的 RESTful 风格 URL,例如 /users/101
  4. 请求参数的精准捕获:我们将重点掌握两种最常用的参数接收方式。第一种是使用 @RequestParam 注解,精准获取 URL 中的查询参数(如 ?keyword=admin),并学习处理可选参数和默认值。第二种是使用普通 Java 对象(POJO)来自动封装多个请求参数,体验 Spring MVC 带来的开发便利。

7.1. 编写你的第一个 API 接口

在上一章中,我们已经了解了 spring-boot-starter-web 这个依赖。它就像一个工具包,为我们提供了构建 Web 应用所需的一切,包括一个内置的 Web 服务器(默认为 Tomcat)。现在,我们要做的就是告诉这个服务器:当收到某个特定 URL 请求时,应该执行哪一段 Java 代码。这个“告诉”的过程,就是通过编写一个“控制器”(Controller)类来完成的。

7.1.1. 创建 Controller 类

首先,我们需要在一个统一的包下管理所有的 Controller。这是一种行业通用的规范,便于项目结构的维护。

操作步骤:在 com.example.demo 主包下创建一个名为 controller 的新包。

接着,在这个新包里,我们来创建第一个控制器 HelloController

文件路径src/main/java/com/example/demo/controller/HelloController.java

现在,我们来编写这个类的初始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @RestController 注解是两个注解的组合:
* 1. @Controller: 标记这个类是一个 Spring MVC 的控制器组件,Spring 容器会自动扫描并管理它。
* 2. @ResponseBody: 告诉 Spring MVC,这个类中所有方法的返回值,都应该直接作为 HTTP 响应的内容(Body),
* 而不是一个视图名称(比如 JSP 页面的名字)。对于开发 RESTful API 来说,这正是我们需要的。
*/
@RestController
public class HelloController {

/**
* @GetMapping("/hello") 注解的作用:
* 将 HTTP 的 GET 请求映射到这个方法上。
* 当用户通过浏览器或工具访问 "http://服务器地址: 端口号/hello" 这个 URL 时,
* Spring MVC 就会调用下面的 sayHello() 方法来处理这个请求。
*/
@GetMapping("/hello")
public String sayHello() {
return "你好,Spring Boot 3!";
}
}

代码核心要点解读

  • @RestController: 这是一个“复合注解”,它告诉 Spring Boot 两件事:第一,HelloController 是一个控制器,请把它交由 Spring 容器管理;第二,这个控制器专门用来返回数据(如字符串、JSON),而不是用来跳转页面。这是现代前后端分离架构中最常用的注解。
  • @GetMapping("/hello"): 这个注解精确地定义了一个“路由规则”。它告诉 Spring MVC,如果有一个 GET 类型的 HTTP 请求,其访问路径是 /hello,那么就应该由 sayHello() 这个方法来处理。

7.1.2. 启动与验证

现在,让我们启动 DemoApplication 主类的 main 方法。当控制台输出 Tomcat started on port(s): 8080 时,就代表我们的内嵌 Web 服务器已经带着我们刚编写的 HelloController 成功启动了。

打开你的浏览器,在地址栏输入 http://localhost:8080/hello 并回车。

如果页面上成功显示了 “你好,Spring Boot 3!”,恭喜你!你已经成功编写并运行了你的第一个 Web API 接口。这个看似简单的过程,背后实际上是 spring-boot-starter-web 自动配置的 TomcatSpring MVC 等一系列组件协同工作的结果。


7.2. HTTP 请求方法:不仅仅是 GET

在 Web 世界中,HTTP 协议定义了一套“动词”(Verbs)来表达对资源的不同操作意图。我们刚刚使用的 GET 只是其中之一,它通常用来表示“获取”或“查询”资源。在一个完整的应用中,我们还需要处理创建、更新、删除等操作。Spring MVC 为此提供了一系列一一对应的注解。

为了更好地演示,我们创建一个 UserController,专门用于处理与用户相关的操作。

文件路径src/main/java/com/example/demo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.example.demo.controller;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users") // 在类级别设置一个统一的 URL 前缀
public class UserController {

// 篇幅所限,这里我们用日志模拟数据库操作
// 实际项目中,这里会调用 Service 层的方法

/**
* 查询操作:获取所有用户列表
* HTTP 方法: GET
* URL: /users
*/
@GetMapping
public String getAllUsers() {
System.out.println("正在查询所有用户信息...");
return "返回所有用户列表";
}

/**
* 创建操作:新增一个用户
* HTTP 方法: POST
* URL: /users
*/
@PostMapping
public String createUser() {
System.out.println("正在创建新用户...");
return "成功创建新用户";
}

/**
* 更新操作:修改指定 ID 的用户信息
* HTTP 方法: PUT
* URL: /users/{id} (这里的 {id} 是一个动态路径变量)
*/
@PutMapping("/{id}")
public String updateUser(@PathVariable Long id) {
System.out.println("正在更新 ID 为 " + id + " 的用户信息...");
return "成功更新 ID 为 " + id + " 的用户";
}

/**
* 删除操作:删除指定 ID 的用户
* HTTP 方法: DELETE
* URL: /users/{id}
*/
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id) {
System.out.println("正在删除 ID 为 " + id + " 的用户信息...");
return "成功删除 ID 为 " + id + " 的用户";
}
}

在这个 UserController 中,我们接触到了几个新的、至关重要的注解:

注解HTTP 方法惯例用途示例 URL描述
@GetMappingGET查询 资源/users用于获取资源列表或单个资源的详情。它是幂等的(Idempotent),即多次调用返回相同的结果。
@PostMappingPOST创建 资源/users用于提交数据以创建一个新的资源。它不是幂等的,多次调用会创建多个资源。
@PutMappingPUT更新 资源(完整替换)/users/101用于更新一个已存在的资源,通常需要提供该资源的完整信息。它是幂等的。
@DeleteMappingDELETE删除 资源/users/101用于删除一个指定的资源。它也是幂等的。

什么是幂等性 (Idempotence)?
简单来说,同一个请求,执行一次和执行 N 次,对资源产生的影响是完全相同的。查询、更新(完整替换)和删除操作都具备此特性,而创建操作则不具备。理解幂等性对于设计健壮的、可重试的 API 至关重要。

由于浏览器地址栏直接访问默认都是 GET 请求,要测试 POST, PUT, DELETE 请求,我们需要使用专业的 API 测试工具,例如 Postman 或 IntelliJ IDEA 内置的 HTTP Client。


7.3. URL 设计:@RequestMapping 与 @PathVariable

一个设计良好、易于理解的 URL 结构是 API 的门面。Spring MVC 提供了强大的工具来帮助我们组织和设计 URL。

7.3.1. 类级别 @RequestMapping:为接口分组

我们遇到了什么问题?

随着 UserController 中的接口越来越多,如果我们为每个方法都编写完整的 URL,如 @GetMapping("/users")@PostMapping("/users"),会出现大量重复的 /users 前缀。这不仅繁琐,而且如果未来需要将 /users 修改为 /api/v1/users,就需要修改每一个注解,极易出错。

解决方案

将共同的路径前缀提取到类级别的 @RequestMapping 注解上。这样,方法级别的路径会自动拼接在类级别路径之后。

代码解读(回顾 UserController):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/users") // 1. 定义所有接口的公共父路径
public class UserController {

/**
* 2. 此处的路径为空,因此最终访问路径就是 /users
* HTTP 方法: GET
*/
@GetMapping
public String getAllUsers() { /* ... */ }

/**
* 3. 此处的路径也为空,最终访问路径也是 /users
* HTTP 方法: POST
*/
@PostMapping
public String createUser() { /* ... */ }
}

通过这种方式,我们将 /users 这个“命名空间”与具体的 GET, POST 操作解耦,使得代码结构更清晰,维护也更便捷。

7.3.2. 动态 URL:使用 @PathVariable 捕获路径变量

我们遇到了什么新需求?

在上面的 updateUserdeleteUser 方法中,我们需要明确操作的是“哪一个”用户。在 RESTful API 设计风格中,资源的唯一标识符(如用户 ID)通常直接放在 URL 路径中,形成类似 /users/101(操作 ID 为 101 的用户)这样的动态 URL。

解决方案

Spring MVC 使用 {占位符} 的语法来定义路径中的变量,并使用 @PathVariable 注解将这部分动态的值绑定到方法的参数上。

代码解读(回顾 UserController):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @PutMapping("/{id}"):
* - "{id}" 定义了一个名为 "id" 的路径变量。
* - 这条规则可以匹配 /users/101, /users/102 等任意 ID 的 URL。
*
* @PathVariable Long id:
* - @PathVariable 注解告诉 Spring MVC,需要从 URL 路径中提取变量。
* - Spring MVC 会自动找到名为 "id" 的路径变量 ({id}),
* - 并将其值(如 "101")转换为 Long 类型,然后赋值给方法参数 id。
*/
@PutMapping("/{id}")
public String updateUser(@PathVariable Long id) {
System.out.println("正在更新 ID 为 " + id + " 的用户信息...");
return "成功更新 ID 为 " + id + " 的用户";
}

常见错误示例

如果 @PathVariable 注解中的名称与路径占位符不一致,会怎么样?

1
2
3
4
5
// 错误示例: 占位符是 {id},但注解想找 "userId"
@PutMapping("/{id}")
public String updateUser(@PathVariable("userId") Long id) { // 运行时会抛出异常
// ...
}

在这种情况下,启动项目并访问 /users/101,后台会抛出 Missing URI template variable 'userId' for method parameter of type Long 异常。

正确修正

要么保持参数名与占位符一致,省略注解的 value:
@PutMapping("/{id}") public String updateUser(@PathVariable Long id)

要么在注解中显式指定占位符的名称:
@PutMapping("/{id}") public String updateUser(@PathVariable("id") Long userId)


7.4. 捕获请求参数:@RequestParam 与 POJO 封装

除了路径变量,Web 开发中更常见的是通过 URL 查询参数来传递数据,例如进行搜索、筛选和分页。

7.4.1. 单个参数捕获:@RequestParam

场景:我们需要实现一个用户搜索接口,URL 可能是这样的:/users/search?keyword=admin&page=1。其中 keywordpage 就是查询参数。

解决方案

使用 @RequestParam 注解,可以精准地从 URL 的 Query String (问号 ? 之后的部分) 中提取参数值。

第一步:在 UserController 中添加新方法

1
2
3
4
5
6
7
8
9
10
11
/**
* 带查询参数的搜索接口
* 访问 URL 示例: /users/search?keyword = admin&page = 2
*/
@GetMapping("/search")
public String searchUsers(
@RequestParam("keyword") String keyword,
@RequestParam("page") Integer page
) {
return "正在搜索用户,关键词: " + keyword + ", 页码: " + page;
}

代码解读

  • @RequestParam("keyword") String keyword: 从查询参数中寻找名为 keyword 的项,并将其值赋给 keyword 字符串变量。

第二步:处理可选参数与默认值

我们遇到了什么新问题?

如果用户搜索时只提供了关键词,没有提供页码(/users/search?keyword=admin),那么上面的代码会因为找不到 page 参数而报错(Required request parameter 'page' for method parameter type Integer is not present)。我们希望 page 参数是可选的,并且在用户不提供时默认为第 1 页。

解决方案

@RequestParam 注解提供了 requireddefaultValue 两个非常有用的属性。

第三步:优化 searchUsers 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 优化后的搜索接口,支持可选参数和默认值
*/
@GetMapping("/search_v2")
public String searchUsersV2(
// keyword 是必需的,如果请求中不包含,会返回 400 Bad Request 错误
@RequestParam("keyword") String keyword,

// page 是可选的 (required = false)
// 如果不传,则使用默认值 "1" (defaultValue = "1")
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page
) {
return "V2-正在搜索用户,关键词: " + keyword + ", 页码: " + page;
}

现在,这个接口就变得更加健壮了。

  • 访问 /users/search_v2?keyword=test -> 正常工作,page 取默认值 1
  • 访问 /users/search_v2?keyword=test&page=5 -> 正常工作,page 取值 5
  • 访问 /users/search_v2?page=5 -> 报错,因为必需的 keyword 参数缺失。

7.4.2. 多个参数的优雅封装:POJO 对象绑定

我们遇到了什么痛点?

想象一个复杂的报表查询接口,可能有十几个筛选条件:开始时间、结束时间、用户状态、部门 ID、订单类型…… 如果全部使用 @RequestParam,Controller 方法的参数列表会变得极长,代码显得非常臃肿且难以维护。

public String searchReport(@RequestParam String startDate, @RequestParam String endDate, @RequestParam Integer status, ...)

解决方案

Spring MVC 支持直接使用一个普通的 Java 对象(Plain Old Java Object, POJO)来接收一组相关的请求参数。它会自动将请求中的参数名与 POJO 中的属性名进行匹配,并完成赋值。

第一步:创建用于数据传输的 DTO 对象

我们通常会创建一个专门的类来承载这些参数,这类对象被称为 DTO(Data Transfer Object,数据传输对象)。

文件路径src/main/java/com/example/demo/dto/UserQueryDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.demo.dto;

// 为了简化代码,我们使用 Lombok 注解自动生成 getter, setter, toString 等方法
// 你需要在 pom.xml 中添加 Lombok 依赖,并确保 IDEA 安装了 Lombok 插件
import lombok.Data;

@Data // @Data 是 @Getter, @Setter, @ToString, @EqualsAndHashCode 和 @RequiredArgsConstructor 的组合
public class UserQueryDTO {

/**
* 搜索关键词,对应 URL 参数 `keyword`
*/
private String keyword;

/**
* 用户状态,对应 URL 参数 `status`
*/
private Integer status;

/**
* 角色 ID,对应 URL 参数 `roleId`
*/
private Long roleId;
}

最佳实践:在项目中引入 Lombok 依赖可以极大地简化实体类和 DTO 的代码,避免手写大量样板代码。这是 Java 企业级项目开发的标准配置。

第二步:在 Controller 中使用 DTO 接收参数

现在,我们可以在 UserController 中创建一个新的接口,直接使用这个 DTO 对象作为方法参数。

1
2
3
4
5
6
7
8
9
10
11
12
import com.example.demo.dto.UserQueryDTO; // 别忘了导入

// ... 在 UserController 类中 ...
/**
* 使用 DTO 对象统一接收查询参数
* URL 示例: /users/filter?keyword = manager&status = 1&roleId = 10
*/
@GetMapping("/filter")
public String filterUsers(UserQueryDTO query) {
System.out.println("收到的筛选条件: " + query.toString());
return "筛选条件: " + query.toString();
}

代码解读

  • filterUsers(UserQueryDTO query): 方法参数是一个 UserQueryDTO 对象,并且没有 @RequestBody 注解(这个注解我们将在下一章讲解 JSON 时用到)。
  • 当请求 /users/filter?keyword=manager&status=1 到达时,Spring MVC 会在后台默默地做以下事情:
    1. 创建一个 UserQueryDTO 的空对象实例。
    2. 查找请求中的参数,找到 keyword
    3. UserQueryDTO 对象中寻找名为 setKeyword 的方法,并调用它,将值 "manager" 传入。
    4. 查找请求中的参数,找到 status
    5. UserQueryDTO 对象中寻找名为 setStatus 的方法,并调用它,将值 "1" 转换为 Integer 类型后传入。
    6. 所有参数匹配完毕后,将这个填充好数据的 query 对象传递给我们的 filterUsers 方法。

这种方式让我们的 Controller 代码保持了极高的整洁度和可读性,是处理多参数场景下的不二之选。


7.5. 本章总结与 Web API 速查

摘要回顾
本章我们完成了从 0 到 1 的跨越,成功让我们的 Java 程序“上网”了。我们没有陷入复杂的理论,而是通过动手实践,掌握了构建一个 Web API 最核心、最常用的几个武器。我们理解了如何使用 @RestController 声明一个数据接口,如何通过 @GetMapping@PostMapping 等注解来响应不同的 HTTP 操作,并通过 @RequestMapping@PathVariable 设计出了清晰的 URL 结构。最后,我们还学习了 @RequestParam 和 POJO 绑定这两种处理请求参数的强大技巧。

遇到以下 4 种 Web API 场景时,请直接参考下方的标准代码模版:

7.5.1. 场景一:提供一个简单的列表查询接口

  • 需求:获取所有商品列表。
  • 设计:使用 GET 方法,URL 为 /products
  • 方案
1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping
public List<Product> listAllProducts() {
// ... 调用 service 层获取数据
return productService.list();
}
}

7.5.2. 场景二:获取特定 ID 资源的详情

  • 需求:根据 ID 获取单个用户的详细信息。
  • 设计:使用 GET 方法,URL 为 /users/{userId},其中 {userId} 是动态的。
  • 方案
1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{userId}")
public User getUserById(@PathVariable("userId") Long id) {
// ... 调用 service 层根据 ID 查询
return userService.getById(id);
}
}

7.5.3. 场景三:处理带可选参数的分页查询

  • 需求:搜索文章,可以根据关键词 q 搜索,并支持分页参数 pagepage 不传时默认为 1。
  • 设计:使用 GET 方法,URL 为 /articles/search?q=...&page=...
  • 方案
1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/articles")
public class ArticleController {
@GetMapping("/search")
public PageResult<Article> search(
@RequestParam("q") String query,
@RequestParam(name = "page", required = false, defaultValue = "1") Integer pageNum
) {
// ... 执行带分页的搜索逻辑
return articleService.search(query, pageNum);
}
}

7.5.4. 场景四:处理包含多个筛选条件的复杂查询

  • 需求:根据订单状态、创建起始时间、客户 ID 等多个条件筛选订单。
  • 设计:使用 GET 方法,将所有筛选条件作为查询参数,并用一个 DTO 对象来接收。
  • 方案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 定义 DTO
@Data
public class OrderQueryDTO {
private Integer status;
private String createTimeStart;
private String createTimeEnd;
private Long customerId;
}

// 2. 编写 Controller
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/filter")
public List<Order> filterOrders(OrderQueryDTO query) {
// ... 将 DTO 传递给 service 层进行复杂查询
return orderService.findByQuery(query);
}
}