在真正踏入后端开发的世界之前,很多人对“REST API”这个词只是模糊的印象,仿佛它代表了一整套高深莫测的协议和模式。事实上,当你已经用Spring Boot成功跑起来一个“Hello, Spring Boot!”的示例之后,再向前迈出半步,就已经站在了REST API的大门口。
这节课会在你已经完成之前的环境准备和简单控制器的基础上,带着你一步步把那个略显单薄的示例,打磨成一个真正有血有肉的REST API服务。我们会从设计一个具体的小场景开始,让接口不再是抽象的概念,而是能够承载真实业务需求的入口。
在上一节课中,我们通过HelloController向浏览器返回了一句固定的字符串。这种形式更多是对框架环境是否配置正确的一种验证,而不是一个真正意义上的服务。当我们谈论REST API时,关注点不再是“框架能不能工作”,而是“客户端能否通过一组清晰的HTTP接口,围绕某个资源完成查询、创建、修改和删除等操作”。所谓资源,可以是用户、课程、订单,也可以是你想让系统管理的任何实体。
为了让讨论变得具体,我们不妨围绕一个极其常见的场景展开——一个极简的课程管理接口。想象一下,你正在搭建的是一个在线学习平台的后端系统,你希望能够通过HTTP接口完成课程的创建、查看、更新和删除。这个部分我们不会引入数据库持久化,而是先在内存中维护一组课程数据,通过这个过程熟悉REST API的各个组成部分。等到下一节课接入数据库时,你会发现,只需要把数据的存储位置从内存替换为数据库,整体的API设计思路可以保持高度一致。
在设计任何一个REST接口之前,先在脑海里勾勒出“资源”的样子往往是最自然的起点。对于课程而言,一个课程至少需要有一个唯一标识、一个名称以及一个简短描述。除此之外,你可能还会关心课程的难度级别、时长、标签等属性,这些字段可以根据后续需求逐步扩展。现在先把注意力集中在那几个最基础的字段上,因为它们足以支撑我们完成一个完整的CRUD接口。

在Spring Boot中,资源模型通常用一个普通的Java类来表示,这个类有时被称为领域模型,有时被称为DTO(数据传输对象),具体称呼取决于它在项目中的职责范围。本章出于简化目的,使用一个最直接的模型类Course,它既作为服务内部的数据表示,也作为对外API的返回结构。等到系统复杂到一定程度时,你可以再引入专门的DTO,将外部暴露的数据结构和内部领域模型解耦。
你可以在src/main/java/com/example/myapp/my_spring_boot_app目录下新建一个名为model的包,然后在这个包下创建Course类,完整路径是src/main/java/com/example/myapp/my_spring_boot_app/model/Course.java。在IDE中,展开之前我们已经创建好的包结构,找到my_spring_boot_app这一层,在它上面点击右键,选择新建包,输入model并确认,然后在model包上点击右键,选择新建Java类,输入类名Course,IDE会在对应目录下生成Course.java文件。打开这个文件,将内容替换为以下代码:
|package com.example.myapp.my_spring_boot_app.model; public class Course { private Long id; private String title; private String description; public Course() { } public Course(Long id, String title, String description) { this.id = id; this.title = title; this.description = description; } 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; } }
这个类没有任何框架特定的注解,只是一个最普通的Java Bean。它的意义在于为后续的控制器和服务提供一个清晰的数据结构,让每一个接口参数和返回值都有具体的载体。当你在浏览器中看到JSON响应时,会更容易将其中的字段与这个类的属性对应起来。
在确定了资源模型之后,就可以着手设计URL路径和HTTP动词的组合方式。一个常见的约定是使用复数形式的名词作为集合资源路径,例如/api/courses代表课程集合,而/api/courses/{id}代表某个具体课程。对于读取列表的操作,通常使用GET方法访问/api/courses;想获取某个特定课程,则使用GET方法访问/api/courses/{id};需要创建新课程时,使用POST请求发送数据到/api/courses;更新现有课程时,可以使用PUT或PATCH请求发送到/api/courses/{id};删除课程时,则使用DELETE请求,并把待删除课程的ID放在路径中。这样一组约定在大多数REST风格的接口中都能见到,当你熟悉了这种模式之后,面对其他团队的接口文档也会有天然的亲近感。

为了让接口具备“记忆力”,我们需要在服务器端有一个可以暂存课程数据的地方。在引入数据库之前,可以先在内存中维护一个简单的“仓库”,利用一个线程安全的集合来保存课程对象,并通过一个自增的ID生成器为新课程分配唯一标识。这种实现方式虽然不能持久化数据,但足以支撑我们理解REST API各个操作之间的协作方式。
仍然在src/main/java/com/example/myapp/my_spring_boot_app包结构下,你可以创建一个名为repository的新包,完整路径是src/main/java/com/example/myapp/my_spring_boot_app/repository,然后在其中新建InMemoryCourseRepository类。创建步骤与上一节类似,在my_spring_boot_app包上点击右键,新建包repository,再在此包中创建Java类InMemoryCourseRepository,IDE会生成InMemoryCourseRepository.java文件。将其内容替换为下面的代码:
|package com.example.myapp.my_spring_boot_app.repository; import com.example.myapp.my_spring_boot_app.model.Course; import org.springframework.stereotype.Repository; import java.util.ArrayList; import java.util.Collections; 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 InMemoryCourseRepository { private final Map<Long, Course
这个简单的仓库使用ConcurrentHashMap作为底层存储结构,AtomicLong负责生成自增的ID。通过@Repository注解,它会被Spring的组件扫描机制自动发现,并注册到应用上下文中。后续的服务层和控制器可以像使用普通对象一样注入并调用这个仓库,而不需要关心它内部是使用内存还是数据库来保存数据。等到你在下一节课引入JPA和真实数据库时,只需要用一个基于JpaRepository的实现替换这个类,其余接口代码几乎可以保持不变。
虽然在一个极简示例中,控制器可以直接调用仓库完成所有操作,但在稍微严肃一些的项目里,引入服务层往往会让结构更清晰。服务层的职责是协调仓库操作、封装业务规则,并向控制器提供一个相对稳定的接口。当业务需求发生变化时,往往优先调整服务层逻辑,而控制器可以尽量保持不变。
在src/main/java/com/example/myapp/my_spring_boot_app包下再创建一个名为service的包,然后在其中新建CourseService类,路径为src/main/java/com/example/myapp/my_spring_boot_app/service/CourseService.java。将代码编写为:
|package com.example.myapp.my_spring_boot_app.service; import com.example.myapp.my_spring_boot_app.model.Course; import com.example.myapp.my_spring_boot_app.repository.InMemoryCourseRepository; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.List; @Service public class CourseService { private final InMemoryCourseRepository repository; public CourseService(InMemoryCourseRepository repository) { this.repository = repository; }
在这个服务类中,我们刻意加入了一些最基本的业务检查,例如课程标题不能为空、更新时根据传入参数决定是否修改对应字段等。通过抛出IllegalArgumentException的方式把问题向上反馈给控制器,控制器再将这些错误转换成适当的HTTP响应。这样既保持了服务层对业务规则的掌控,又让控制器专注于HTTP世界中的细节。

现在我们已经有了资源模型、内存仓库和服务层,是时候为客户端打开一扇正式的入口了。REST控制器的职责是接收HTTP请求、调用服务层完成相应操作,然后将结果转换为HTTP响应。在Spring Boot中,这个角色通常由标注了@RestController的类来承担。
继续在src/main/java/com/example/myapp/my_spring_boot_app/controller包下创建一个新的控制器类CourseController,路径是src/main/java/com/example/myapp/my_spring_boot_app/controller/CourseController.java。在IDE中,找到前一节创建的controller包,在该包上点击右键,新建Java类CourseController,然后将文件内容替换为:
|package com.example.myapp.my_spring_boot_app.controller; import com.example.myapp.my_spring_boot_app.model.Course; import com.example.myapp.my_spring_boot_app.service.CourseService; import org.springframework.http.HttpStatus; 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.RestController; import java.util.List;
这个控制器将/api/courses作为统一的前缀路径,通过不同的HTTP动词和子路径实现列表查询、单个查询、创建、更新和删除等操作。创建课程时使用ResponseEntity包装返回结果,并将HTTP状态码设置为201 Created,以清晰表达“新资源已经被成功创建”的语义;删除操作则返回一个没有响应体的204 No Content。这些状态码并不是强制要求,但遵循这样的约定能让客户端更容易理解接口行为。
此时,如果你重新运行应用,使用curl或其他HTTP客户端发送请求,就可以验证这些接口是否按预期工作。例如,可以发送一个POST请求创建课程,再通过GET请求查看列表和单个课程的详情,最后通过DELETE请求将其删除。整个过程虽然还没有持久化到数据库,但已经完整覆盖了一个典型REST资源的生命周期。
前面的实现中,服务层在遇到非法参数或资源不存在时,会抛出IllegalArgumentException。如果不做任何额外处理,这些异常会以默认的错误页面或通用错误响应的形式返回给客户端,信息往往不够友好。一个更优雅的做法是集中处理这些异常,根据异常类型返回结构化的错误响应,让客户端能够以一致的方式感知错误。
在src/main/java/com/example/myapp/my_spring_boot_app/controller包下新建一个名为GlobalExceptionHandler的类,路径是src/main/java/com/example/myapp/my_spring_boot_app/controller/GlobalExceptionHandler.java,将代码写成:
|package com.example.myapp.my_spring_boot_app.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.Instant; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<Map<String, Object>> handleIllegalArgument
@RestControllerAdvice让这个类成为全局异常处理器,@ExceptionHandler方法会截获特定类型的异常,并构造自定义的JSON响应。客户端在收到错误响应时,可以通过error字段快速判断错误类型,通过message字段获取具体原因。这样一来,即便接口在错误情况下返回的仍是JSON结构,也不会破坏整体的API风格。
在实践型项目中,测试不仅是保障质量的手段,也是理解框架行为的另一种窗口。通过为REST控制器编写测试,你可以在不依赖前端页面的前提下,验证接口是否按照预期处理各种请求场景。更重要的是,当你后续重构服务层或替换持久化方案时,测试能帮助你迅速发现潜在的行为改变。
在src/test/java/com/example/myapp/my_spring_boot_app/controller路径下创建一个与包结构对应的测试类CourseControllerTest。在IDE中,展开src/test/java,如果还没有com.example.myapp.my_spring_boot_app.controller这一层包,就逐级创建,最后在controller包中添加CourseControllerTest类,并将内容替换为:
|package com.example.myapp.my_spring_boot_app.controller; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.util.HashMap; import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
这个测试方法模拟了从创建课程到查询、更新再到删除的完整生命周期。通过MockMvc发送HTTP请求并断言响应状态以及JSON内容,你可以在不打开浏览器的前提下验证接口是否工作正常。当你执行mvn test或在IDE中运行测试类时,如果所有断言都通过,就说明REST API的行为与预期一致。
在实践中,为REST API编写端到端测试是一项极具回报的投资。每当你调整业务规则、修改参数结构、替换持久化层甚至升级Spring Boot版本时,这些测试都会像一张安全网一样托住你,帮你及时发现那些不易肉眼察觉的行为变化。对入门者而言,习惯通过测试来观察接口行为,也能更快形成对HTTP交互细节的直观认识。
到这里你已经从一个只能返回固定字符串的简单示例,迈向了真正意义上的REST API服务。你为课程这一资源设计了清晰的数据模型,构建了内存仓库和服务层,在控制器中将HTTP动词与业务操作一一对应起来,并通过全局异常处理器为错误情况提供了统一的响应结构。 最后,你还借助测试工具从外部视角验证了接口在整个生命周期中的行为。即使这些课程数据目前还停留在内存中,但整体结构已经非常接近真实项目,在下一节课接入数据库时,你会发现很多代码都可以原封不动地复用。
在接下来的课程中,我们将把视线从内存搬到持久化存储上,通过引入Spring Data JPA和关系型数据库,让这些课程数据真正“落地”。届时,你会看到实体类如何映射到数据库表,仓库接口如何通过约定式方法名自动生成查询语句,以及事务管理如何保证数据在复杂操作中的一致性。 当REST API与数据库连接起来之后,你构建的就不再只是一个演示玩具,而是可以承载真实业务数据的后端服务。