自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

Spring Boot后端开发入门

  1. 01Spring Boot 概览
  2. 02选择工具与开始
  3. 03创建你的第一个 Spring Boot REST API
  4. 04为你的 Spring Boot 应用添加数据库访问
  5. 05配置并检查你的 Spring Boot 应用
  6. 06深入数据处理
  7. 07使用 Spring MVC 构建应用程序
  8. 08Project Reactor 和 Spring WebFlux
  9. 09Spring Boot 应用的测试能力
  10. 10保护你的 Spring Boot 应用
  11. 11部署你的 Spring Boot 应用
  12. 12更深入的响应式编程
正在加载课程章节内容
课程编程Spring Boot后端开发入门创建你的第一个 Spring Boot REST API

创建你的第一个 Spring Boot REST API

上一节,我们已经让 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,标题为空时顺手抛个异常。文件少,代码也能跑,看上去似乎很划算。

下面这段代码就是这种写法的缩影:

java
@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 负责定义允许进出接口的数据形状。多出的几个文件不是为了让目录显得正规,而是为了让变化各自停在合适的位置。

Controller 拆分前后:从解析、校验、业务和存储全部混在一起,变成职责清楚的 Controller、Service 与 DTO

直接用 Map<String, Object> 接收长期维护的请求体,看似省掉了 DTO,实际上也省掉了类型约束、字段说明和自动校验。拼错 priority 不会在编译期暴露,类型不对还可能拖到业务代码里才报错。临时调试接口可以这样写,正式业务边界不要把它当默认方案。


先固定任务资源的 HTTP 契约

REST 最先要想清楚的不是注解,而是“客户端在操作什么”。TaskHub 管理的是任务资源,所以集合使用复数路径 /api/tasks,单个任务使用 /api/tasks/{id}。{id} 是占位符,例如 /api/tasks/7 指编号为 7 的任务。

本节实现下面这组接口:

客户端意图方法与路径请求内容成功状态响应内容
查看任务列表GET /api/tasks可选查询参数 status200 OK任务数组
查看一个任务GET /api/tasks/{id}路径中的任务编号200 OK一个任务
创建任务POST /api/tasks创建 DTO 的 JSON201 Created新任务,并返回 Location
完整更新任务PUT /api/tasks/{id}更新 DTO 的 JSON200 OK更新后的任务
删除任务DELETE /api/tasks/{id}路径中的任务编号204 No Content没有响应体

这里的 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 是在实现设计,而不是让注解反过来替我们设计接口。

把一条 HTTP 请求拆开看

很多初学者第一次用 curl 时,会把 URL、请求头、JSON 和状态码看成一整团。把它们拆开之后,Controller 的每个参数就很好理解了。下面是一条创建任务的原始请求轮廓:

http
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,但服务器最终都保持“该任务不存在”。理解这些词的目的不是背定义,而是避免重试请求时制造意外副作用。


一次请求在 Spring MVC 里经历了什么

写 Controller 之前,先把注解背后的参与者认清楚。浏览器或 curl 发来的请求先到内嵌服务器,然后进入 Spring MVC 的前端控制器 DispatcherServlet。它不会自己执行任务业务,而是向处理器映射查询:“哪个方法能处理 POST /api/tasks?”找到目标方法后,处理器适配器负责准备方法参数、调用 Controller,再处理方法返回值。

对于本节的 JSON API,这条链路可以压缩成下面几步:

  1. DispatcherServlet 接收请求。
  2. 请求映射根据路径、HTTP 方法和媒体类型找到 TaskController 中的方法。
  3. 参数解析器从路径或查询字符串中取得简单参数。
  4. HTTP 消息转换器读取请求体,交给 JSON 映射器转换成 DTO。
  5. 校验器检查 DTO 上的约束,失败时在进入业务方法前终止请求。
  6. Controller 调用 TaskService。
  7. 返回对象再经消息转换器序列化为 JSON,写入 HTTP 响应。

Spring MVC 根据请求路径与 HTTP 方法找到 TaskController,并把创建结果作为 JSON 返回

@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 能力选择合适的转换器。

请求体 JSON 经 Jackson 按字段名与类型绑定为创建任务 DTO,返回对象再转换成 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>,就没有对象需要序列化。


用 DTO 把 JSON 边界说清楚

先确认 pom.xml 里有 Web MVC 和 Validation。上一节通过 Initializr 生成的 TaskHub 已经包含它们,这里只核对关键片段:

xml
<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 包,方便把完整链路看清楚;项目继续扩大时,再按团队约定细分子包。

text
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。枚举让任务状态只能落在三个合法值中:

java
package com.welearn.taskhub.task;
 
public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}

再创建 Task.java。本节使用不可变的 Java record 表示内存中的任务:

java
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:

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,
 
        @Size(max = 2000, message = "描述不能超过 2000 个字符")
        String description,
 
        @Min(value = 1, message = "优先级最小为 1")
        @Max(value = 5, message = "优先级最大为 5")
        int priority,
 
        LocalDate dueDate) {
}

创建请求没有 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) 约束的是“有值时不能过长”,不等于“必须有值”。

更新 DTO 表达完整替换

新建 UpdateTaskRequest.java。更新时允许客户端明确设置状态,所以它比创建 DTO 多一个 status:

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.NotNull;
import jakarta.validation.constraints.Size;
 
import java.time.LocalDate;
 
public record UpdateTaskRequest(
        @NotBlank(message = "标题不能为空")
        @Size(max = 120, message = "标题不能超过 120 个字符")
        String title,
 
        @Size(max = 2000, message = "描述不能超过 2000 个字符")
        String description,
 
        @NotNull(message = "状态不能为空")
        TaskStatus status,
 
        @Min(value = 1, message = "优先级最小为 1")
        @Max(value = 5, message = "优先级最大为 5")
        int priority,
 
        LocalDate dueDate) {
}

创建和更新 DTO 有几个重复字段,这不是必须立刻消灭的坏味道。它们代表两个独立的外部契约:创建默认状态,更新要求显式状态。为了省几行代码强行共用一个 DTO,往往会把字段改成大量可空值,再把真正的语义塞回 Service 的条件判断里。

响应 DTO 隔离内部模型

最后创建 TaskResponse.java:

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.title(),
                task.description(),
                task.status(),
                task.priority(),
                task.dueDate(),
                task.createdAt(),
                task.version());
    }
}

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 不是给实体换一个名字

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 上。无论消息放在哪里,客户端程序最好主要依赖稳定状态码、错误类型和字段名,不要靠完整中文句子做分支判断。


让 Repository 专心管理内存数据

接下来把 Map、编号生成和基本查找从 Controller 移出去。先创建 TaskRepository.java,用接口描述 Service 真正需要的存储能力:

java
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:

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 final AtomicLong sequence = new AtomicLong();
 
    @Override
    public List<Task> findAll() {
        return tasks.values().stream()
                .sorted(Comparator.comparing(Task::id))
                .toList();
    }
 
    @Override
    public Optional<Task> findById(long id) {
        return Optional.ofNullable(tasks.get(id));
    }
 
    @Override
    public Task save(Task task) {
        if (task.id() != null) {
            tasks.put(task.id(), task);
            return task;
        }
 
        long id = sequence.incrementAndGet();
        Task stored = new Task(
                id,
                task.title(),
                task.description(),
                task.status(),
                task.priority(),
                task.dueDate(),
                task.createdAt(),
                task.version());
        tasks.put(id, stored);
        return stored;
    }
 
    @Override
    public boolean deleteById(long id) {
        return tasks.remove(id) != null;
    }
}

ConcurrentHashMap 让多个请求线程访问这张表时不至于破坏 Map 自身结构,AtomicLong 保证并发生成编号时不会拿到相同的值。它们只能让这个教学仓库具备最基本的线程安全,不等于获得了数据库事务、跨进程一致性或持久化能力。

findAll() 主动按 id 排序。ConcurrentHashMap 不承诺遍历顺序,如果直接返回 values(),同样的数据可能以不同顺序出现,curl 输出和测试都会显得飘忽。稳定顺序是接口行为的一部分,不应该碰巧依赖容器内部实现。

@Repository 不是给类贴一个“仓库标签”就结束了。应用启动时,组件扫描会发现它,为它注册 Bean 定义并创建实例。这个实例由 Spring 容器管理,后面的 TaskService 不会自己执行 new InMemoryTaskRepository(),而是声明需要一个 TaskRepository,由容器完成装配。

Repository 接口隔离的究竟是什么

接口隔离的不是所有数据差异,而是 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。数据库主键生成器之所以重要,不只是省掉一个计数器,而是让共享存储在多个应用实例之间协调唯一标识。我们保留这份局限,是为了下一节能看见持久化层真正解决了什么。


把业务规则放进 Service

资源不存在时,我们希望抛出一个语义清楚的异常,而不是拿通用的 IllegalArgumentException 同时表示标题错误、编号错误和任务不存在。创建 TaskNotFoundException.java:

java
package com.welearn.taskhub.task;
 
public class TaskNotFoundException extends RuntimeException {
 
    public TaskNotFoundException(long id) {
        super("没有找到编号为 " + id + " 的任务");
    }
}

然后创建 TaskService.java:

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) {
        return repository.findAll().stream()
                .filter(task -> status == null || task.status() == status)
                .map(TaskResponse::from)
                .toList();
    }
 
    public TaskResponse get(long id) {
        return TaskResponse.from(findTask(id));
    }
 
    public TaskResponse create(CreateTaskRequest request) {
        Task task = new Task(
                null,
                request.title().trim(),
                normalizeDescription(request.description()),
                TaskStatus.TODO,
                request.priority(),
                request.dueDate(),
                Instant.now(),
                0L);
        return TaskResponse.from(repository.save(task));
    }
 
    public TaskResponse update(long id, UpdateTaskRequest request) {
        Task current = findTask(id);
        Task updated = new Task(
                current.id(),
                request.title().trim(),
                normalizeDescription(request.description()),
                request.status(),
                request.priority(),
                request.dueDate(),
                current.createdAt(),
                current.version() + 1);
        return TaskResponse.from(repository.save(updated));
    }
 
    public void delete(long id) {
        if (!repository.deleteById(id)) {
            throw new TaskNotFoundException(id);
        }
    }
 
    private Task findTask(long id) {
        return repository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));
    }
 
    private String normalizeDescription(String description) {
        return description == null ? null : description.trim();
    }
}

Service 里出现的是业务动作:列出任务、取得任务、创建、完整更新和删除。标题去掉首尾空格,新任务固定为 TODO,创建时间由服务器生成,更新保留原创建时间并推进版本号。这些规则不属于 HTTP,也不属于 Map 的存储细节,所以放在 Service 最合适。

列表的 status 可以为空。为空表示不过滤;有值时只保留对应状态。这是一个小规模内存实现,数据量大后不应该先取出全部任务再筛选。后面的数据处理课程会把筛选、关键词查询、分页和排序交给数据库执行。

为什么构造器前面没有 @Autowired

TaskService 没有写 new InMemoryTaskRepository(),TaskController 也不会写 new TaskService(...)。应用启动时,Spring 的 ApplicationContext 会先根据组件扫描找到 @Repository、@Service 和 @RestController,把它们作为 Bean 管理。创建 TaskService 时,容器看到它唯一的构造器需要 TaskRepository,便在容器中按类型查找实现,把 InMemoryTaskRepository 的 Bean 传进去。

这就是依赖注入:对象声明自己需要什么,容器负责寻找并组装依赖。单构造器场景不必额外写 @Autowired。这个省略不是“Spring 猜到了”,而是明确的构造器解析规则。

IoC 容器扫描 Repository、Service 与 RestController,创建 Bean 并按构造器类型逐步注入依赖

我们也不使用字段注入:

java
// 不建议把依赖藏在字段里
@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。


Controller 只翻译 HTTP

现在创建 TaskController.java。它接收 HTTP 输入、调用 Service,再选择合适的 HTTP 响应:

java
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 java.util.List;
 
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
 
    private final TaskService service;
 
    public TaskController(TaskService service) {
        this.service = service;
    }
 
    @GetMapping
    public List<TaskResponse> list(
            @RequestParam(name = "status", required = false) TaskStatus status) {
        return service.list(status);
    }
 
    @GetMapping("/{id}")
    public TaskResponse get(@PathVariable("id") long id) {
        return service.get(id);
    }
 
    @PostMapping
    public ResponseEntity<TaskResponse> create(
            @Valid @RequestBody CreateTaskRequest request) {
        TaskResponse created = service.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(created.id())
                .toUri();
        return ResponseEntity.created(location).body(created);
    }
 
    @PutMapping("/{id}")
    public TaskResponse update(
            @PathVariable("id") long id,
            @Valid @RequestBody UpdateTaskRequest request) {
        return service.update(id, request);
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable("id") long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

这个 Controller 没有 Map、编号生成器和业务字段清洗。每个方法都很短,因为它们只做边界翻译。比如创建方法把 JSON 绑定到 CreateTaskRequest,校验通过后交给 Service,再用 ResponseEntity.created(location) 同时设置 201 Created 与 Location 响应头。

直接返回 TaskResponse 时,Spring MVC 默认使用 200 OK;需要控制状态码或响应头时,ResponseEntity 表达完整响应。删除方法返回 ResponseEntity<Void>,204 的语义就是操作成功但没有内容,因此不要再塞一个 { "success": true } 响应体。

TaskHub REST API 常见状态码:200、201、204、400、404 与 500 分别概括不同请求结果

列表方法演示了查询参数绑定。请求 /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 要薄”经常被误解成 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 硬编码开发机地址”这条原则现在就能保留。


用 ProblemDetail 统一错误响应

现在还差错误路径。TaskService 抛出的 TaskNotFoundException 如果没人处理,会落入通用服务器错误响应;DTO 校验失败虽然默认是 400,但字段错误的结构也不一定符合 TaskHub 的契约。最糟糕的解决办法,是在五个 Controller 方法里各写一遍 try/catch。

Spring MVC 提供了集中处理机制。创建 web/ApiExceptionHandler.java:

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 {
 
    @ExceptionHandler(TaskNotFoundException.class)
    ProblemDetail handleNotFound(
            TaskNotFoundException exception,
            HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                exception.getMessage());
        problem.setTitle("任务不存在");
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail handleInvalidBody(
            MethodArgumentNotValidException exception,
            HttpServletRequest request) {
        var fields = new LinkedHashMap<String, String>();
        exception.getBindingResult().getFieldErrors()
                .forEach(error -> fields.putIfAbsent(
                        error.getField(),
                        error.getDefaultMessage()));
 
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "请求内容没有通过校验");
        problem.setTitle("参数校验失败");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("fields", fields);
        return problem;
    }
 
    @ExceptionHandler(HttpMessageNotReadableException.class)
    ProblemDetail handleUnreadableBody(
            HttpMessageNotReadableException exception,
            HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "请求体不是有效的 JSON,或字段类型与接口要求不一致");
        problem.setTitle("请求体无法读取");
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }
 
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    ProblemDetail handleTypeMismatch(
            MethodArgumentTypeMismatchException exception,
            HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "参数 " + exception.getName() + " 的值无法转换为目标类型");
        problem.setTitle("参数类型错误");
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }
}

@RestControllerAdvice 本质上是能够作用于多个 Controller 的 Advice,并带有响应体语义。应用启动时它同样会成为 Bean;请求处理过程中出现异常时,Spring MVC 的异常解析器会寻找匹配的 @ExceptionHandler 方法。这样,Controller 不需要知道错误 JSON 长什么样,Service 也不需要依赖 HTTP 状态码。

ProblemDetail 是 Spring 对标准问题详情结构的类型化支持。它的 status 决定响应状态,title 给出简短分类,detail 说明这一次具体发生了什么,instance 指向出错的请求资源。返回时消息转换器会把它写成 application/problem+json。

校验错误额外添加 fields,让前端能把“标题不能为空”显示在标题输入框旁边。这里用 putIfAbsent 保留同一字段的第一条消息,避免一个空标题同时触发多条约束后让响应变得嘈杂。

校验失败后由全局异常处理器生成 400 ProblemDetail,校验通过时才进入 Service

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 的原因。


启动 TaskHub 并走完任务生命周期

回到项目根目录,启动应用:

bash
./mvnw spring-boot:run

看到应用监听 8080 端口后,另开一个终端。下面每条命令都加了 -i,这样响应头和状态码也会显示出来,而不是只看到 JSON。

创建任务:POST 返回 201 与 Location

bash
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
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",
  "version": 0
}

可以观察到三条业务规则已经生效:标题两侧空格被 Service 去掉,状态由服务器设成 TODO,编号和创建时间也由服务器生成。Location 与响应中的 id 指向同一个资源。

再创建一条低优先级任务,便于观察列表与筛选:

bash
curl -s -X POST http://localhost:8080/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "整理任务字段",
    "description": null,
    "priority": 2,
    "dueDate": null
  }'

查询列表与单个任务

bash
curl -s http://localhost:8080/api/tasks
json
[
  {
    "id": 1,
    "title": "补上接口测试",
    "description": "固定创建任务的状态码与响应字段",
    "status": "TODO",
    "priority": 5,
    "dueDate": "2026-08-20",
    "createdAt": "2026-08-17T08:30:00Z",
    "version": 0
  },
  {
    "id": 2,
    "title": "整理任务字段",
    "description": null,
    "status": "TODO",
    "priority": 2,
    "dueDate": null,
    "createdAt": "2026-08-17T08:31:00Z",
    "version": 0
  }
]

查询参数用来筛选集合:

bash
curl -s 'http://localhost:8080/api/tasks?status=TODO'

要读取 Location 指向的一个任务,访问资源路径:

bash
curl -i http://localhost:8080/api/tasks/1
http
HTTP/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 规定的完整字段:

bash
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
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 再用它构造更新后的内部对象。

观察参数校验失败

发一个空标题和越界优先级:

bash
curl -i -X POST http://localhost:8080/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "   ",
    "description": "这条任务不会被保存",
    "priority": 9,
    "dueDate": null
  }'
http
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:

bash
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。

查询不存在的任务

bash
curl -i http://localhost:8080/api/tasks/999
http
HTTP/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 或重定向。

删除任务

bash
curl -i -X DELETE http://localhost:8080/api/tasks/1
http
HTTP/1.1 204 No Content

204 后面没有 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 包办全部”多了,但每个文件都有明确的变化原因:

  • HTTP 路径、参数绑定或状态码变化,主要看 TaskController。
  • 标题清洗、新任务默认状态等业务规则变化,主要看 TaskService。
  • JSON 输入输出字段变化,主要看三个 DTO。
  • 内存存取与编号生成变化,主要看 InMemoryTaskRepository。
  • 错误 JSON 结构变化,主要看 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,然后查询:

bash
curl -s http://localhost:8080/api/tasks
json
[]

之前创建的任务全部消失了,因为 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 会继续沿用,这条连续的项目线也会让每个新注解都有明确的落点。

上一章选择工具与开始下一章为你的 Spring Boot 应用添加数据库访问