CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/Spring MVC 与 REST
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

Spring MVC 与 REST

@RestController、路径变量、请求体、统一响应。

40 分钟

Spring MVC 与 REST

这一课你将能做到

  1. 独立实现一套 RESTful 任务 CRUD 接口(列表 / 详情 / 新增 / 修改 / 删除),并用 curl 全流程验证
  2. 正确使用 @PathVariable、@RequestParam、@RequestBody 三种参数接收方式,并说清各自对应的 HTTP 位置
  3. 用 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 走一遍:

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

改造 Controller(以新增为例):

@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 结构,后端改字段名=前端崩——契约稳定比实现优雅更重要。

讲清楚要点

  1. @RestController 返回的不是页面是数据:方法返回值经 HttpMessageConverter(JSON 场景是 Jackson)直接写入响应体;传统 @Controller 返回视图名,前后端分离项目一律用 @RestController。
  2. HTTP 状态码是接口的「第一信号」:200 成功、201 已创建、204 成功无内容、400 参数错、401 未认证、403 无权限、404 不存在、500 服务器错误。只返 200 + 错误标记会让网关、监控、重试机制全部失明。
  3. DTO 与领域模型分离:接口出入参用专门的 record(CreateRequest/StatusRequest),不要把数据库实体直接暴露——字段增删、脱敏、校验都不至于牵连存储层。
  4. URL 设计用复数名词:/api/tasks/3/comments/5 表达「资源层级」;动词不要出现在 URL 里(/api/deleteTask 是反模式),动作由 HTTP 方法表达;确有非 CRUD 动作(如 /api/tasks/3/archive)可接受动名词。
  5. 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/json400,body 读不到请求头与 @RequestBody 配套
方法返回实体但忘了 404 分支不存在时返回 null/500Optional + ResponseEntity.notFound()

卡点急救

  1. 404:代码没报错但接口访问不到:核对 URL(含类级 @RequestMapping 前缀)、HTTP 方法是否匹配(@PostMapping 的接口用 GET 访问会 404)、Controller 是否在扫描包内。
  2. 400:Failed to convert value of type 'java.lang.String' to required type 'long':路径变量类型与实际传值不符(传了非数字给 {id});或 @RequestParam 的 required 参数没传。
  3. 415:Content-Type 'application/x-www-form-urlencoded' is not supported:@RequestBody 只吃 JSON,curl/Postman 里把请求头改成 application/json。
  4. 中文乱码: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(先发后校验)、数据库唯一索引兜底。这是「新增接口」的高频追问。

自测三问

  1. POST /api/tasks 创建成功应该返回什么状态码?Location 头放什么?
  2. GET /api/tasks?status=TODO&page=2 里的 status 和 page 用哪个注解接收?为什么?
  3. 统一响应结构里为什么 HTTP 状态码和业务 code 要同时存在?

小结一句话

Spring MVC 用 DispatcherServlet 把「URL + 方法 + JSON」翻译成 Java 方法调用,REST 风格让接口自解释——资源进路径、条件进问号、数据进请求体。

延伸

  • Spring 官方文档:Spring MVC
  • MDN:HTTP 方法语义
  • 下一课:参数校验与异常处理——让非法请求在门口被拦下,让所有错误返回统一格式。
加载本节练习…
← 上一课Spring AOP 入门下一课 →参数校验与异常处理