目标与范围
- 明确一致的接口风格,降低客户端心智负担,提升跨团队协作效率。
- 规范涵盖:资源建模、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/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;
}
}
3 条评论
看不懂,只为占个榜打破零评论~( ๑´•ω•) "(ㆆᴗㆆ)
哈哈哈,除了阿牛哥,也没人来看了😊
怎么会呢,只是文章太深奥了,插不上嘴,哈哈。