目标与范围

  • 明确一致的接口风格,降低客户端心智负担,提升跨团队协作效率。
  • 规范涵盖:资源建模、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 传输。
  • 统一返回结构(可选但推荐,便于一致性与观测):

    {
    "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 OKapplication/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/2
HTTP/1.1 204 No Content

6.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 Content

7.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;
  }
}
最后修改:2025 年 09 月 11 日
如果觉得我的文章对你有用,请随意赞赏
END
本文作者:
文章标题:软件工程实践三:RESTful API 设计原则
本文地址:https://blog.ybyq.wang/archives/1101.html
版权说明:若无注明,本文皆Xuan's blog原创,转载请保留文章出处。