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 自动配置的 Tomcat、Spring 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 捕获路径变量

我们遇到了什么新需求?

在上面的 updateUser 和 deleteUser 方法中,我们需要明确操作的是“哪一个”用户。在 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。其中 keyword 和 page 就是查询参数。

解决方案:

使用 @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 注解提供了 required 和 defaultValue 两个非常有用的属性。

第三步:优化 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 搜索,并支持分页参数 page,page 不传时默认为 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);
}
}