第2课:构建完整的 RESTful API

课程目标:构建一个完整的任务管理 API,掌握 RESTful 设计原则、三层架构、数据校验和错误处理。
预计时间:45-60 分钟 | 前置:第1课:Hello World——4 道挑战已全部完成
端口:你第1课把端口改成了 8081,本课所有命令都按 8081 写。想换回默认的 8080,改 server.port 即可。
上一课作业审视 检视对象:code/_hello-spring-boot/hello-spring-boot · 4 道挑战全部完成

做得好的

  • 4 道挑战一道不落/hello/{name}/aboutserver.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 完全正确。
开始本课之前,花 2 分钟做两件事:
  1. 删掉那两个 .LCK 锁文件(src 里和 target/ 里各一个)
  2. /hello/{name} 的返回值改成 "Hello, " + name + "!", 并照着上面两个方法给它补一行 Javadoc
这两条是习惯问题,不阻塞本课——做完直接往下读。
本课阅读方式: 正文里带彩色虚线下划线的名词,鼠标放上去就会弹出一个小窗, 里面是三段式① 官方定义(正式术语 + 英文全称)→ ② 一句话大白话③ 生活类比; 带用法的名词(比如各种 @ 注解)还会多一段 ④ 规范写法——可直接照抄的最小代码示例。 比如把鼠标放到 PATCH 上试试。
每个名词只在第一次出现时带下划线,之后再出现只保留颜色,不再重复弹窗。 全部 69 个名词都记录在 名词速查笔记 GLOSSARY.md(带目录,可搜索)。 答疑和笔记都收在 DSH 笔记文档页

RESTful API 设计原则

REST(Representational State Transfer)是一种 API 设计风格, 它建立在 HTTP 之上,遵循以下原则:

1 资源导向

每个 URL 代表一个资源,使用名词而非动词:

2 HTTP 方法语义化
方法 语义 示例 幂等性
GET 获取资源 GET /api/tasks
POST 创建资源 POST /api/tasks
PUT 更新资源(全量) PUT /api/tasks/1
DELETE 删除资源 DELETE /api/tasks/1

幂等性:多次请求产生相同结果,不会重复创建资源。 注意 POST 是唯一幂等的常用方法——这正是它被用来「创建」的原因。

3 状态码规范

服务器用 状态码 告诉你这次请求的结果:

分类含义谁的问题你该做什么
2xx一切正常都没问题继续下一步
4xx请求有问题你(客户端)检查 URL、参数、请求体
5xx服务端出错我(服务器)翻服务端日志,别急着改请求

口诀收在上表「谁的问题」一列。完整对照表见 HTTP 方法与状态码速查表

三层架构模式

T1浏览器发起 GET /api/tasks/1T2DispatcherServlet 按 URL 路由到控制器方法T3Service 处理业务,Repository 查库T4返回对象 → Jackson 序列化成 JSONT5响应写回浏览器(带状态码)
Controller 控制器层 Service 服务层 Repository 数据层

职责分离:Controller 处理 HTTP → Service 处理业务逻辑 → Repository 访问数据

为什么要分层?

三层各守自己的边界,越界就是后面难维护的根源:

先点左边的行,再点上方的列。

管什么该写什么不该写什么
ControllerHTTP 出入口收参数、调 Service、返回响应业务逻辑,一行都不写
Service业务逻辑规则判断、组合数据、事务边界HTTP 细节,不碰请求与响应
Repository数据访问增删改查业务规则判断

实战:构建任务管理 API

用你已有的项目: 你第1课建的项目在 code/_hello-spring-boot/hello-spring-boot, 包名是 com.sutrasky.hello_spring_boot。 下面所有代码都按你的包名写,直接建在同一项目里,不要新建工程。
你是 Spring Boot 4.1.1,依赖名和网上多数教程不同:

本课要实现的是一个标准的 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; }
}
这里的注解属于 Bean Validation 规范: 它们只是声明规则,真正触发检查的是后面的 @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();
    }
}
关键注解逐个看:
一个小细节值得注意: 构造函数 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
        ));
    }
}
两个注解分工明确: 上面用了 Java 16+ 的 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 重新加载。

运行并测试

1 启动应用

在项目根目录(code/_hello-spring-boot/hello-spring-boot)执行:

mvnw.cmd spring-boot:run

看到 Tomcat started on port 8081 就成功了。

2 用工具发请求

cURL 是命令行工具;HTTPie 更好读; Postman 有图形界面。任选一种:

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
  1. 新建 Collection,命名 "Task API"
  2. 添加请求,选择对应的 HTTP 方法
  3. URL 填 http://localhost:8081/api/tasks
  4. POST / PUT 时:Body → raw → 选 JSON,粘贴数据
  5. 点击 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
对照检查: 如果验证1、2 返回了 201 而不是 400,说明第5步的 validation 依赖没加上(校验被静默忽略)。 如果验证3 返回 500 和一坨堆栈,说明全局异常处理器没生效或类没被扫描到。

知识检验

问题1:REST API 中,获取资源列表应该用哪个 HTTP 方法?
问题2:@Valid 注解的作用是什么?
问题3:三层架构中,哪一层负责业务逻辑?
问题4:资源创建成功后,应返回哪个状态码?
问题5:下面哪个 HTTP 方法不满足幂等性?
全部 69 个名词(含前面课程内容)都在 GLOSSARY.md 里,带目录表格,可直接搜索。

推荐阅读(按优先级)

  1. Spring 官方:Building a RESTful Web Service —— 本课的主参考,官方权威
  2. RESTful API 设计最佳实践 —— 系统讲设计规范
  3. Baeldung:Bean Validation —— 校验注解大全
  4. 本工作区:Spring Boot 核心注解速查表 —— 本课用到的 9 个注解都在里面有表格
  5. 本工作区:HTTP 方法与状态码速查表

课后挑战(做完再往下学)

  1. Taskpriority 字段(枚举 LOW / MEDIUM / HIGH),并加 @NotNull 校验
  2. 加一个接口支持按优先级筛选:GET /api/tasks?priority=HIGH
  3. 加「标记完成」的专用接口:PATCH /api/tasks/{id}/complete
  4. (进阶)实现分页:GET /api/tasks?page=0&size=10,返回时带上总条数

卡住了就把报错贴给我,我们一起看。