软件工程实践三:RESTful API 设计原则

IsayIsee Lv3

文章来源说明:

  • 作者: [xuan]
  • 发布平台: [Xuan’s blog]
  • 原链接: https://blog.ybyq.wang/archives/1101.html
  • 发布时间: [2025-09-11]
  • 转载用途: 仅供学习和技术交流

    版权归原作者所有,本文仅做整理和分享之用。

软件工程实践三:RESTful API 设计原则

目标与范围

  • 明确一致的接口风格,降低客户端心智负担,提升跨团队协作效率。
  • 规范涵盖:资源建模、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 传输。
  • 统一返回结构(可选但推荐,便于一致性与观测):
1
2
3
4
5
6
{  
"code": "OK", // 可选
"message": "success", // 可选
"data": { /* 资源对象或结果 */ },
"requestId": "f7a2b..."
}
  • 错误返回结构:
1
2
3
4
5
6
7
8
{  
"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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 响应示例

1
2
3
4
[
{ "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
1
2
3
4
POST /api/v1/todos
Content-Type: application/json

{ "title": "Read book", "completed": false }
1
2
3
4
5
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
1
2
3
4
PATCH /api/v1/todos/1
Content-Type: application/json

{ "completed": true }
1
2
3
4
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
1
DELETE /api/v1/todos/2
1
HTTP/1.1 204 No Content

6.6 完整控制器(含新增/修改/删除)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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 请求/响应示例

  • 列表:
1
GET /api/v1/products  
1
2
3
4
[
{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 },
{ "id": 2, "description": "Demo B", "price": 199.00, "stock": 5 }
]
  • 详情:
1
GET /api/v1/products/1  
1
{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }
  • 新建:
1
2
3
POST /api/v1/products  
Content-Type: application/json
{ "description": "New Product", "price": 9.99, "stock": 100 }
1
2
3
4
5
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 }
  • 修改(部分字段):
1
2
3
PATCH /api/v1/products/1  
Content-Type: application/json
{ "stock": 8 }
1
2
3
4
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 8 }
  • 删除:
1
DELETE /api/v1/products/2  
1
HTTP/1.1 204 No Content

7.2 控制器示例(Java / Spring)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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;
}
}
  • 标题: 软件工程实践三:RESTful API 设计原则
  • 作者: IsayIsee
  • 创建于 : 2026-04-07 15:33:13
  • 更新于 : 2026-05-18 14:00:26
  • 链接: https://blog.120528.xyz/2026/04/07/630ad3b4/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。