创建第一个 Spring Boot REST API

1. 总览

本文将指导你使用 Spring Boot 创建一个完整的 REST API,以 Order(订单)管理为例。学完后,你将能搭建项目、编写处理 CRUD 的 controller、按照 HTTP 标准返回错误,以及用 MockMvc 为 API 编写测试。

2. 项目初始化

通过 Spring Initializr 创建项目,添加以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-web 提供嵌入式 Tomcat 和 Spring MVC。spring-boot-starter-validation 用于验证请求体(第 5 部分)。spring-boot-starter-test 包含 JUnit 5、AssertJ、MockMvc。

检查应用是否能正常运行:

@SpringBootApplication
public class OrderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApiApplication.class, args);
    }
}

运行 mvn spring-boot:run,你会看到 Tomcat 在端口 8080 启动。

3. 定义 model

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // getters, setters
}

这是一个简单的 model,还没涉及 JPA。本文的目标是先掌握 REST layer,数据持久化部分留给其他文章。

4. 编写 Service 存储临时数据

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMap 充当临时“数据库”,让你无需配置数据库即可运行演示。AtomicLong 生成递增的 id,多个线程调用 create 时是线程安全的。

注意: 这种数据存储方式仅用于学习,重启应用时数据会丢失。生产环境需要使用 Spring Data JPA 或类似的持久化框架。

5. 编写 REST Controller

5.1. 创建和查询所有订单的端点

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestController 结合了 @Controller 和 @ResponseBody,返回 JSON 而不是视图。@RequestMapping("/api/orders") 为类中的所有端点设置公共前缀。@PostMapping 通过 @RequestBody 接收 body,Spring 会利用 spring-boot-starter-web 中内置的 Jackson 自动将 JSON 转换为 OrderRequest。

OrderRequest 是用于验证输入的 record,与 Order 分离:

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. 按 id 查询和删除端点

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariable 从 URL 中获取 {id} 的值。当找不到订单时,API 返回 404 Not Found 而不是 null——这是初学 REST API 时容易遗漏的一点。

尝试用 curl 调用:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. 集中处理错误

如果不处理,验证错误会返回难以理解的默认 JSON 响应。使用 @RestControllerAdvice 来统一处理:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

这个类捕获 @Valid 抛出的所有 MethodArgumentNotValidException,按字段收集错误,然后返回包含易于理解的内容的 400 Bad Request:

{
  "amount": "must be greater than 0",
  "customerName": "must not be blank"
}

注意: 不要让 Spring 的默认异常在生产环境中暴露——它可能会暴露堆栈跟踪,影响安全性。

7. 为 Controller 编写测试

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTest 只加载 web layer(controller、filter、validation),不初始化整个 context,所以测试运行更快。@MockBean 用 mock 替换真实的 OrderService,帮助测试 controller 时与 service 逻辑独立。这两个测试用例检查成功和失败的路径——这是每个端点都应该有的习惯。

用 AssertJ 在 service 层进行额外验证:

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. 常见错误

  • 忘记 @RequestBody:Spring 不会将 JSON 绑定到对象,字段始终为 null,但不会明确报错。
  • 返回 null 而不是 ResponseEntity.notFound():客户端收到 200 OK 和 null body,容易导致前端 bug。
  • 不验证输入:API 接收负数金额、空名称等,导致下层逻辑错误且难以追踪。
  • 使用 @Controller 而不是 @RestController:必须在每个方法上手动添加 @ResponseBody,容易遗漏。

注意: 如果你的 API 需要服务多个客户端(移动端、web、合作方),应该从一开始就考虑版本控制(例如 /api/v1/orders),以避免日后的破坏性变更。

9. 结论

你已经用 Spring Boot 搭建了一个基础的 REST API:从 model、service、controller、集中错误处理到使用 MockMvc 的测试。下一步,你应该用 Spring Data JPA 替换 ConcurrentHashMap 来实现真正的数据持久化,并在 @RestControllerAdvice 中添加处理其他异常(如 EntityNotFoundException)的逻辑。

spring-bootrest-apijavaspring-web