目标与范围
- 明确一致的接口风格,降低客户端心智负担,提升跨团队协作效率。
- 规范涵盖:资源建模、URI 设计、HTTP 方法与状态码、请求/响应、错误、分页过滤排序、版本化、安全、缓存、可观测性与文档。
1. 核心理念
- 资源导向:一切皆资源(名词复数命名),动作用 HTTP 方法表达。
- 统一接口:方法、状态码、媒体类型、错误结构一致。
- 无状态:每个请求自包含认证与上下文,不依赖服务器会话。
- 可缓存:合理使用条件请求与缓存头,降低延迟与负载。
- 可演进:通过版本化与向后兼容策略平滑升级。
2. 资源建模与 URI 规范
- 资源命名:使用复数、短小、层级表达从属关系。 - /api/v1/users
- /api/v1/users/{userId}
- /api/v1/users/{userId}/orders
 
- 关联过滤:也可使用查询参数表达关联(优先简单方案)。 - /api/v1/orders?userId=123
 
- 避免在路径中使用动词;确需动作,用子资源表达。 - POST /api/v1/invoices/{id}/pay
 
- 标识符:建议使用不可泄漏信息的 ID(UUID/雪花),避免自增 ID 暴露业务规模。
路径示例与反例:
- 推荐:GET /api/v1/users/{id},PATCH /api/v1/users/{id},DELETE /api/v1/users/{id}
- 不推荐:POST /api/update/users/{id}、POST /api/create/users/{id}(应分别使用 PUT/PATCH 与 POST /api/v1/users)
3. HTTP 方法语义与幂等性
- GET(安全、幂等):获取资源或集合。
- POST(非幂等):创建资源、触发计算/异步任务。
- PUT(幂等):整体替换资源(客户端提供完整表述)。
- PATCH(建议近幂等):局部更新,使用 JSON Merge Patch 或 JSON Patch。
- DELETE(幂等):删除资源(软删/硬删在语义上对客户端保持透明)。
4. 标准状态码
- 2xx: - 200 OK:成功,返回资源或结果;
- 201 Created:创建成功,Location 指向新资源;
- 202 Accepted:已受理异步任务;
- 204 No Content:成功但无响应体(删除、幂等更新)。
 
- 4xx: - 400 Bad Request:参数错误/校验失败;
- 401 Unauthorized:未认证或凭证无效;
- 403 Forbidden:已认证但无权限;
- 404 Not Found:资源不存在;
- 409 Conflict:资源状态冲突(如唯一键冲突);
- 412 Precondition Failed:条件请求失败(ETag 并发控制);
- 415 Unsupported Media Type:媒体类型不支持;
- 422 Unprocessable Entity:语义错误(校验未通过);
- 429 Too Many Requests:限流触发。
 
- 5xx:服务器错误,尽量避免;记录告警并快速恢复。
5. 请求与响应规范
- 媒体类型:请求/响应 Content-Type/Accept 统一使用 application/json; charset=utf-8。
- 字段命名与格式: - 推荐 camelCase;时间使用 ISO 8601(UTC),如 2025-08-20T10:30:00Z。
- 大整数(如 ID)防止前端精度丢失可按 string 传输。
 
- 推荐 camelCase;时间使用 ISO 8601(UTC),如 
- 统一返回结构(可选但推荐,便于一致性与观测): - { "code": "OK", // 可选 "message": "success", // 可选 "data": { /* 资源对象或结果 */ }, "requestId": "f7a2b...", }
- 错误返回结构: - { "code": "VALIDATION_ERROR", // 可以是数字,字符串 "message": "email is invalid",// 提示 "requestId": "9e6d7...", // 可选 "details": [ // 可选 { "field": "email", "issue": "must be a valid email" } ] }
6. 示例:Todo 列表接口(内存 List)
- 用途:演示使用内存 List存储并返回 todo 集合(示例代码,非生产)。
- 路径:GET /api/v1/todos
- 返回:200 OK,application/json; charset=utf-8
6.1 控制器示例(Java / Spring)
package com.example.demo.todo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
@RestController
@RequestMapping("/api/v1/todos")
public class TodoController {
  private final List<Todo> store = new CopyOnWriteArrayList<>();
  private final AtomicLong idGen = new AtomicLong(0);
  public record Todo(Long id, String title, boolean completed) {}
  public TodoController() {
    store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false));
    store.add(new Todo(idGen.incrementAndGet(), "Write docs", true));
  }
  @GetMapping
  public List<Todo> list() {
    return store; // 直接返回内存 List
  }
}6.2 响应示例
[
  { "id": 1, "title": "Learn REST", "completed": false },
  { "id": 2, "title": "Write docs", "completed": true }
]6.3 创建 Todo(POST)
- 路径:POST /api/v1/todos
- 请求体:{ "title": string, "completed": boolean? }
- 返回:201 Created(或200 OK),Location指向新资源,响应体为创建后的 todo
POST /api/v1/todos
Content-Type: application/json
{ "title": "Read book", "completed": false }HTTP/1.1 201 Created
Location: /api/v1/todos/3
Content-Type: application/json; charset=utf-8
{ "id": 3, "title": "Read book", "completed": false }6.4 修改 Todo(PATCH)
- 路径:PATCH /api/v1/todos/{id}
- 请求体:可选字段,部分更新
- 返回:200 OK,返回更新后的 todo;不存在返回404 Not Found
PATCH /api/v1/todos/1
Content-Type: application/json
{ "completed": true }HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "id": 1, "title": "Learn REST", "completed": true }6.5 删除 Todo(DELETE)
- 路径:DELETE /api/v1/todos/{id}
- 返回:存在则 204 No Content,不存在返回404 Not Found
DELETE /api/v1/todos/2HTTP/1.1 204 No Content6.6 完整控制器(含新增/修改/删除)
package com.example.demo.todo;
import org.springframework.http.HttpHeaders;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
@RestController
@RequestMapping("/api/v1/todos")
public class TodoController {
  private final List<Todo> store = new CopyOnWriteArrayList<>();
  private final AtomicLong idGen = new AtomicLong(0);
  public record Todo(Long id, String title, boolean completed) {}
  public record CreateTodoRequest(String title, Boolean completed) {}
  public record PatchTodoRequest(String title, Boolean completed) {}
  public TodoController() {
    store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false));
    store.add(new Todo(idGen.incrementAndGet(), "Write docs", true));
  }
  @GetMapping
  public List<Todo> list() {
    return store;
  }
  @PostMapping
  public ResponseEntity<Todo> create(@RequestBody CreateTodoRequest req) {
    if (req == null || req.title() == null || req.title().isBlank()) {
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    boolean completed = req.completed() != null ? req.completed() : false;
    long id = idGen.incrementAndGet();
    Todo todo = new Todo(id, req.title(), completed);
    store.add(todo);
    return ResponseEntity
      .status(HttpStatus.CREATED)
      .header(HttpHeaders.LOCATION, "/api/v1/todos/" + id)
      .body(todo);
  }
  @PatchMapping("/{id}")
  public ResponseEntity<Todo> patch(@PathVariable Long id, @RequestBody PatchTodoRequest req) {
    int idx = indexOfId(id);
    if (idx < 0) {
      return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
    Todo old = store.get(idx);
    String newTitle = (req != null && req.title() != null) ? req.title() : old.title();
    boolean newCompleted = (req != null && req.completed() != null) ? req.completed() : old.completed();
    Todo updated = new Todo(id, newTitle, newCompleted);
    store.set(idx, updated);
    return ResponseEntity.ok(updated);
  }
  @DeleteMapping("/{id}")
  public ResponseEntity<Void> delete(@PathVariable Long id) {
    boolean removed = store.removeIf(t -> t.id().equals(id));
    return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build();
  }
  private int indexOfId(Long id) {
    for (int i = 0; i < store.size(); i++) {
      if (store.get(i).id().equals(id)) {
        return i;
      }
    }
    return -1;
  }
}7. 示例:Product 产品接口(内存 List)
- 用途:演示“产品”资源的增删改查实现(示例代码,非生产)。
- 路径: - GET /api/v1/products列表
- GET /api/v1/products/{id}详情
- POST /api/v1/products新建
- PATCH /api/v1/products/{id}部分更新
- DELETE /api/v1/products/{id}删除
 
- 模型字段: - id: number/string(响应中为数字,此处示例用 Long)
- description: string(产品描述)
- price: number(价格,建议十进制定点,Java 用 BigDecimal)
- stock: number(库存,非负整数)
 
7.1 请求/响应示例
- 列表: - GET /api/v1/products
[
  { "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 },
  { "id": 2, "description": "Demo B", "price": 199.00, "stock": 5 }
]- 详情: - GET /api/v1/products/1
{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }- 新建: - POST /api/v1/products Content-Type: application/json { "description": "New Product", "price": 9.99, "stock": 100 }
HTTP/1.1 201 Created
Location: /api/v1/products/3
Content-Type: application/json; charset=utf-8
{ "id": 3, "description": "New Product", "price": 9.99, "stock": 100 }- 修改(部分字段): - PATCH /api/v1/products/1 Content-Type: application/json { "stock": 8 }
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 8 }- 删除: - DELETE /api/v1/products/2
HTTP/1.1 204 No Content7.2 控制器示例(Java / Spring)
package com.example.demo.product;
import org.springframework.http.HttpHeaders;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
  private final List<Product> store = new CopyOnWriteArrayList<>();
  private final AtomicLong idGen = new AtomicLong(0);
  public record Product(Long id, String description, BigDecimal price, int stock) {}
  public record CreateProductRequest(String description, BigDecimal price, Integer stock) {}
  public record PatchProductRequest(String description, BigDecimal price, Integer stock) {}
  public ProductController() {
    store.add(new Product(idGen.incrementAndGet(), "Demo A", new BigDecimal("99.90"), 10));
    store.add(new Product(idGen.incrementAndGet(), "Demo B", new BigDecimal("199.00"), 5));
  }
  @GetMapping
  public List<Product> list() {
    return store;
  }
  @GetMapping("/{id}")
  public ResponseEntity<Product> getById(@PathVariable Long id) {
    int idx = indexOfId(id);
    if (idx < 0) {
      return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
    return ResponseEntity.ok(store.get(idx));
  }
  @PostMapping
  public ResponseEntity<Product> create(@RequestBody CreateProductRequest req) {
    if (!isValidCreate(req)) {
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    long id = idGen.incrementAndGet();
    Product product = new Product(id, req.description(), req.price(), req.stock());
    store.add(product);
    return ResponseEntity
      .status(HttpStatus.CREATED)
      .header(HttpHeaders.LOCATION, "/api/v1/products/" + id)
      .body(product);
  }
  @PatchMapping("/{id}")
  public ResponseEntity<Product> patch(@PathVariable Long id, @RequestBody PatchProductRequest req) {
    int idx = indexOfId(id);
    if (idx < 0) {
      return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
    }
    Product old = store.get(idx);
    String newDescription = req != null && req.description() != null ? req.description() : old.description();
    BigDecimal newPrice = req != null && req.price() != null ? req.price() : old.price();
    Integer newStockBoxed = req != null && req.stock() != null ? req.stock() : old.stock();
    if (!isValidFields(newDescription, newPrice, newStockBoxed)) {
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
    Product updated = new Product(id, newDescription, newPrice, newStockBoxed);
    store.set(idx, updated);
    return ResponseEntity.ok(updated);
  }
  @DeleteMapping("/{id}")
  public ResponseEntity<Void> delete(@PathVariable Long id) {
    boolean removed = store.removeIf(p -> p.id().equals(id));
    return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build();
  }
  private int indexOfId(Long id) {
    for (int i = 0; i < store.size(); i++) {
      if (store.get(i).id().equals(id)) {
        return i;
      }
    }
    return -1;
  }
  private boolean isValidCreate(CreateProductRequest req) {
    if (req == null || req.description() == null || req.description().isBlank()) return false;
    if (req.price() == null || req.price().compareTo(BigDecimal.ZERO) < 0) return false;
    if (req.stock() == null || req.stock() < 0) return false;
    return true;
  }
  private boolean isValidFields(String description, BigDecimal price, Integer stock) {
    if (description == null || description.isBlank()) return false;
    if (price == null || price.compareTo(BigDecimal.ZERO) < 0) return false;
    if (stock == null || stock < 0) return false;
    return true;
  }
} 
                    
3 条评论
看不懂,只为占个榜打破零评论~( ๑´•ω•) "(ㆆᴗㆆ)
哈哈哈,除了阿牛哥,也没人来看了😊
怎么会呢,只是文章太深奥了,插不上嘴,哈哈。