第 7 章 · RESTful API 与统一响应
本章目标:使用 ResponseEntity 精确控制 HTTP 状态码与响应头;实现 Pageable 分页与排序;设计 Result<T> 统一包装(code/message/data);完成商品 CRUD REST API;处理 @Valid 校验与全局异常映射;对照 shop-demo DRF 的 Response、Pagination、Serializer 说明异同;让 shop-spring-demo 的 JSON API 达到可对接虚构 api.example.com 前端 SDK 的契约水平。
学时建议:6~7 小时(含 3 小时跟练)
前置:完成 spring-boot-web ch06;java-dev ch08 异常;了解 HTTP 状态码语义。
7.1 场景说明:契约稳定的 JSON API
移动端与 SPA 从 https://api.example.com/api/v1/products 拉取分页商品;创建/更新需 Session 或 Token(ch06 Session / ch15 JWT)。响应格式需统一,避免有的接口返回裸数组、有的返回 {error: ...}。
| 需求 | ch03 现状 | ch07 目标 |
|---|---|---|
| 列表 | 裸 List<ProductDto> | Result<PageResult<ProductDto>> |
| 创建 | 未实现 | 201 + Location 头 |
| 错误 | 500 堆栈 | 400/404/409 + 统一 code |
| 分页 | limit 参数 | Pageable page/size/sort |
shop-demo DRF 常用:
{"count": 100, "next": "...", "previous": null, "results": [...]}
shop-spring-demo 采用更贴近国内团队的 {code, message, data} 包装,并在 data 内放分页元信息。
7.2 统一 Result 模型
package com.example.shop.common.api;
public record Result<T>(
int code,
String message,
T data
) {
public static <T> Result<T> ok(T data) {
return new Result<>(0, "success", data);
}
public static <T> Result<T> ok() {
return ok(null);
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
}
public record PageResult<T>(
List<T> items,
long total,
int page,
int size,
int totalPages
) {}
| 字段 | 含义 |
|---|---|
code | 业务码,0 成功,非 0 失败 |
message | 人类可读说明 |
data | 载荷,可为 PageResult、单对象、null |
Django DRF:HTTP 状态 + body 内 detail;Spring 本课程同时规范 HTTP 状态与 body code。
7.3 分页 Pageable
7.3.1 Controller
@GetMapping
public Result<PageResult<ProductDto>> list(
@RequestParam(required = false) String q,
@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<ProductDto> page = catalogService.search(q, pageable);
PageResult<ProductDto> body = new PageResult<>(
page.getContent(),
page.getTotalElements(),
page.getNumber(),
page.getSize(),
page.getTotalPages()
);
return Result.ok(body);
}
7.3.2 Service
public Page<ProductDto> search(String q, Pageable pageable) {
Page<Product> page;
if (q == null || q.isBlank()) {
page = productRepository.findByActiveTrue(pageable);
} else {
page = productRepository.findByActiveTrueAndNameContainingIgnoreCase(q.trim(), pageable);
}
return page.map(this::toDto);
}
7.3.3 请求示例
GET /api/v1/products?page=0&size=10&sort=price,desc
| 参数 | 默认 | DRF 对照 |
|---|---|---|
page | 0 | page(DRF 常从 1 起,需文档说明) |
size | 20 | page_size |
sort | id,desc | ordering=-price |
文档中写清 page 从 0 开始,避免前端 off-by-one。
7.4 ResponseEntity 精细控制
7.4.1 创建 201
@PostMapping
@PreAuthorize("hasRole('STAFF')")
public ResponseEntity<Result<ProductDto>> create(
@Valid @RequestBody ProductCreateRequest req) {
ProductDto created = catalogService.create(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(Result.ok(created));
}
7.4.2 更新与删除
@PutMapping("/{id}")
@PreAuthorize("hasRole('STAFF')")
public Result<ProductDto> update(@PathVariable Long id,
@Valid @RequestBody ProductUpdateRequest req) {
return Result.ok(catalogService.update(id, req));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Result<Void>> delete(@PathVariable Long id) {
catalogService.delete(id);
return ResponseEntity.ok(Result.ok());
}
| 操作 | HTTP | Body |
|---|---|---|
| 创建 | 201 | Result<ProductDto> |
| 更新 | 200 | Result<ProductDto> |
| 删除 | 200 或 204 | 可选空 body |
| 查询 | 200 | Result<PageResult> / Result<ProductDto> |
7.5 请求 DTO 与校验
public record ProductCreateRequest(
@NotBlank @Size(max = 32) String sku,
@NotBlank @Size(max = 128) String name,
@NotNull @DecimalMin("0.01") BigDecimal price,
@NotNull @Min(1) Long categoryId,
@Min(0) Integer stock
) {}
Controller 类加 @Validated;方法参数 @Valid @RequestBody。
校验失败由全局异常处理转换为:
{"code": 40001, "message": "sku: SKU 不能为空", "data": null}
Django DRF:serializer.is_valid(raise_exception=True) → 400 field errors。
7.6 全局异常处理 @RestControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException ex) {