上一节,我们已经让 TaskHub 跑了起来,也用 GET /api/hello 收到过一句“TaskHub 已启动”。那条接口很有用,它证明了 Java、Maven、Spring Boot 和内嵌 Web 服务器能够连成一条完整链路。不过它还没有处理任何业务:无论请求多少次,服务器都只会返回同一句话。
这一节,我们把问候接口升级成真正的任务 API。客户端可以创建任务、查询任务、修改任务和删除任务;请求与响应都使用 JSON;标题为空、优先级越界或任务不存在时,接口也会给出可以被程序理解的错误信息。
我们暂时不接数据库。任务先放在进程内存里,这样可以把注意力放在 HTTP、Spring MVC、DTO、依赖注入和分层上。这个选择并不意味着写一套用完就扔的代码:本节会先固定 /api/tasks 的 HTTP 契约,下一节接入数据库时,客户端看到的路径、请求体和响应体都不用跟着改变。
第一次写 REST 接口时,把所有东西都塞进 Controller 很有诱惑力。一个类里放一张 Map,收到 POST 就生成编号,收到 GET 就查 Map,标题为空时顺手抛个异常。文件少,代码也能跑,看上去似乎很划算。
下面这段代码就是这种写法的缩影:
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final Map<Long, TaskResponse> tasks = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@PostMapping
public TaskResponse create(@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("标题不能为空");
}
long id = sequence.incrementAndGet();
TaskResponse task = new TaskResponse(
id,
title.trim(),
(String) body.get("description"),
TaskStatus.TODO,
(Integer) body.get("priority"),
null,
Instant.now(),
0L);
tasks.put(id, task);
return task;
}
// 查询、更新、删除也继续堆在这里……
}它的问题不是“不够像标准答案”,而是职责已经搅在了一起。TaskController 同时理解 HTTP、解析任意结构的 Map、校验业务字段、生成编号、保存数据,还负责决定返回对象。只要需求再长一点,这个类就会迅速膨胀。
先问几个很实际的问题:如果命令行工具和将来的网页都要复用“创建任务”这条规则,难道要复制 Controller 代码吗?如果存储从 Map 换成数据库,是不是每个接口方法都要重写?如果只想测试“标题会去掉首尾空格”,为什么必须先构造一整个 HTTP 请求?如果 priority 被客户端发成字符串,强制类型转换又会在哪里失败?
这些问题正好给出了分层的理由。Controller 负责 HTTP 边界,Service 负责一次业务操作,Repository 负责数据存取,DTO 负责定义允许进出接口的数据形状。多出的几个文件不是为了让目录显得正规,而是为了让变化各自停在合适的位置。

直接用 Map<String, Object> 接收长期维护的请求体,看似省掉了 DTO,实际上也省掉了类型约束、字段说明和自动校验。拼错 priority 不会在编译期暴露,类型不对还可能拖到业务代码里才报错。临时调试接口可以这样写,正式业务边界不要把它当默认方案。
REST 最先要想清楚的不是注解,而是“客户端在操作什么”。TaskHub 管理的是任务资源,所以集合使用复数路径 /api/tasks,单个任务使用 /api/tasks/{id}。{id} 是占位符,例如 /api/tasks/7 指编号为 7 的任务。
本节实现下面这组接口:
这里的 URL 使用名词“tasks”,动作由 HTTP 方法表达,所以不需要设计 /api/createTask、/api/deleteTask 这类路径。GET 用来取得当前表示,POST 把新数据交给任务集合处理,PUT 请求用给定内容替换指定任务,DELETE 请求移除指定任务。
状态码也是接口契约的一部分。创建成功使用 201,比一律返回 200 多表达了一层信息;响应的 Location 头会告诉客户端新资源位于哪里。删除成功后没有要返回的 JSON,就使用 204。找不到任务应该是 404,而不是把 null 包在一个 200 响应里。客户端通常先看状态码决定走成功分支还是错误分支,响应体再补充细节。
PUT 在本节表示完整更新。因此 UpdateTaskRequest 会带上标题、描述、状态、优先级和截止日期。以后如果需要“只把状态改成 DONE”这种局部更新,可以另行设计 PATCH,不要让同一个字段在 PUT 中一会儿代表“不修改”,一会儿代表“改成 null”。边界越明确,前后端越少靠猜测协作。
REST 不是要求每个项目照抄同一组 URL,而是要求客户端能从统一的资源语义、HTTP 方法和状态码理解结果。我们先把契约定住,再写 Spring 注解;这样 Controller 是在实现设计,而不是让注解反过来替我们设计接口。
很多初学者第一次用 curl 时,会把 URL、请求头、JSON 和状态码看成一整团。把它们拆开之后,Controller 的每个参数就很好理解了。下面是一条创建任务的原始请求轮廓:
POST /api/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Accept: application/json
{
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"priority": 5,
"dueDate": "2026-08-20"
}第一行给出方法与目标路径。Host 指向哪台服务器,Content-Type 说明请求体是什么格式,Accept 表示客户端希望收到什么格式,空行后面才是请求体。Spring MVC 先用方法和路径寻找处理方法,再根据媒体类型选择消息转换器。路径匹配正确但 Content-Type 写成 text/plain 时,不能因为正文“看起来像 JSON”就自动按 JSON 处理,接口通常会返回不支持该媒体类型的错误。
Content-Type 与 Accept 经常被混淆。前者描述“我发给你的内容”,后者描述“我希望你回给我的内容”。本课程当前只有 JSON API,所以很多工具省略 Accept 也能工作;一旦同一路径可能返回 JSON、XML 或其他格式,内容协商就会用到它。
路径和查询参数也承担不同角色。/api/tasks/7 定位一个具体任务,/api/tasks?status=TODO 仍定位任务集合,只是要求服务端过滤表示。路径变量通常参与资源身份,查询参数通常表达筛选、分页、排序或展示选项。把状态放成 /api/tasks/TODO 并非绝对错误,但它会和 /api/tasks/{id} 争夺同一段路径,后面再加关键词、页码就越来越别扭。
HTTP 还区分“安全”与“幂等”等语义。GET 应该只读,刷新列表不应偷偷把任务改成已读;PUT 和 DELETE 的目标效果应当能够重复表达,而 POST 创建通常会在每次调用时生成新任务。幂等不要求每次响应完全相同,例如第二次删除可能得到 404,但服务器最终都保持“该任务不存在”。理解这些词的目的不是背定义,而是避免重试请求时制造意外副作用。
写 Controller 之前,先把注解背后的参与者认清楚。浏览器或 curl 发来的请求先到内嵌服务器,然后进入 Spring MVC 的前端控制器 DispatcherServlet。它不会自己执行任务业务,而是向处理器映射查询:“哪个方法能处理 POST /api/tasks?”找到目标方法后,处理器适配器负责准备方法参数、调用 Controller,再处理方法返回值。
对于本节的 JSON API,这条链路可以压缩成下面几步:
DispatcherServlet 接收请求。TaskController 中的方法。TaskService。
@RestController 是 @Controller 与 @ResponseBody 语义的组合。前者让组件扫描把这个类识别为 MVC Controller,后者让方法返回值写入响应体,而不是被当作视图名称。换句话说,返回一个 TaskResponse 时,Spring MVC 会把它交给消息转换器;在本课程的 Spring Boot 4.1.0 项目里,默认 JSON 映射由 Jackson 3 完成。
这也解释了为什么 Java 方法返回对象,客户端却收到 JSON。不是 TaskResponse 自己会变成 JSON,也不是 @RestController 在编译期改写了类。真正执行转换的是运行时注册在 MVC 中的 HttpMessageConverter,它根据 Content-Type、Accept、目标 Java 类型和类路径中的 JSON 能力选择合适的转换器。

路径映射注解则是在描述匹配条件:
@RequestMapping("/api/tasks") 提供公共前缀。@GetMapping、@PostMapping、@PutMapping 和 @DeleteMapping 是限定 HTTP 方法的组合注解。@PathVariable("id") 把 /api/tasks/7 中的 7 转成 Java 的 long。@RequestParam(name = "status", required = false) 读取 ?status=TODO,并尝试转成 TaskStatus 枚举。@RequestBody 要求从请求体读取内容,而不是从 URL 查询参数读取。@Valid 要求在调用方法前检查 DTO 内的 Bean Validation 约束。如果路径写成 /api/tasks/abc,框架无法把 abc 转成 long;如果查询参数写成 status=doing,它也无法转换成只允许 TODO、IN_PROGRESS、DONE 的枚举。这些都属于参数绑定失败,甚至还没轮到 Service 执行业务逻辑。理解这一点后,遇到错误时就能先判断问题发生在“HTTP 到 Java 的转换阶段”,还是发生在“业务规则执行阶段”。
网上仍能搜到大量使用 XML <bean>、javax.validation 或 Spring Boot 2/3 依赖名称的文章。本课程使用组件扫描、注解配置和 jakarta.validation,Web 起步依赖是 Spring Boot 4 拆分后的 spring-boot-starter-webmvc。看到旧资料时先核对版本和包名,不要为了消除一个红色 import,把整套项目倒退到旧写法。
Spring MVC 并不是每来一个请求就从所有类开始反射搜索。应用上下文启动时,框架会检查已经注册的 Controller Bean,读取类和方法上的映射注解,把条件与可调用方法整理成处理器映射。运行期请求到来后,框架是在已建立的映射表中匹配,而不是临时猜哪个方法名字最像。
这也说明方法名本身不决定 URL。你可以把 Java 方法命名为 create、add 或 banana,真正参与 HTTP 匹配的是 @PostMapping 等元数据。当然,方法名仍应表达意图,因为它服务于阅读代码和堆栈排查。反过来,仅仅把方法命名成 deleteTask,没有 @DeleteMapping,也不会自动成为 DELETE 接口。
如果两个方法声明了完全重叠的映射条件,框架无法可靠决定该调用谁,通常会在启动或请求匹配时报告歧义。这个报错与 Service、数据库都无关,应该回到 Controller 比较路径、方法、consumes 和 produces 条件。把问题定位在正确阶段,比盯着整个调用链逐行打断点有效得多。
参数准备也不是 Controller 自己完成的。路径变量、查询参数、请求头和请求体分别由不同的参数解析器处理。简单字符串到数字或枚举通常走类型转换系统,JSON 请求体走消息转换器。@RequestBody 前少了注解时,Spring 不会按你的主观意图自动把整段 JSON 填进 DTO;注解是在明确告诉框架“这个参数的来源是请求体”。
返回阶段方向相反。Controller 返回 TaskResponse 后,处理器适配器查看返回值类型和 REST 响应体语义,选择能写 JSON 的转换器。Jackson 读取 record 组件,产生属性名和值。若返回 ResponseEntity<TaskResponse>,Spring 先取出其中的状态与响应头,再转换 body;若返回 ResponseEntity<Void>,就没有对象需要序列化。
先确认 pom.xml 里有 Web MVC 和 Validation。上一节通过 Initializr 生成的 TaskHub 已经包含它们,这里只核对关键片段:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>这一节完成后,任务相关代码都在主包 com.welearn.taskhub 下面。先保持同一个 task 包,方便把完整链路看清楚;项目继续扩大时,再按团队约定细分子包。
src/main/java/com/welearn/taskhub
├── TaskHubApplication.java
├── task
│ ├── Task.java
│ ├── TaskStatus.java
│ ├── CreateTaskRequest.java
│ ├── UpdateTaskRequest.java
│ ├── TaskResponse.java
│ ├── TaskRepository.java
│ ├── InMemoryTaskRepository.java
│ ├── TaskService.java
│ ├── TaskController.java
│ └── TaskNotFoundException.java
└── web
└── ApiExceptionHandler.java先创建 TaskStatus.java。枚举让任务状态只能落在三个合法值中:
package com.welearn.taskhub.task;
public enum TaskStatus {
TODO,
IN_PROGRESS,
DONE
}再创建 Task.java。本节使用不可变的 Java record 表示内存中的任务:
package com.welearn.taskhub.task;
import java.time.Instant;
import java.time.LocalDate;
public record Task(
Long id,
String title,
String description,
TaskStatus status,
int priority,
LocalDate dueDate,
Instant createdAt,
long version) {
}id、createdAt 和 version 由服务端管理。新任务的状态固定从 TODO 开始。priority 使用 1 到 5,数字越大表示越优先;dueDate 允许为空。version 现在从 0 开始,每次更新加 1,后面接入 JPA 时会继续用它识别覆盖写问题。
这里刻意没有添加任何 JPA 注解。Task 目前只是领域数据,Repository 也只是内存实现。下一节讨论数据库时,我们再让它成为实体,不把还没讲过的持久化概念提前塞进来。
新建 CreateTaskRequest.java:
package com.welearn.taskhub.task;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record CreateTaskRequest(
@NotBlank(message = "标题不能为空")
@Size(max = 120, message = "标题不能超过 120 个字符")
String title,
@
创建请求没有 id、status、createdAt 和 version。客户端不能伪造服务端编号和创建时间,新任务状态也由业务规则统一设为 TODO。如果直接拿内部 Task 接请求,这些字段就会混进接口输入,调用方会以为它们可以控制。
@NotBlank 不只拒绝 null 和空字符串,也拒绝只有空白字符的标题;@Size 约束字符数量;@Min 与 @Max 把优先级限制在 1 到 5。注解本身只是元数据,真正读取它们的是 Bean Validation 实现。Controller 参数上的 @Valid 触发校验,失败时 Spring MVC 抛出 MethodArgumentNotValidException,Controller 方法不会继续执行。
description 和 dueDate 可以为 null。大多数校验约束不会把 null 自动视为非法;需要必填时应该明确加 @NotNull。这是一个常见误区:@Size(max = 2000) 约束的是“有值时不能过长”,不等于“必须有值”。
新建 UpdateTaskRequest.java。更新时允许客户端明确设置状态,所以它比创建 DTO 多一个 status:
package com.welearn.taskhub.task;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record UpdateTaskRequest(
@NotBlank(message = "标题不能为空")
@Size(max = 120, message = "标题不能超过 120 个字符")
创建和更新 DTO 有几个重复字段,这不是必须立刻消灭的坏味道。它们代表两个独立的外部契约:创建默认状态,更新要求显式状态。为了省几行代码强行共用一个 DTO,往往会把字段改成大量可空值,再把真正的语义塞回 Service 的条件判断里。
最后创建 TaskResponse.java:
package com.welearn.taskhub.task;
import java.time.Instant;
import java.time.LocalDate;
public record TaskResponse(
Long id,
String title,
String description,
TaskStatus status,
int priority,
LocalDate dueDate,
Instant createdAt,
long version) {
public static TaskResponse from(Task task) {
return new TaskResponse(
task.id(),
Task 与 TaskResponse 目前字段很像,仍然值得分开。今天的内部对象是 record,下一节会变成受 JPA 管理的实体;以后还可能增加内部备注、归档标记或关联对象。响应 DTO 是 Web 层的承诺,只暴露客户端确实需要的字段,不应该因为数据库映射变化而被动改变。
Jackson 会按 record 组件名完成 JSON 与 DTO 的映射。客户端发来的 dueDate 使用 yyyy-MM-dd,例如 2026-08-20;createdAt 是带时区含义的 ISO-8601 时间,例如 2026-08-17T08:30:00Z。日期格式不合法时,失败发生在 JSON 反序列化阶段,而不是 @Size 这类 Bean Validation 阶段。
DTO 的价值在于划出进出系统的边界。创建请求回答“客户端创建任务时可以提交什么”,更新请求回答“完整更新必须提交什么”,响应回答“服务端承诺返回什么”。它们都围绕一次数据传输设计,不需要具备保存能力,也不应该知道 Repository。
如果以后给 Task 增加内部字段 archivedAt,不代表客户端立刻应该看见它;如果数据库关系变成 Task 关联一个 Project 实体,也不应该直接把整个 Project 对象图序列化出去。实体直接暴露时,还容易出现懒加载对象序列化、双向关联循环、内部字段泄露和客户端反向覆盖服务端字段。现在提前使用 DTO,下一节把 Task 改成 JPA 实体时就不会临时处理这些问题。
record 很适合表达这类只承载数据的不可变结构。构造参数就是组件清单,访问器是 title() 而不是 getTitle(),Jackson 与 Bean Validation 都能识别组件上的元数据。它并不意味着所有业务对象都必须改成 record;后面的 JPA 实体需要无参构造器和受持久化上下文管理的状态,会使用普通类。DTO 和实体根据各自运行机制选择形态,不必追求表面统一。
Bean Validation 擅长检查单个输入对象的结构约束:必填、长度、数值范围、日期范围以及符合某种格式。它在进入业务方法前就能拒绝明显无效的数据,让 Service 不必重复判断标题是不是空字符串。
但“这个任务是否存在”“当前状态能不能从 DONE 退回 TODO”“当前用户是否有权修改这个任务”都需要查询上下文或理解业务流程,不适合硬塞进简单字段注解。这些规则应该由 Service 执行,必要时抛出有语义的业务异常。校验注解与 Service 不是二选一:前者守住输入形状,后者守住业务状态。
校验消息直接写中文适合当前教学项目,能让请求响应马上可读。更大的系统可能把消息放进资源文件,根据请求语言选择文本;约束本身仍然留在 DTO 上。无论消息放在哪里,客户端程序最好主要依赖稳定状态码、错误类型和字段名,不要靠完整中文句子做分支判断。
接下来把 Map、编号生成和基本查找从 Controller 移出去。先创建 TaskRepository.java,用接口描述 Service 真正需要的存储能力:
package com.welearn.taskhub.task;
import java.util.List;
import java.util.Optional;
public interface TaskRepository {
List<Task> findAll();
Optional<Task> findById(long id);
Task save(Task task);
boolean deleteById(long id);
}接口没有说数据必须来自 Map、H2 还是 PostgreSQL。Service 只依赖这组行为。下一节接数据库时,变化会集中在 Repository 一侧,HTTP 层不需要知道底下的存储已经换了。
再创建 InMemoryTaskRepository.java:
package com.welearn.taskhub.task;
import org.springframework.stereotype.Repository;
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;
@Repository
public class InMemoryTaskRepository implements TaskRepository {
private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
private
ConcurrentHashMap 让多个请求线程访问这张表时不至于破坏 Map 自身结构,AtomicLong 保证并发生成编号时不会拿到相同的值。它们只能让这个教学仓库具备最基本的线程安全,不等于获得了数据库事务、跨进程一致性或持久化能力。
findAll() 主动按 id 排序。ConcurrentHashMap 不承诺遍历顺序,如果直接返回 values(),同样的数据可能以不同顺序出现,curl 输出和测试都会显得飘忽。稳定顺序是接口行为的一部分,不应该碰巧依赖容器内部实现。
@Repository 不是给类贴一个“仓库标签”就结束了。应用启动时,组件扫描会发现它,为它注册 Bean 定义并创建实例。这个实例由 Spring 容器管理,后面的 TaskService 不会自己执行 new InMemoryTaskRepository(),而是声明需要一个 TaskRepository,由容器完成装配。
接口隔离的不是所有数据差异,而是 Service 当前使用的存储操作。findAll、findById、save 和 deleteById 足够支撑本节 CRUD,所以接口先到这里。不要因为数据库以后可能支持上百种查询,就提前给内存接口塞满猜测出来的方法。需求出现时再扩展,调用者和实现者可以一起用测试固定语义。
findById 返回 Optional<Task>,明确表达“这个编号可能没有任务”。如果直接返回 Task,调用方必须靠约定猜 null 是否可能出现;如果 Repository 在找不到时直接抛 HTTP 404,它又会越过 Service 依赖 Web 语义。现在由 Repository 描述可能为空,Service 决定“查询业务要求任务必须存在”,再抛 TaskNotFoundException,职责链是清楚的。
内存实现保存的 Task 是不可变 record。更新时 Service 构造一个新 Task,再用相同 id 覆盖 Map 中的旧值。这样,请求线程拿到一个 Task 后,它的字段不会被另一个线程改到一半。ConcurrentHashMap 保证单次 put、get、remove 等操作的并发安全,但跨多个操作的业务原子性仍未解决。例如“先检查存在再更新”之间可能发生其他修改,这正是数据库事务和乐观锁以后要处理的问题。
AtomicLong 也只在当前进程里递增。重启后它回到 0;两个 TaskHub 进程各自都会生成 id 1。数据库主键生成器之所以重要,不只是省掉一个计数器,而是让共享存储在多个应用实例之间协调唯一标识。我们保留这份局限,是为了下一节能看见持久化层真正解决了什么。
资源不存在时,我们希望抛出一个语义清楚的异常,而不是拿通用的 IllegalArgumentException 同时表示标题错误、编号错误和任务不存在。创建 TaskNotFoundException.java:
package com.welearn.taskhub.task;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(long id) {
super("没有找到编号为 " + id + " 的任务");
}
}然后创建 TaskService.java:
package com.welearn.taskhub.task;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public List<TaskResponse> list(TaskStatus status) {
Service 里出现的是业务动作:列出任务、取得任务、创建、完整更新和删除。标题去掉首尾空格,新任务固定为 TODO,创建时间由服务器生成,更新保留原创建时间并推进版本号。这些规则不属于 HTTP,也不属于 Map 的存储细节,所以放在 Service 最合适。
列表的 status 可以为空。为空表示不过滤;有值时只保留对应状态。这是一个小规模内存实现,数据量大后不应该先取出全部任务再筛选。后面的数据处理课程会把筛选、关键词查询、分页和排序交给数据库执行。
@AutowiredTaskService 没有写 new InMemoryTaskRepository(),TaskController 也不会写 new TaskService(...)。应用启动时,Spring 的 ApplicationContext 会先根据组件扫描找到 @Repository、@Service 和 @RestController,把它们作为 Bean 管理。创建 TaskService 时,容器看到它唯一的构造器需要 TaskRepository,便在容器中按类型查找实现,把 InMemoryTaskRepository 的 Bean 传进去。
这就是依赖注入:对象声明自己需要什么,容器负责寻找并组装依赖。单构造器场景不必额外写 @Autowired。这个省略不是“Spring 猜到了”,而是明确的构造器解析规则。

我们也不使用字段注入:
// 不建议把依赖藏在字段里
@Autowired
private TaskRepository repository;字段注入会让 TaskService 在执行完普通构造器后仍处于依赖未就绪状态,离开 Spring 容器也不容易直接创建。构造器注入则把依赖写进类型的创建条件,字段还能声明为 final。做普通单元测试时,可以直接 new TaskService(fakeRepository),无需启动整个 Spring 上下文。这正是 IoC 与分层换来的实际收益,不只是少写一行 new。
如果以后同时出现两个 TaskRepository 实现,按类型注入会产生歧义,应用会在启动阶段明确报错。届时可以用 @Primary 选默认实现,或用限定符指出目标,而不是让容器随机选一个。
把 IoC 容器想成仓库能帮助入门,但最后要回到真实术语。ApplicationContext 保存的是 Bean 定义和已经创建的 Bean,并协调实例化、依赖解析、初始化回调与销毁。组件扫描发现 InMemoryTaskRepository、TaskService、TaskController 和 ApiExceptionHandler 后,会先建立这些类型的定义;创建某个 Bean 时,再解析它的构造器参数。
以 TaskController 为例,它唯一的构造器需要 TaskService。容器按类型找到 Service Bean;创建 Service 又需要 TaskRepository;容器继续找到 InMemoryTaskRepository。这形成了一棵依赖关系。依赖满足后,Controller 才是一个可用对象,随后 MVC 基础设施读取它的映射方法。
注入失败通常也能从这棵关系定位。完全没有 TaskRepository Bean,错误会说明没有候选者;同时有两个又没指定优先级,错误会说明候选者不唯一;类放到主包扫描范围外,容器根本不会建立它的 Bean 定义。遇到“required a bean that could not be found”时,先检查组件是否被扫描、类型是否一致、实现数量是否明确,而不是随手在更多字段上补 @Autowired。
Spring 管理的对象常被称为 Bean。并非 Java 中所有 new 出来的对象都是 Bean:Controller 方法里由 Jackson 创建的请求 DTO,不需要长期放在容器里;Service 内构造的 Task 也只是领域数据。通常只有需要生命周期管理、依赖装配或框架能力的协作组件才交给容器。把每个数据对象都标成 @Component,反而会混淆组件与业务数据的边界。
默认情况下,这些组件 Bean 是单例作用域,即同一个应用上下文中共享一个实例。这也是为什么 Repository 里的 Map 能跨多次请求保留数据。单例不是“全世界只有一个对象”,也不是跨进程共享;重启应用或启动另一个 TaskHub 实例,会得到新的上下文、新的 Bean 和新的 Map。
构造器注入还让循环依赖更早暴露。如果 Controller 依赖 Service,Service 又反过来依赖 Controller,说明职责方向很可能已经错了。不要用延迟注入或字段注入把这个结构问题藏起来。正常方向应是 Web 层依赖业务层,业务层依赖存储抽象,底层不回头调用上层 Controller。
现在创建 TaskController.java。它接收 HTTP 输入、调用 Service,再选择合适的 HTTP 响应:
package com.welearn.taskhub.task;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import
这个 Controller 没有 Map、编号生成器和业务字段清洗。每个方法都很短,因为它们只做边界翻译。比如创建方法把 JSON 绑定到 CreateTaskRequest,校验通过后交给 Service,再用 ResponseEntity.created(location) 同时设置 201 Created 与 Location 响应头。
直接返回 TaskResponse 时,Spring MVC 默认使用 200 OK;需要控制状态码或响应头时,ResponseEntity 表达完整响应。删除方法返回 ResponseEntity<Void>,204 的语义就是操作成功但没有内容,因此不要再塞一个 { "success": true } 响应体。

列表方法演示了查询参数绑定。请求 /api/tasks 时 status 为 null,请求 /api/tasks?status=TODO 时得到 TaskStatus.TODO。路径变量用于定位具体资源,查询参数用于筛选同一个集合,两者不要混成 /api/tasks/TODO 和 /api/tasks/id/7 这种难以扩展的路径层级。
@Valid 只放在请求 DTO 前面,不需要为了这个场景给整个 Controller 加类级 @Validated。当前 Spring MVC 能直接处理请求体对象校验;类级 @Validated 会切换到方法校验的代理路径,只有确实需要服务方法或参数级约束时再引入,别为了“注解看起来更全”重复添加。
“Controller 要薄”经常被误解成 Controller 只能写一行。它仍然要处理 HTTP 特有的决定:从哪里取参数、允许哪些方法、成功用哪个状态码、创建资源的 Location 怎样构造。只要逻辑离开 HTTP 后仍有业务意义,就应考虑放进 Service。
例如,标题 trim 不应该放在 Controller,因为将来的 Thymeleaf 页面、批量导入或消息消费者创建任务时也要遵守;创建成功返回 201 不应该放在 Service,因为 Service 不需要知道调用方是 HTTP 客户端还是普通 Java 代码。这个判断比机械限制 Controller 行数更有用。
参数类型也应尽量具体。long id 让框架在进入方法前完成数字转换,TaskStatus status 让非法枚举尽早失败,CreateTaskRequest 让 JSON 字段进入一个清楚的结构。若全部接成 String 和 Map,再在方法里手工转换,相当于放弃 Spring MVC 已经提供的参数解析、类型转换和校验链。
创建 Location 时使用当前请求作为基础,不把主机名写死成 localhost。应用将来部署到不同域名、端口或反向代理之后,响应仍应指向客户端能理解的资源地址。更复杂的代理场景还需要正确处理转发头,但“不要在 Controller 硬编码开发机地址”这条原则现在就能保留。
现在还差错误路径。TaskService 抛出的 TaskNotFoundException 如果没人处理,会落入通用服务器错误响应;DTO 校验失败虽然默认是 400,但字段错误的结构也不一定符合 TaskHub 的契约。最糟糕的解决办法,是在五个 Controller 方法里各写一遍 try/catch。
Spring MVC 提供了集中处理机制。创建 web/ApiExceptionHandler.java:
package com.welearn.taskhub.web;
import com.welearn.taskhub.task.TaskNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.net.URI;
import java.util.LinkedHashMap;
@RestControllerAdvice
public class ApiExceptionHandler {
@RestControllerAdvice 本质上是能够作用于多个 Controller 的 Advice,并带有响应体语义。应用启动时它同样会成为 Bean;请求处理过程中出现异常时,Spring MVC 的异常解析器会寻找匹配的 @ExceptionHandler 方法。这样,Controller 不需要知道错误 JSON 长什么样,Service 也不需要依赖 HTTP 状态码。
ProblemDetail 是 Spring 对标准问题详情结构的类型化支持。它的 status 决定响应状态,title 给出简短分类,detail 说明这一次具体发生了什么,instance 指向出错的请求资源。返回时消息转换器会把它写成 application/problem+json。
校验错误额外添加 fields,让前端能把“标题不能为空”显示在标题输入框旁边。这里用 putIfAbsent 保留同一字段的第一条消息,避免一个空标题同时触发多条约束后让响应变得嘈杂。

JSON 读不出来和 DTO 校验失败是两类问题。{"priority":"高"} 无法把字符串转换成整数,会触发 HttpMessageNotReadableException;{"priority":9} 能成为 Java 对象,但违反 @Max(5),会触发 MethodArgumentNotValidException。把这两个阶段分开,排错时会清楚很多。
没有添加一个兜底的 @ExceptionHandler(Exception.class) 把所有异常都改成自定义消息。未知异常确实应该返回 500,但同时要保留服务器端完整日志供排查,不能把数据库连接错误、编程错误和可预期业务错误都揉成“参数不合法”。
错误响应不是把所有异常翻译成中文就结束了。先区分是谁能修复问题。请求 JSON 写错、字段越界和路径参数类型错误通常由客户端修复,因此返回 400;资源编号格式正确但没有对应任务,返回 404;服务器代码出现空指针或依赖故障,客户端重复同一个请求通常也修不好,应该按 500 处理并在服务端记录上下文。
400 与 404 也不要混用。优先级为 9 是对任务输入的错误理解,属于 400;编号 999 的格式完全合法,只是当前集合中不存在,属于 404。若把二者都抛成 IllegalArgumentException 再统一返回 400,客户端就无法判断是应该修改字段还是刷新已经被删除的任务。
异常会沿调用栈向上传播。Repository 返回空 Optional,Service 将它转成 TaskNotFoundException,Controller 没有捕获,异常继续回到 MVC;异常解析器找到 Advice 中类型最匹配的方法,生成 ProblemDetail;消息转换器最后写 JSON。集中处理没有“吞掉错误”,只是把预期异常放在统一边界翻译。
ProblemDetail 的公共字段让不同接口拥有一致骨架,扩展属性则承载 TaskHub 特有信息。校验失败添加 fields 很合理,但不要随意把异常类名、SQL、服务器文件路径或堆栈放进响应。那些信息对攻击者可能有用,对普通客户端却没有修复价值。详细诊断留在受控日志里,对外只给能指导请求修正的内容。
本节 Advice 处理了四种明确情况。随着项目增加认证、并发冲突和数据库约束,错误类型会继续扩展。扩展时先为业务语义设计稳定分类,再写异常映射;不要让每个新异常都复制一份几乎相同的 JSON Map。这也是使用 ProblemDetail 类型而不是散落 Map 的原因。
回到项目根目录,启动应用:
./mvnw spring-boot:run看到应用监听 8080 端口后,另开一个终端。下面每条命令都加了 -i,这样响应头和状态码也会显示出来,而不是只看到 JSON。
curl -i -X POST http://localhost:8080/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": " 补上接口测试 ",
"description": "固定创建任务的状态码与响应字段",
"priority": 5,
"dueDate": "2026-08-20"
}'响应的关键部分如下,createdAt 会以你运行时的时间为准:
HTTP/1.1 201 Created
Location: http://localhost:8080/api/tasks/1
Content-Type: application/json
{
"id": 1,
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"status": "TODO",
"priority": 5,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:30:00Z",
可以观察到三条业务规则已经生效:标题两侧空格被 Service 去掉,状态由服务器设成 TODO,编号和创建时间也由服务器生成。Location 与响应中的 id 指向同一个资源。
再创建一条低优先级任务,便于观察列表与筛选:
curl -s -X POST http://localhost:8080/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": "整理任务字段",
"description": null,
"priority": 2,
"dueDate": null
}'curl -s http://localhost:8080/api/tasks[
{
"id": 1,
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"status": "TODO",
"priority": 5,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:30:00Z",
"version": 0
},
{
"id": 2,
查询参数用来筛选集合:
curl -s 'http://localhost:8080/api/tasks?status=TODO'要读取 Location 指向的一个任务,访问资源路径:
curl -i http://localhost:8080/api/tasks/1HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1,
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"status": "TODO",
"priority": 5,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:30:00Z",
"version": 0
把第一个任务改成进行中,同时补充新的描述。PUT 请求要提供更新 DTO 规定的完整字段:
curl -i -X PUT http://localhost:8080/api/tasks/1 \
-H 'Content-Type: application/json' \
-d '{
"title": "补上接口测试",
"description": "覆盖创建、查询、更新和删除",
"status": "IN_PROGRESS",
"priority": 5,
"dueDate": "2026-08-20"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1,
"title": "补上接口测试",
"description": "覆盖创建、查询、更新和删除",
"status": "IN_PROGRESS",
"priority": 5,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:30:00Z",
"version": 1
id 与 createdAt 保持不变,version 从 0 变成 1。客户端发来的状态由 Jackson 转成 TaskStatus.IN_PROGRESS,Service 再用它构造更新后的内部对象。
发一个空标题和越界优先级:
curl -i -X POST http://localhost:8080/api/tasks \
-H 'Content-Type: application/json' \
-d '{
"title": " ",
"description": "这条任务不会被保存",
"priority": 9,
"dueDate": null
}'HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "参数校验失败",
"status": 400,
"detail": "请求内容没有通过校验",
"instance": "/api/tasks",
"fields": {
"title": "标题不能为空",
"priority": "优先级最大为 5"
}
这次请求没有进入 TaskService.create(),所以再次查询列表不会看到一条半成品任务。校验发生在参数绑定完成之后、Controller 方法调用之前。
如果 PUT 时把状态写成小写 doing:
curl -i -X PUT http://localhost:8080/api/tasks/1 \
-H 'Content-Type: application/json' \
-d '{
"title": "补上接口测试",
"description": "状态值故意写错",
"status": "doing",
"priority": 5,
"dueDate": "2026-08-20"
}'响应会是 400,标题为“请求体无法读取”。原因不是 @NotNull,而是 JSON 映射器找不到名为 doing 的枚举常量。接口只接受 TODO、IN_PROGRESS 和 DONE。
curl -i http://localhost:8080/api/tasks/999HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "任务不存在",
"status": 404,
"detail": "没有找到编号为 999 的任务",
"instance": "/api/tasks/999"
}Service 只表达“任务不存在”,Advice 才把这个业务异常翻译成 HTTP 404。将来同一个 Service 被网页 Controller 调用时,业务层不需要因为展示方式不同而改成返回 HTML 或重定向。
curl -i -X DELETE http://localhost:8080/api/tasks/1HTTP/1.1 204 No Content204 后面没有 JSON,这是预期行为。再次查询 /api/tasks/1 会得到 404。重复 DELETE 也会得到 404,因为第二次执行时资源已经不存在;最终状态仍然是“编号 1 的任务不在仓库中”。
手工调接口时,不要只说“请求失败了”。先看状态码,再看响应头和 ProblemDetail,通常能迅速缩小范围。
404 有两种常见外观。若返回本节的“任务不存在”,说明请求已经匹配 Controller,并执行到了 Service;若路径拼成 /api/task/1,它可能根本没有匹配到处理方法。两者状态都可能是 404,但 title、detail、instance 和服务器日志不同。
405 Method Not Allowed 表示路径可能存在,但 HTTP 方法不被接受。例如对 /api/tasks/1 发送 POST,而我们只为这个具体资源声明 GET、PUT 和 DELETE。此时不要去查数据库,也不要在 Service 加方法;先核对接口表。
415 Unsupported Media Type 往往表示 POST/PUT 没有发送 Content-Type: application/json,或声明的媒体类型没有可用转换器。400 则需要继续看标题:请求体无法读取,说明 JSON 语法或字段类型有问题;参数校验失败,说明 JSON 已经成功变成 DTO,但约束不通过;参数类型错误,说明路径或查询参数转换失败。
500 表示服务端没有按预期处理请求。不要为了让演示“看起来都是友好 JSON”就把它改成 200。先看应用控制台中的异常链,最靠上的包装异常不一定是根因,通常沿 Caused by 找到最初失败位置。后面接入数据库后,连接失败和 SQL 约束问题尤其需要这样排查。
还有一个容易忽略的工具细节:curl -s 会隐藏进度条,但也让初学者容易只盯响应体;需要排错时改用 -i 看响应头,必要时用 -v 看请求实际发出了什么。命令行里“我写了这个头”和网络上“工具确实发了这个头”并不总是一回事。
现在 TaskHub 的文件确实比“一个 Controller 包办全部”多了,但每个文件都有明确的变化原因:
TaskController。TaskService。InMemoryTaskRepository。ApiExceptionHandler。这让调试也有了顺序。请求根本匹配不到方法,检查路径与 Mapping;JSON 无法读取,检查消息转换和字段类型;字段错误,检查 DTO 约束;任务找不到,顺着 Service 到 Repository;响应状态或错误结构不对,检查 Controller 与 Advice。注解多并不可怕,可怕的是不知道每个注解把工作交给了谁。
分层也让测试更轻。想验证标题会 trim,可以给 TaskService 传一个内存版或假的 TaskRepository,直接调用 create();不需要打开端口。想验证 POST 返回 201,则只测试 Web 层与 Controller。后面的测试课程会把这两类测试分别实现,现在先记住:依赖注入不是为了少写 new,它让我们能够替换依赖并只验证当前关心的一层。
再看一次“更换数据库”这个变化。如果一开始所有逻辑都在 Controller,更换存储会碰到 Map 字段、编号生成、查询方式、异常分支和每个 CRUD 方法;现在 Controller 只依赖 Service,DTO 也不包含存储细节。下一节主要改 Task 的持久化映射、Repository 实现与事务边界,调用方仍按原来的 JSON 契约工作。
同样,如果前端要求校验失败时把字段错误显示在表单旁,只需要让 Advice 保持统一 fields 结构;Service 不必返回 HTTP 对象。如果业务要求任务完成后不能再次修改标题,规则放进 Service,命令行 API 和后面的网页会一起生效。职责边界并不消灭变化,它让一次变化不必穿透整个项目。
文件多仍然有成本:需要命名、导航和理解依赖方向。所以我们没有在本节再引入十几个“Manager”“Facade”或抽象父类。分层要由真实变化驱动。TaskHub 现在确实存在 HTTP、业务和存储三个边界,Controller、Service、Repository 与 DTO 正好对应这些边界;更多层等出现真实问题再加。
到这里,TaskHub 已经拥有一条完整的 REST 链路:HTTP 请求由 DispatcherServlet 分派,JSON 转成经过校验的 DTO,Controller 调用注入的 Service,Service 通过 Repository 读写任务,返回对象再序列化为 JSON;可预期错误则由 Advice 统一变成 ProblemDetail。
先停掉应用,再重新执行 ./mvnw spring-boot:run,然后查询:
curl -s http://localhost:8080/api/tasks[]之前创建的任务全部消失了,因为 ConcurrentHashMap 只存在于当前 Java 进程的堆内存里。进程结束,Map 就不存在。即使进程不重启,部署两个 TaskHub 实例也会各有一张 Map:请求打到实例 A 创建的任务,下一次打到实例 B 可能完全查不到。
内存仓库也没有关系型数据库提供的事务、约束、查询优化和持久化保障。ConcurrentHashMap 解决的是单进程内数据结构的并发访问,不应该被包装成“轻量数据库”。
还有一个不容易在单人练习里立刻看到的问题:一次业务操作可能涉及多次写入。假设以后创建任务时还要写操作日志,Map 先保存任务、日志保存随后失败,系统就留下了一半结果。内存代码可以继续加锁和补偿,但很快会重新发明一套不完整的事务机制。关系型数据库能够把相关写入放进同一个事务,要么一起提交,要么一起回滚。
查询能力也会成为瓶颈。现在按状态筛选必须先取出整个 Map 再遍历,任务多时既浪费计算,也无法借助索引。数据库可以在存储侧完成条件查询、排序和分页,只把需要的一页传回应用。后续课程会逐步加入这些能力,而不是在本节的内存仓库里造一个微型查询引擎。
好消息是,我们已经提前把变化隔开了。TaskController 只认识 TaskService,请求与响应使用稳定 DTO,错误仍然使用 ProblemDetail。下一节会把 Task 改造成 JPA 实体,让 TaskRepository 由 Spring Data 生成数据库实现,并用事务管理一次业务操作。届时我们继续发送同样的 POST、GET、PUT 和 DELETE 请求,变化发生在存储一侧,而不是让客户端陪着后端重写。
进入下一节之前,可以把本节的边界再核对一遍:关闭进程后数据丢失是已知限制,不是 REST 契约失败;创建仍返回 201 和 Location,删除仍返回 204,参数错误与资源不存在仍分别返回 400 和 404;客户端只认识 DTO,不会把内存 Map 当成接口的一部分。只要这些外部行为保持不变,我们就能放心重构持久化实现。
下一节第一次看到 @Entity、@Id、JpaRepository 和 @Transactional 时,也不用把它们当成另一套互不相关的知识。它们接手的是本节 Repository 后面的工作:对象怎样映射成表、接口怎样获得运行时实现、一次业务操作怎样划定提交与回滚边界。Controller、Service 与 DTO 会继续沿用,这条连续的项目线也会让每个新注解都有明确的落点。