RESTful API 设计:让前后端好好说话
难度:中级 | 预计时间:40 分钟 | 前置:Spring Boot 入门
📖 这个 API 是谁设计的?
故事背景
你正在对接一个后端 API。获取用户列表:POST /api/getAllUsers。删除用户:GET /api/deleteUser?id=5。错误时返回 HTTP 200 但 body 里是 {"error":"something wrong"}。你作为前端,看着这些 API 想打人——REST 不是这样玩的。一个好的 RESTful API,你应该能从 URL 和方法名猜出它的行为。这节课让你写出让前端同事点赞的后端接口。
💻 代码演示
一个任务管理 REST API——标准的 CRUD + 正确的 HTTP 语义:
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
import java.util.*;
import java.util.concurrent.*;
@RestController
@RequestMapping("/api/tasks") // 统一前缀
public class TaskController {
private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
private final AtomicLong idGen = new AtomicLong(1);
// GET /api/tasks → 获取所有任务
@GetMapping
public List<Task> list() {
return new ArrayList<>(tasks.values());
}
// GET /api/tasks/1 → 获取单个任务
@GetMapping("/{id}")
public ResponseEntity<Task> get(@PathVariable Long id) {
Task task = tasks.get(id);
if (task == null) {
return ResponseEntity.notFound().build(); // 404
}
return ResponseEntity.ok(task); // 200
}
// POST /api/tasks → 创建任务
@PostMapping
public ResponseEntity<Task> create(@RequestBody TaskDTO dto) {
Task task = new Task(idGen.getAndIncrement(), dto.title(), dto.desc(), false);
tasks.put(task.id(), task);
return ResponseEntity.status(HttpStatus.CREATED).body(task); // 201
}
// PUT /api/tasks/1 → 完整更新
@PutMapping("/{id}")
public ResponseEntity<Task> update(@PathVariable Long id, @RequestBody TaskDTO dto) {
Task task = tasks.get(id);
if (task == null) return ResponseEntity.notFound().build();
Task updated = new Task(id, dto.title(), dto.desc(), dto.done());
tasks.put(id, updated);
return ResponseEntity.ok(updated);
}
// DELETE /api/tasks/1 → 删除任务
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (tasks.remove(id) == null) return ResponseEntity.notFound().build();
return ResponseEntity.noContent().build(); // 204
}
}
record Task(long id, String title, String desc, boolean done) {}
record TaskDTO(String title, String desc, boolean done) {}运行输出:
$ curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"学 REST","desc":"今天搞定","done":false}'
HTTP 201 Created
{"id":1,"title":"学 REST","desc":"今天搞定","done":false}
$ curl http://localhost:8080/api/tasks/999
HTTP 404 Not Found🎯 三个核心问题
这是什么?
REST(Representational State Transfer)是一套API 设计风格,不是协议。核心原则:①资源导向——URL 代表资源(/users/5),不是动作(/getUser)②HTTP 方法表达动作——GET 读、POST 创建、PUT 全量更新、PATCH 部分更新、DELETE 删除 ③状态码表达结果——200 OK、201 Created、204 No Content、400 Bad Request、404 Not Found、500 Internal Server Error。
为什么需要它?
一致的 API 风格让前后端协作成本骤降。前端看到 GET /users/5 就知道是获取用户,看到 POST /users 就知道是创建。不用翻文档猜"这个 API 是干什么的"。状态码让前端可以写统一的错误处理——401 就跳登录、403 就提示无权限、500 就显示系统错误。
如果没有它会怎样?
如果 API 不遵循 REST 风格(比如所有操作都用 POST,URL 里带动词),前端每次对接新接口都要读文档——完全无法从 URL 推断行为。状态码总是 200 但返回错误信息——前端无法用 HTTP 拦截器统一处理错误,每个请求都要自己解析 body 判断成功与否。
📝 原理讲解
REST API 速查表:
HTTP方法 URL路径 作用 成功状态码
─────────────────────────────────────────────
GET /api/tasks 获取全部任务 200 OK
GET /api/tasks/5 获取ID=5的任务 200 OK / 404
POST /api/tasks 创建新任务 201 Created
PUT /api/tasks/5 全量更新任务 200 OK / 404
DELETE /api/tasks/5 删除任务 204 No Content@RequestMapping("/api/tasks"):类级别的 URL 前缀,避免每个方法重复写。
ResponseEntity:让你完全控制 HTTP 响应——状态码、Header、Body。链式 API 非常流畅。
@RequestBody:把 HTTP 请求体自动反序列化为 Java 对象(Jackson 负责 JSON→Java)。
DTO(Data Transfer Object):用
record定义请求/响应的数据结构,不要直接暴露领域实体。@Valid / @Validated:配合 Jakarta Validation 注解(@NotNull, @Size 等)自动校验请求参数。
🎨 生活类比
类比理解
RESTful API 像餐厅的标准化菜单——客人不需要知道厨房怎么做菜,只需要看菜单(API 文档)点菜。菜单格式统一:前菜(GET)、主菜(POST/PUT)、甜点(DELETE)。每道菜有编号(资源 ID)。服务员(HTTP 状态码)会明确告诉你:菜已上(200)、正在做(201)、卖完了(404)、厨房着火了(500)。 DTO 像餐厅的传菜单——客人写的点单(请求 DTO)和厨房出来的成品(响应 DTO)不是同一个东西,中间经过加工(Service 层逻辑)。不要把厨房的原材料(Entity)直接端给客人。
✏️ 动手练习
练习 1
在上节课的项目中,写一个 UserController,实现用户注册和查询:POST /api/users(注册)、GET /api/users(列表)、GET /api/users/{id}(详情)。用内存 Map 存储。
<details> <summary>💡 查看提示</summary>
POST 返回 201 Created 而不是 200。如果 GET 找不到用户返回 404。URL 使用复数(users 不是 user)——这是 REST 惯例。
</details>
练习 2
给 User 加字段校验:用户名 @NotBlank、年龄 @Min(0) @Max(150)、邮箱 @Email。用 @Valid + @RequestBody 自动校验,错误时返回 400。
<details> <summary>💡 查看提示</summary>
加 spring-boot-starter-validation 依赖。在 Controller 方法的 @RequestBody 前加 @Valid。校验失败会抛 MethodArgumentNotValidException,Spring 自动返回 400。
</details>
练习 3
实现一个全局异常处理器 @RestControllerAdvice:把各种异常翻译成统一的错误 JSON 格式 {"code":404,"message":"..."}。
<details> <summary>💡 查看提示</summary>
用 @ExceptionHandler 注解方法,可以分别处理不同异常类型。@RestControllerAdvice 是全局的,所有 Controller 的异常都会被这里拦截。
</details>
✅ 自检站
<details> <summary><strong>POST /api/users 和 PUT /api/users/5 各表达什么语义?为什么要区分?</strong></summary>
POST 用于创建新资源——服务器分配 ID,所以 URL 不包含 ID。PUT 用于全量更新已存在的资源——必须知道是哪个资源,所以 URL 包含 ID。语义区分让 API 的行为可预测:看到 POST 就知道会创建,看到 PUT 就知道会修改。这也是"幂等性"的差异——PUT 调用多次结果相同(幂等),POST 每次调用都创建新资源(非幂等)。
</details>
<details> <summary><strong>为什么返回数据要用 DTO 而不是直接返回 Entity?</strong></summary>
①安全性——Entity 可能包含密码、手机号等敏感字段,DTO 可以屏蔽。②解耦——前端需要的字段格式和数据库表结构不一定一样(比如前端要 fullName = firstName + lastName)。③维护性——改数据库表不应该直接影响 API 响应(前端可能依赖特定字段名)。
</details>
<details> <summary><strong>201 Created 和 200 OK 在使用场景上有什么区别?</strong></summary>
201 Created 专用于资源创建成功的响应,语义更精确。200 OK 是通用的成功响应。用 201 让前端可以区分"创建成功"和"查询成功"——比如创建成功后自动跳转到详情页。另外 201 的 Response Header 里通常带 Location 字段指向新资源的 URL。
</details>