第2课:构建完整的 RESTful API
预计时间:45-60 分钟 | 前置:第1课:Hello World——4 道挑战已全部完成
端口:你第1课把端口改成了
8081,本课所有命令都按 8081 写。想换回默认的 8080,改 server.port 即可。
做得好的
- 4 道挑战一道不落:
/hello/{name}、/about、server.port=8081,以及第 4 题——你把hello-spring-boot-0.0.1-SNAPSHOT.jar(19 MB) 单独放进了code/_hello-spring-boot/test/,而那个目录里 只有这一个文件:没有src、没有mvnw、 没有pom.xml,它照样启动、照样能访问。 你亲手验证了「内嵌服务器」到底省掉了什么—— 第1课的底子这下真齐了,这正是本课要用的基础。 /hello/{name}里@PathVariable用得对,方法重载 (同名hello、不同路径)也让 Spring 正确区分开了。 你是先在「有疑问」问清用法、再自己写出来的——这个闭环走得很漂亮。/about返回的是Map<String, String>而不是拼字符串。 三个字段的值都是字符串,你就老老实实用String作值类型, 没有图省事写成Object——这是很准的判断, 所以它的Content-Type是真的application/json。- 类和方法都带中文 Javadoc,连新写的
/about也补上了 ("使用 Map 构造数据,Spring 会自动序列化为 JSON")。 说明你不是照抄完就走,是真理解了才写得出这句注释。
需要改的
- 锁文件还在。
src/main/java/com/.LCKHelloController.java~与target/classes/com/里那一份都还在(时间戳仍是 09-16 20:27)。 第1课的清理提示和上一次审视都提过它。删掉——留着会把提交记录弄脏。 /hello/{name}的代码风格和文件里其它方法不一致: 只有它把{换到了下一行,也只有它没写 Javadoc。 风格不统一会让之后的 diff 全是噪音——本课会带你把整套代码写整齐。- 那一行返回值有三个小毛病:
"hello,"开头是小写(上面那句是"Hello, Spring Boot!")、逗号后少一个空格、+两侧空格不一致 (name +"!")。建议统一成"Hello, " + name + "!",和/hello的措辞对齐。 - 一个提醒(不是错):
Map.of(...)建出来的是只读的。 本课要往里增删任务,所以会换成new HashMap<>()。 你那个/about只是只读展示,用Map.of完全正确。
- 删掉那两个
.LCK锁文件(src里和target/里各一个) - 把
/hello/{name}的返回值改成"Hello, " + name + "!", 并照着上面两个方法给它补一行 Javadoc
@ 注解)还会多一段
④ 规范写法——可直接照抄的最小代码示例。
比如把鼠标放到 PATCH 上试试。每个名词只在第一次出现时带下划线,之后再出现只保留颜色,不再重复弹窗。 全部 69 个名词都记录在 名词速查笔记 GLOSSARY.md(带目录,可搜索)。 答疑和笔记都收在 DSH 笔记文档页。
RESTful API 设计原则
REST(Representational State Transfer)是一种 API 设计风格, 它建立在 HTTP 之上,遵循以下原则:
每个 URL 代表一个资源,使用名词而非动词:
/api/tasks- 任务集合/api/tasks/1- 单个任务/api/getTasks- 避免动词
| 方法 | 语义 | 示例 | 幂等性 |
|---|---|---|---|
| GET | 获取资源 | GET /api/tasks | 是 |
| POST | 创建资源 | POST /api/tasks | 否 |
| PUT | 更新资源(全量) | PUT /api/tasks/1 | 是 |
| DELETE | 删除资源 | DELETE /api/tasks/1 | 是 |
幂等性:多次请求产生相同结果,不会重复创建资源。 注意 POST 是唯一不幂等的常用方法——这正是它被用来「创建」的原因。
服务器用 状态码 告诉你这次请求的结果:
200 OK- 成功获取/更新201 Created- 成功创建204 No Content- 成功删除400 Bad Request- 请求参数错误404 Not Found- 资源不存在500 Internal Server Error- 服务器内部错误
| 分类 | 含义 | 谁的问题 | 你该做什么 |
|---|---|---|---|
| 2xx | 一切正常 | 都没问题 | 继续下一步 |
| 4xx | 请求有问题 | 你(客户端) | 检查 URL、参数、请求体 |
| 5xx | 服务端出错 | 我(服务器) | 翻服务端日志,别急着改请求 |
口诀收在上表「谁的问题」一列。完整对照表见 HTTP 方法与状态码速查表。
三层架构模式
职责分离:Controller 处理 HTTP → Service 处理业务逻辑 → Repository 访问数据
- 可测试性:每层可独立测试
- 可维护性:改一层不动其他层
- 可复用性:Service 可被多个 Controller 调用
三层各守自己的边界,越界就是后面难维护的根源:
先点左边的行,再点上方的列。
| 管什么 | 该写什么 | 不该写什么 | |
|---|---|---|---|
| Controller | HTTP 出入口 | 收参数、调 Service、返回响应 | 业务逻辑,一行都不写 |
| Service | 业务逻辑 | 规则判断、组合数据、事务边界 | HTTP 细节,不碰请求与响应 |
| Repository | 数据访问 | 增删改查 | 业务规则判断 |
实战:构建任务管理 API
code/_hello-spring-boot/hello-spring-boot,
包名是 com.sutrasky.hello_spring_boot。
下面所有代码都按你的包名写,直接建在同一项目里,不要新建工程。
- Web 依赖在你 pom 里叫
spring-boot-starter-webmvc(老教程写的spring-boot-starter-web是 Boot 3 的叫法) - 数据校验要额外加
spring-boot-starter-validation,Boot 4 不再自带 - 参考:Spring Boot 4.0 迁移指南 |validation starter 说明
本课要实现的是一个标准的 CRUD 接口集——创建(Create)、查询(Read)、更新(Update)、删除(Delete)任务。 一共 5 个接口,分 5 步写出来。
- 步骤1:创建数据模型Task 实体
- 步骤2:创建服务层TaskService
- 步骤3:创建控制器层TaskController
- 步骤4:全局异常处理异常总台
- 步骤5:补上校验依赖pom 依赖
步骤1:创建数据模型
数据在网络上用 JSON 传输,在 Java 里则用对象表示。新建
src/main/java/com/sutrasky/hello_spring_boot/model/Task.java:
package com.sutrasky.hello_spring_boot.model;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
public class Task {
private Long id;
@NotBlank(message = "任务标题不能为空")
@Size(min = 2, max = 100, message = "标题长度必须在2-100之间")
private String title;
private String description;
private boolean completed;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// 无参构造:Spring 反序列化 JSON 时需要
public Task() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public Task(Long id, String title, String description) {
this();
this.id = id;
this.title = title;
this.description = description;
}
// Getter / Setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@NotBlank- 字符串必须有实际内容,不能是空或纯空格@Size- 限定长度范围(这里 2~100)
@Valid。
步骤2:创建服务层
新建 service/TaskService.java。
用 @Service 把它交给 Spring 管理,业务逻辑都写在这里:
package com.sutrasky.hello_spring_boot.service;
import com.sutrasky.hello_spring_boot.model.Task;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class TaskService {
// 模拟数据库,使用内存存储
private final Map<Long, Task> tasks = new HashMap<>();
private final AtomicLong counter = new AtomicLong();
public List<Task> getAllTasks() {
return new ArrayList<>(tasks.values());
}
public Task getTaskById(Long id) {
Task task = tasks.get(id);
if (task == null) {
throw new NoSuchElementException("任务不存在,ID: " + id);
}
return task;
}
public Task createTask(Task task) {
Long id = counter.incrementAndGet();
task.setId(id);
tasks.put(id, task);
return task;
}
public Task updateTask(Long id, Task taskDetails) {
Task existingTask = getTaskById(id);
existingTask.setTitle(taskDetails.getTitle());
existingTask.setDescription(taskDetails.getDescription());
existingTask.setCompleted(taskDetails.isCompleted());
existingTask.setUpdatedAt(LocalDateTime.now());
return existingTask;
}
public void deleteTask(Long id) {
getTaskById(id); // 不存在就抛异常,交给全局处理器
tasks.remove(id);
}
public List<Task> getTasksByStatus(boolean completed) {
return tasks.values().stream()
.filter(task -> task.isCompleted() == completed)
.toList();
}
}
HashMap 模拟数据库,重启应用数据就没了。
真实项目会用 JPA + 数据库,第3课就做这件事。
步骤3:创建控制器层
新建 controller/TaskController.java。控制器只负责「收请求、给响应」,
业务逻辑一行都不写在这里:
package com.sutrasky.hello_spring_boot.controller;
import com.sutrasky.hello_spring_boot.model.Task;
import com.sutrasky.hello_spring_boot.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
// 构造函数注入(推荐方式)
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// 获取所有任务,可按完成状态筛选
@GetMapping
public ResponseEntity<List<Task>> getAllTasks(
@RequestParam(required = false) Boolean completed) {
List<Task> tasks = (completed != null)
? taskService.getTasksByStatus(completed)
: taskService.getAllTasks();
return ResponseEntity.ok(tasks);
}
// 获取单个任务
@GetMapping("/{id}")
public ResponseEntity<Task> getTaskById(@PathVariable Long id) {
return ResponseEntity.ok(taskService.getTaskById(id));
}
// 创建任务
@PostMapping
public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
Task created = taskService.createTask(task);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// 更新任务
@PutMapping("/{id}")
public ResponseEntity<Task> updateTask(
@PathVariable Long id,
@Valid @RequestBody Task taskDetails) {
return ResponseEntity.ok(taskService.updateTask(id, taskDetails));
}
// 删除任务
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
}
@RestController- 声明「这个类处理网络请求,返回内容直接当响应体」@RequestMapping("/api/tasks")- 类级 URL 前缀,下面所有方法都带上它@GetMapping/@PostMapping/@PutMapping/@DeleteMapping- 把方法绑定到「某种 HTTP 方法 + 某条路径」@PathVariable- 取 URL 路径里的{id}@RequestParam- 取问号后面的查询参数,如?completed=true@RequestBody- 把请求体 JSON 自动转成Task对象@Valid- 触发Task字段上写好的校验规则ResponseEntity- 同时控制状态码、响应头、响应体
public TaskController(TaskService taskService) 上的
依赖注入并没有写 @Autowired。
Spring 对「只有一个构造函数」的类会自动注入,这就是推荐的
构造函数注入。
步骤4:全局异常处理
现在访问不存在的任务会抛出异常,返回一坨 500 报错堆栈,很不友好。
新建 exception/GlobalExceptionHandler.java 统一处理:
package com.sutrasky.hello_spring_boot.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Java record:不可变数据载体,一行定义好构造/getter/equals
public record ErrorResponse(
LocalDateTime timestamp,
int status,
String error,
String message,
Map<String, String> validationErrors
) {}
// 资源不存在 -> 404
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NoSuchElementException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(
LocalDateTime.now(),
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
null
));
}
// 校验失败 -> 400,并把每个字段的错误原因列出来
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String field = ((FieldError) error).getField();
errors.put(field, error.getDefaultMessage());
});
return ResponseEntity.badRequest().body(new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
"请求参数验证失败",
errors
));
}
// 兜底:其他未预料异常 -> 500
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneralError(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse(
LocalDateTime.now(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
"服务器内部错误:" + ex.getMessage(),
null
));
}
}
@RestControllerAdvice- 贴在类上:当全局的异常总台@ExceptionHandler- 贴在方法上:声明这个方法负责哪种异常
record 来定义错误响应结构,比写传统类省几十行样板代码。
三个处理方法的分工,一张对照表记住:
- NoSuchElementException
- 资源不存在 → 404 Not Found,message 用异常自带的说明
- MethodArgumentNotValidException
- 校验不通过 → 400 Validation Failed,validationErrors 逐字段列出原因
- Exception(兜底)
- 其余未预料的异常 → 500 Internal Server Error
步骤5:补上校验依赖
在 pom.xml 的 <dependencies> 里加:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
spring-boot-starter-validation,
@NotBlank、@Size 会被静默忽略——不报错,但也不校验,
这是新手最容易踩的坑之一。加完依赖记得让 Maven 重新加载。
运行并测试
在项目根目录(code/_hello-spring-boot/hello-spring-boot)执行:
mvnw.cmd spring-boot:run
看到 Tomcat started on port 8081 就成功了。
cURL 是命令行工具;HTTPie 更好读; Postman 有图形界面。任选一种:
# 1. 创建任务
curl -X POST http://localhost:8081/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "学习 Spring Boot", "description": "完成第2课"}'
# 2. 获取所有任务
curl http://localhost:8081/api/tasks
# 3. 获取单个任务
curl http://localhost:8081/api/tasks/1
# 4. 更新任务
curl -X PUT http://localhost:8081/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "学习 Spring Boot", "description": "已完成", "completed": true}'
# 5. 删除任务
curl -X DELETE http://localhost:8081/api/tasks/1
# 6. 按状态筛选
curl "http://localhost:8081/api/tasks?completed=true"
# 1. 创建任务
http POST :8081/api/tasks title="学习 Spring Boot" description="完成第2课"
# 2. 获取所有任务
http GET :8081/api/tasks
# 3. 获取单个任务
http GET :8081/api/tasks/1
# 4. 更新任务
http PUT :8081/api/tasks/1 title="学习 Spring Boot" description="已完成" completed:=true
# 5. 删除任务
http DELETE :8081/api/tasks/1
- 新建 Collection,命名 "Task API"
- 添加请求,选择对应的 HTTP 方法
- URL 填
http://localhost:8081/api/tasks - POST / PUT 时:Body → raw → 选 JSON,粘贴数据
- 点击 Send,看下方状态码和响应体
三个必做的验证
好的 API 不只「能用」,还要「用错时给出正确反馈」。逐个验证:
# 验证1:标题为空 -> 应该是 400 + 字段错误信息
curl -X POST http://localhost:8081/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "", "description": "测试"}'
# 验证2:标题只有1个字(@Size min=2)-> 应该是 400
curl -X POST http://localhost:8081/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "短", "description": "测试"}'
# 验证3:访问不存在的任务 -> 应该是 404 而不是 500
curl http://localhost:8081/api/tasks/999
知识检验
推荐阅读(按优先级)
- Spring 官方:Building a RESTful Web Service —— 本课的主参考,官方权威
- RESTful API 设计最佳实践 —— 系统讲设计规范
- Baeldung:Bean Validation —— 校验注解大全
- 本工作区:Spring Boot 核心注解速查表 —— 本课用到的 9 个注解都在里面有表格
- 本工作区:HTTP 方法与状态码速查表
课后挑战(做完再往下学)
- 给
Task加priority字段(枚举 LOW / MEDIUM / HIGH),并加@NotNull校验 - 加一个接口支持按优先级筛选:
GET /api/tasks?priority=HIGH - 加「标记完成」的专用接口:
PATCH /api/tasks/{id}/complete - (进阶)实现分页:
GET /api/tasks?page=0&size=10,返回时带上总条数
卡住了就把报错贴给我,我们一起看。