Spring MVC 与 REST
这一课你将能做到
- 独立实现一套 RESTful 任务 CRUD 接口(列表 / 详情 / 新增 / 修改 / 删除),并用 curl 全流程验证
- 正确使用 @PathVariable、@RequestParam、@RequestBody 三种参数接收方式,并说清各自对应的 HTTP 位置
- 用 ResponseEntity 精确控制状态码(201/204/404),并落地一套统一响应结构
为什么要学
前端页面、小程序、App、第三方系统,全部通过 HTTP 接口消费你的后端能力。Spring MVC 就是把「一个 Java 方法」变成「一个 HTTP 接口」的翻译官:URL 映射到方法、JSON 自动转对象、对象自动转 JSON。本站前端的每次点击,背后都打到这样一组接口上。
REST 是目前最主流的接口设计风格:用 URL 表示资源,用 HTTP 方法表示动作——GET /api/tasks 是查列表,DELETE /api/tasks/5 是删 5 号任务。看 URL 就知道在操作什么,这套「动词 + 名词」的默契,是你和前端协作的共同语言。
先建立图像
一个 POST 请求的完整旅程:
POST /api/tasks body: {"title":"学习AOP","priority":2}
↓
内嵌 Tomcat 接收,交给 DispatcherServlet(前端控制器,MVC 的总调度)
↓
HandlerMapping:根据 URL + HTTP 方法找到 TaskController.create()
↓
HandlerAdapter:执行方法前完成三件翻译——
路径变量 → @PathVariable 参数
查询串 → @RequestParam 参数
JSON体 → Jackson 反序列化 → @RequestBody 参数
↓
你的方法执行,返回 Task 对象
↓
Jackson 序列化成 JSON + HTTP 状态码 → 响应给调用方
记住 DispatcherServlet 这个名字:所有请求的总入口,后面排查「为什么映射到这个方法」「参数为什么没绑上」都以它为起点。
| HTTP 方法 | 语义 | 对应操作 |
|---|
| GET | 读取,幂等且安全 | 查列表、查详情 |
| POST | 创建非幂等资源 | 新增 |
| PUT | 整体替换,幂等 | 全量更新 |
| DELETE | 删除,幂等 | 删除 |
跟着做
步骤 1:领域模型与内存存储
先用内存 Map 代替数据库(第 9、10 课再换成真数据库),保证本课代码零依赖可跑:
package com.example.taskflow.model;
import java.time.LocalDateTime;
public record Task(
Long id,
String title,
int priority, // 1 最高
String status, // TODO / DOING / DONE
LocalDateTime createdAt
) {
public Task withStatus(String newStatus) {
return new Task(id, title, priority, newStatus, createdAt);
}
}
package com.example.taskflow.service;
import com.example.taskflow.model.Task;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class TaskService {
private final Map<Long, Task> store = new ConcurrentHashMap<>();
private final AtomicLong idGen = new AtomicLong();
public List<Task> list(String status) {
return store.values().stream()
.filter(t -> status == null || status.equals(t.status()))
.sorted(Comparator.comparingInt(Task::priority)
.thenComparing(Task::id))
.toList();
}
public Optional<Task> findById(long id) { return Optional.ofNullable(store.get(id)); }
public Task create(String title, int priority) {
long id = idGen.incrementAndGet();
Task task = new Task(id, title, priority, "TODO", LocalDateTime.now());
store.put(id, task);
return task;
}
public Optional<Task> updateStatus(long id, String status) {
return Optional.ofNullable(store.computeIfPresent(id,
(k, old) -> old.withStatus(status)));
}
public boolean delete(long id) { return store.remove(id) != null; }
}
(对应上一课的 @AuditLog、PerfLogAspect 等切面会自动作用在这些方法上,可顺手观察。)
步骤 2:五个 RESTful 接口一次写全
package com.example.taskflow.controller;
import com.example.taskflow.model.Task;
import com.example.taskflow.service.TaskService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController // = @Controller + @ResponseBody
@RequestMapping("/api/tasks") // 类级公共前缀
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// GET /api/tasks?status=TODO
@GetMapping
public List<Task> list(@RequestParam(required = false) String status) {
return taskService.list(status);
}
// GET /api/tasks/3
@GetMapping("/{id}")
public ResponseEntity<Task> detail(@PathVariable long id) {
return taskService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); // 404
}
// POST /api/tasks body: {"title":"...","priority":2}
@PostMapping
public ResponseEntity<Task> create(@RequestBody CreateRequest req) {
Task created = taskService.create(req.title(), req.priority());
// 201 Created + Location: /api/tasks/新id
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
// PUT /api/tasks/3 body: {"status":"DONE"}
@PutMapping("/{id}")
public ResponseEntity<Task> update(@PathVariable long id, @RequestBody StatusRequest req) {
return taskService.updateStatus(id, req.status())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// DELETE /api/tasks/3
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
return taskService.delete(id)
? ResponseEntity.noContent().build() // 204
: ResponseEntity.notFound().build(); // 404
}
// DTO 直接放 Controller 文件里演示,正式项目单独建包
public record CreateRequest(String title, int priority) { }
public record StatusRequest(String status) { }
}
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" \
-d '{"title":"学习AOP","priority":1}'
curl "http://localhost:8080/api/tasks?status=TODO"
curl -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" \
-d '{"status":"DONE"}'
curl -X DELETE http://localhost:8080/api/tasks/1 -i
逐个观察返回:POST 返回 201 和新任务 JSON;PUT 返回更新后的对象;DELETE 返回 204(无响应体)。
步骤 3:三种参数接收方式对照练习
// ① @PathVariable:URL 路径里的值,表示「资源是谁」
@GetMapping("/{id}/comments/{commentId}")
public String comments(@PathVariable long id, @PathVariable long commentId) {
return "任务" + id + "的评论" + commentId;
}
// ② @RequestParam:URL 问号后面的值,表示「筛选/分页条件」
@GetMapping("/search")
public List<Task> search(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
return taskService.list(keyword);
}
// ③ @RequestBody:请求体 JSON,表示「提交的数据」,仅 POST/PUT 使用
@PostMapping("/batch")
public int batch(@RequestBody List<CreateRequest> requests) {
requests.forEach(r -> taskService.create(r.title(), r.priority()));
return requests.size();
}
选择口诀:资源身份进路径,查询条件进问号,提交数据进请求体。
步骤 4:统一响应结构(真实项目必备)
直接返回裸对象的问题是:成功和失败的 JSON 结构不一致,前端要写两套解析。统一包装:
package com.example.taskflow.common;
public record ApiResponse<T>(int code, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data);
}
public static ApiResponse<Void> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}
@PostMapping
public ResponseEntity<ApiResponse<Task>> create(@RequestBody CreateRequest req) {
Task created = taskService.create(req.title(), req.priority());
return ResponseEntity
.created(URI.create("/api/tasks/" + created.id()))
.body(ApiResponse.ok(created));
}
本站后端的接口返回就是这种模式:HTTP 状态码表示「传输层成不成功」,业务码 code 表示「业务成不成功」,两者职责分离。下一课的全局异常处理器会负责所有失败分支的包装。
步骤 5:POST 的「插入式」设计——为什么先定契约
动手前先写接口文档(哪怕只是写在 wiki 里):URL、方法、入参 JSON、出参 JSON、错误码。前后端并行开发的正确节奏是先冻结契约,再各自实现。对照本站前端请求代码能看到:前端对接的就是这套 URL + JSON 结构,后端改字段名=前端崩——契约稳定比实现优雅更重要。
讲清楚要点
- @RestController 返回的不是页面是数据:方法返回值经 HttpMessageConverter(JSON 场景是 Jackson)直接写入响应体;传统 @Controller 返回视图名,前后端分离项目一律用 @RestController。
- HTTP 状态码是接口的「第一信号」:200 成功、201 已创建、204 成功无内容、400 参数错、401 未认证、403 无权限、404 不存在、500 服务器错误。只返 200 + 错误标记会让网关、监控、重试机制全部失明。
- DTO 与领域模型分离:接口出入参用专门的 record(CreateRequest/StatusRequest),不要把数据库实体直接暴露——字段增删、脱敏、校验都不至于牵连存储层。
- URL 设计用复数名词:
/api/tasks/3/comments/5 表达「资源层级」;动词不要出现在 URL 里(/api/deleteTask 是反模式),动作由 HTTP 方法表达;确有非 CRUD 动作(如 /api/tasks/3/archive)可接受动名词。
- Content-Type 要对齐:@RequestBody 要求请求头
Content-Type: application/json,curl 少了这个头,400 报错「Required request body is missing」是最常见的乌龙。
你可能会懵的地方
-
@Controller 和 @RestController 到底差在哪?
@RestController = @Controller + @ResponseBody。前者每个方法返回视图名;后者每个方法返回值直接序列化为响应体。REST 项目统一用后者。
-
@RequestParam 和 @PathVariable 能同时用吗?
能,且很常见:GET /api/tasks/{id}?fields=detail 里 id 用 @PathVariable(资源身份)、fields 用 @RequestParam(附加选项)。它们来自 HTTP 报文的不同位置,互不冲突。
-
GET 请求能用 @RequestBody 吗?
语法上可以但强烈不建议:GET 的语义是「无请求体的安全读取」,部分网关/客户端会丢弃 GET body。复杂查询条件用查询参数或改为 POST 搜索接口(如 /api/tasks/search)。
-
接口返回 LocalDateTime 为什么变成一串数组?
Jackson 默认序列化时间对象的怪格式。加依赖 jackson-datatype-jsr310(Boot 已自带)并配置 spring.jackson.serialization.write-dates-as-timestamps: false,或全局配置日期格式,输出即变成 "2026-10-06T20:30:00" 标准格式。
-
PUT 和 PATCH 什么区别?
PUT 全量替换(客户端提交完整资源),PATCH 局部更新(只传变化字段)。本课演示用 PUT 简化;字段多的资源建议 PATCH + @PatchMapping。
-
404 和 400 是谁产生的?
404「URL 没有对应处理器」由 DispatcherServlet 产生;400「参数绑定/格式错」由参数解析阶段产生,比如路径传 abc 给 long 类型。分清这层,报错才知道往哪查。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 所有接口都返回 200,错误放 body 里 | 监控/网关/重试全部失明 | 语义化状态码 + 业务码分离 |
URL 写动词 /api/getTaskList | 违反 REST 语义 | GET /api/tasks,动作由 HTTP 方法表达 |
| GET 接口用 @RequestBody 收参 | 部分客户端丢 body,语义错误 | GET 用 @RequestParam/@PathVariable |
| 直接返回数据库实体 | 字段耦合、可能泄露敏感列 | 出入参走 DTO record |
| curl 测 POST 忘带 Content-Type: application/json | 400,body 读不到 | 请求头与 @RequestBody 配套 |
| 方法返回实体但忘了 404 分支 | 不存在时返回 null/500 | Optional + ResponseEntity.notFound() |
卡点急救
- 404:代码没报错但接口访问不到:核对 URL(含类级 @RequestMapping 前缀)、HTTP 方法是否匹配(@PostMapping 的接口用 GET 访问会 404)、Controller 是否在扫描包内。
- 400:
Failed to convert value of type 'java.lang.String' to required type 'long':路径变量类型与实际传值不符(传了非数字给 {id});或 @RequestParam 的 required 参数没传。
- 415:
Content-Type 'application/x-www-form-urlencoded' is not supported:@RequestBody 只吃 JSON,curl/Postman 里把请求头改成 application/json。
- 中文乱码:Boot 默认 UTF-8 一般不会出现;若用老工具链出现乱码,检查
server.servlet.encoding 配置与请求头 charset。
面试常问
-
Spring MVC 处理一个请求的流程?
DispatcherServlet 统一接收 → HandlerMapping 找到匹配的 Handler(方法)→ HandlerAdapter 执行(含参数绑定、消息转换、校验)→ 返回值经 HttpMessageConverter 序列化 → 响应;异常走 HandlerExceptionResolver(下一课的 @RestControllerAdvice 挂在这里)。
-
RESTful API 设计原则?
资源化 URL(名词复数)、HTTP 方法表达动作、无状态、正确使用状态码、版本管理(/api/v1)、HATEOAS 理想态(工程上常省略)。
-
@RequestParam、@PathVariable、@RequestBody 的区别?
分别绑定查询串、路径变量、请求体。资源身份用路径、过滤条件用查询串、提交数据用请求体。
-
接口幂等性怎么保证?(POST 重复提交)
天然幂等:GET/PUT/DELETE;POST 不幂等。方案:唯一业务键约束、幂等 token(先发后校验)、数据库唯一索引兜底。这是「新增接口」的高频追问。
自测三问
- POST /api/tasks 创建成功应该返回什么状态码?Location 头放什么?
GET /api/tasks?status=TODO&page=2 里的 status 和 page 用哪个注解接收?为什么?
- 统一响应结构里为什么 HTTP 状态码和业务 code 要同时存在?
小结一句话
Spring MVC 用 DispatcherServlet 把「URL + 方法 + JSON」翻译成 Java 方法调用,REST 风格让接口自解释——资源进路径、条件进问号、数据进请求体。
延伸