API 设计最佳实践——RESTful 设计中10个常被忽视的细节
API 设计最佳实践——RESTful 设计中10个常被忽视的细节
适读人群:做后端接口设计的工程师、关注 API 质量的技术 TL | 阅读时长:约15分钟 | 核心价值:RESTful 不只是 URL 里用名词,这10个细节决定了你的 API 是否真的好用
先说一件让我头疼的事
我们团队有段时间做了一批内部 API,供移动端和 Web 前端调用。上线后,前端同学过来找我,说这个接口用起来"很别扭"。
我问哪里别扭,他说:
- 创建订单用
POST /createOrder,删除订单用POST /deleteOrder,为什么不是DELETE /orders/{id}? - 接口出错了,HTTP 状态码一律返回 200,错误信息在 body 里,每次都要先解析 body 才知道成不成功。
- 有个分页接口,有时候返回
page字段,有时候返回pageIndex,名字不统一。 - 有的接口返回的列表字段叫
data,有的叫list,有的叫result。
这些问题每一个单独看都不大,合在一起,就让接口变成了一个"需要专门查文档才能用"的东西,完全没有一致性和可预测性可言。
好的 API 应该是"有预测性的"——你第一次看到一个接口,就能猜到它的行为。
细节一:URL 用名词,操作用 HTTP 动词
这是 RESTful 最基础的原则,但很多项目还是没做对。
// 不好的设计(动词 URL)
POST /createOrder
POST /deleteOrder
POST /updateOrderStatus
POST /getOrderList
// 好的设计(名词 URL + HTTP 动词)
POST /orders // 创建订单
DELETE /orders/{id} // 删除订单
PATCH /orders/{id}/status // 更新订单状态
GET /orders // 获取订单列表操作含义用 HTTP 方法表达:GET(读取)、POST(创建)、PUT(完整替换)、PATCH(部分更新)、DELETE(删除)。
细节二:HTTP 状态码要用对
常见的规范用法:
200 OK - 请求成功,有响应体
201 Created - 创建成功,Location header 指向新资源
204 No Content - 成功,无响应体(DELETE 成功时)
400 Bad Request - 客户端传参有问题
401 Unauthorized - 未认证(没有 token 或 token 无效)
403 Forbidden - 已认证但无权限
404 Not Found - 资源不存在
409 Conflict - 资源冲突(如重复创建)
422 Unprocessable Entity - 参数格式正确但业务校验失败
429 Too Many Requests - 请求频率超限
500 Internal Server Error - 服务端错误最常见的问题是:所有错误都返回 400,甚至连服务端异常也返回 400。这让客户端无法区分"是我传错了参数"还是"服务端出 Bug 了"。
细节三:错误响应要有统一结构
// 不好的:每个接口错误格式不一样
{"msg": "用户不存在"}
{"message": "Invalid param", "code": 10001}
{"error": true, "errorMsg": "系统异常"}
// 好的:统一的错误响应格式
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"details": [
{"field": "userId", "issue": "指定的用户 ID 123 不存在"}
],
"traceId": "abc-123-xyz" // 便于日志追踪
}关键字段:
code:业务错误码(字符串比数字更有可读性)message:面向用户的可读错误信息details:可选,具体哪个字段有什么问题(表单校验错误时特别有用)traceId:追踪 ID,方便用户反馈问题时定位日志
细节四:分页参数和响应要统一
分页是最容易出现不一致的地方。
// 统一的分页请求参数
GET /orders?page=1&pageSize=20&sort=createTime,desc
// 统一的分页响应结构
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 156,
"totalPages": 8
}
}注意:page 从 1 开始(面向用户)还是从 0 开始(面向开发者)要明确规定,全系统一致,不能混用。
细节五:日期时间用 ISO 8601 格式
// 不好的:自定义格式,时区不明确
{"createTime": "2024-01-15 10:30:00"}
{"createTime": 1705283400000} // 时间戳,可读性差
// 好的:ISO 8601,带时区
{"createTime": "2024-01-15T10:30:00+08:00"}
// 或者统一用 UTC
{"createTime": "2024-01-15T02:30:00Z"}细节六:幂等性要明确设计
PUT/DELETE 应该是幂等的,多次调用结果相同。
POST 创建资源通常不是幂等的,但可以通过客户端提供幂等 Key 来实现幂等:
POST /orders
Idempotency-Key: abc-123-unique-key
// 用相同的 Idempotency-Key 重复请求,服务端返回相同结果
// 不会创建两个订单实现方式:
@PostMapping("/orders")
public ResponseEntity<OrderVO> createOrder(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
@RequestBody CreateOrderRequest request
) {
if (idempotencyKey != null) {
// 检查是否已处理过这个 Key
Optional<OrderVO> cached = idempotencyCache.get(idempotencyKey);
if (cached.isPresent()) {
return ResponseEntity.ok(cached.get()); // 直接返回缓存结果
}
}
OrderVO result = orderService.create(request);
if (idempotencyKey != null) {
idempotencyCache.put(idempotencyKey, result, Duration.ofHours(24));
}
return ResponseEntity.status(201).body(result);
}细节七:版本管理要从第一天开始
API 一旦发布就很难改。从第一天开始版本化:
// 方式 A:URL 里加版本(最常见,最直观)
GET /v1/orders
GET /v2/orders
// 方式 B:Header 里加版本
GET /orders
Accept: application/vnd.myapp.v2+json
// 方式 C:查询参数
GET /orders?version=2我倾向于 URL 版本化,最简单直观,对前端也最友好。
细节八:不要在 URL 里暴露数据库 ID
// 不好的:直接暴露数据库自增 ID
GET /orders/12345
// 这有什么问题?
// 1. 用户可以猜测其他订单的 ID(安全问题)
// 2. 迁移数据库或分库后 ID 会变(稳定性问题)
// 好的:用业务 ID 或 UUID
GET /orders/ORD-20240115-ABCD1234 // 业务 ID
GET /orders/550e8400-e29b-41d4-a716-446655440000 // UUID细节九:响应中的字段命名要统一
驼峰(camelCase)还是下划线(snake_case)都可以,但全系统必须统一。
Java 后端默认是驼峰,如果前端是 JavaScript,也习惯驼峰,统一用驼峰最省事:
// camelCase(推荐 Java + JS 组合)
{
"orderId": "123",
"createTime": "2024-01-15T10:30:00Z",
"totalAmount": 299.00
}用 Jackson 的配置可以全局统一:
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return Jackson2ObjectMapperBuilder.json()
.propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE)
.build();
}
}细节十:null 字段的处理策略要明确
接口返回 null 字段有两种处理方式,哪种都可以,但要全系统统一:
方式 A:null 字段不出现在响应里(减小响应体积)
// cancelTime 为 null 时,不包含这个字段
{
"orderId": "123",
"status": "PENDING"
}方式 B:null 字段以 null 出现(让客户端知道这个字段存在,只是没有值)
{
"orderId": "123",
"status": "PENDING",
"cancelTime": null
}我倾向于方式 B,因为让客户端的类型定义更明确(它能看到所有可能的字段)。
Jackson 配置:
// 序列化时包含 null 字段
mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); // 方式 B
// 或者
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 方式 A踩坑记录
踩坑一:PATCH 和 PUT 用混了
PUT 是完整替换资源,必须传所有字段。PATCH 是部分更新,只传要改的字段。
我们曾经用 PATCH 更新用户信息,但接口实现是"把传入的所有字段都更新"——这是对的。但前端同学以为 PATCH 也要传所有字段(和 PUT 一样),结果有次只传了 phone 字段,服务端把其他字段全清空了。
教训: PATCH 的接口文档必须明确说明"只传要修改的字段,未传的字段保持不变",而且服务端实现必须严格执行这个语义。
踩坑二:List API 没有总数返回,前端分页做不了
有个接口返回商品列表,只返回数据,没有返回 total。前端说"分页做不了,不知道一共有多少页"。
教训: 任何分页接口,必须返回 total(总记录数),即使这个 total 是估算值(ES 的大数据集场景)。
踩坑三:接口改了但版本没升,客户端崩了
我们上线了一个接口改动:某个字段从 string 改成了 object。虽然新版本更合理,但老的客户端没有更新,用老代码解析新格式,全部报 JSON 解析错误。
教训: 破坏性变更(字段类型变化、字段删除、含义变更)必须新开一个版本,不能在原版本上改。非破坏性变更(新增字段)在原版本上加可以。
API 设计没有绝对的对错,但有明显的好坏。好的 API 是"一致的、可预测的、自文档化的",让使用方不用翻文档就能猜到怎么用。
