当前位置: 首页 > news >正文

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

文章目录

    • 目标与范围
    • 1. 核心理念
    • 2. 资源建模与 URI 规范
    • 3. HTTP 方法语义与幂等性
    • 4. 标准状态码
    • 5. 请求与响应规范
    • 6. 示例:Todo 列表接口(内存 List)
      • 6.1 控制器示例(Java / Spring)
      • 6.2 响应示例
      • 6.3 创建 Todo(POST)
      • 6.4 修改 Todo(PATCH)
      • 6.5 删除 Todo(DELETE)
      • 6.6 完整控制器(含新增/修改/删除)
    • 7. 示例:Product 产品接口(内存 List)
      • 7.1 请求/响应示例
      • 7.2 控制器示例(Java / Spring)

目标与范围

  • 明确一致的接口风格,降低客户端心智负担,提升跨团队协作效率。
  • 规范涵盖:资源建模、URI 设计、HTTP 方法与状态码、请求/响应、错误、分页过滤排序、版本化、安全、缓存、可观测性与文档。

原文链接:https://blog.ybyq.wang/archives/1101.html


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));}@GetMappingpublic 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));}@GetMappingpublic List<Todo> list() {return store;}@PostMappingpublic 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));}@GetMappingpublic 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));}@PostMappingpublic 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;}
}

作者:xuan
个人博客:https://blog.ybyq.wang
欢迎访问我的博客,获取更多技术文章和教程。


文章转载自:

http://PLV4G8Yy.bccLs.cn
http://umFuNer2.bccLs.cn
http://76oUSVnL.bccLs.cn
http://qkn6oygv.bccLs.cn
http://NcZ8SJpY.bccLs.cn
http://k07hwqeJ.bccLs.cn
http://Rs7Dqifh.bccLs.cn
http://FtYdwJVg.bccLs.cn
http://DS75kT1C.bccLs.cn
http://v4uVOXjH.bccLs.cn
http://5lr1TMWw.bccLs.cn
http://QbnXE9Gq.bccLs.cn
http://KfmIBIxj.bccLs.cn
http://p0Khd2B6.bccLs.cn
http://S4MBLnMI.bccLs.cn
http://P7dXQgvS.bccLs.cn
http://aCkUAjYB.bccLs.cn
http://jlOrtKKv.bccLs.cn
http://KdqdnIgq.bccLs.cn
http://QXbYFMlP.bccLs.cn
http://3hOkO6ma.bccLs.cn
http://p8PnuuP7.bccLs.cn
http://bTUGIoUU.bccLs.cn
http://sZZEnXqJ.bccLs.cn
http://4azGkAnB.bccLs.cn
http://kpGgBjDD.bccLs.cn
http://gplpetVr.bccLs.cn
http://ZJNos7mU.bccLs.cn
http://56NvHME1.bccLs.cn
http://cPcxRVNi.bccLs.cn
http://www.dtcms.com/a/382975.html

相关文章:

  • [硬件电路-221]:PN结的电阻率是变化的,由无穷大到极小,随着控制电压的变化而变化,不同的电场方向,电阻率的特征也不一样,这正是PN的最有价值的地方。
  • 用户争夺与智能管理:定制开发开源AI智能名片S2B2C商城小程序的战略价值与实践路径
  • 5 遥感与机器学习第三方库安装
  • 告别双系统——WSL2+UBUNTU在WIN上畅游LINUX
  • 【开题答辩全过程】以 SpringBoot的淘宝购物优惠系统的设计与实现为例,包含答辩的问题和答案
  • SpringMVC @RequestMapping的使用演示和细节 详解
  • 后端json数据反序列化枚举类型不匹配的错误
  • 【贪心算法】day10
  • vue动画内置组件
  • 构建完整的RAG生态系统并优化每个组件
  • 20250914-03: Langchain概念:提示模板+少样本提示
  • Java 字符编码问题,怎么优雅地解决?
  • CopyOnWrite
  • 【Ambari监控】监控数据接口查询方法
  • shell 脚本:正则表达式
  • 可调精密稳压器的原理
  • Altium Designer(AD)PCB打孔
  • React 状态管理
  • [Spring Cloud][5] 注册中心详解,CAP 理论,什么是 Eureka
  • 返利app的跨域问题解决方案:CORS与反向代理在前后端分离架构中的应用
  • C++算法题—图的邻接矩阵输入形式(I\O)
  • 主动性算法-如何让机器拥有嗅觉?
  • Knockout.js Google Closure Compiler 工具模块详解
  • 从关键词匹配到语义理解:6大Embedding技术如何重塑企业搜索
  • 【面试实录01】
  • Docker 容器化部署核心实战——镜像仓库管理与容器多参数运行详解
  • Jenkins的安装与简单使用
  • Step-by-Step:用C语言构建一个带精准错误提示的括号匹配器
  • 【LeetCode - 每日1题】元音拼写检查器
  • KingbaseES读写分离集群架构解析