设计良好的 API 需要的考量点。本文从设计原则、URL 规范、HTTP 语义、状态码、版本控制、安全认证、请求响应格式、错误处理、Spring 框架实践等多个维度,系统阐述 REST API 的设计规范与最佳实践。
API 设计的一致性是全公司范围内接口可用性的基石。一致性不仅体现在命名和格式上,更体现在设计理念和行为约定上。
资源(实体)的划分是 REST API 设计的核心。以下设计考量点并没有绝对的优劣之分,但必须在组织内部事先统一约定:
| 考量点 | 选项 A | 选项 B | 说明 |
|---|---|---|---|
| 资源层级 | 最多一层扁平结构 | 允许多层嵌套 | 扁平结构更简单,嵌套结构更直观 |
| 资源粒度 | 粗粒度(聚合根) | 细粒度(独立实体) | 粗粒度减少请求次数,细粒度更灵活 |
| 权限粒度 | 与资源粒度一致 | 独立权限模型 | 一致时更简单,独立时更灵活 |
关键决策点:
/users/123/orders/456/items vs /items?userId=123&orderId=456在同一个组织或产品线内,所有 API 应遵循统一的设计风格:
/cancel、/approve)在确定的输入及上下文中,保证执行结果的可预测性。
特殊情形示例:账户关闭。从法律上讲,关户前确定的交易,依然有履约责任。因此即使账户已关闭,历史交易查询接口仍应返回有效数据。
确定性原则:
2023-03-23T00:00:00.000Z 和 2023-03-23T00:00:00.000+0000 两种格式不同但含义相同的序列化方式。/api/users ✅,/api/Users ❌- 而非下划线 _:/api/user-profiles ✅,/api/user_profiles ❌/api/orders ✅,/api/getOrders ❌/api/users ✅,/api/user ❌https://api.example.com/v1/users/{userId}/orders/{orderId}
\___/ \_____________/\_/\____/\______/\_____/\______/
| | | | | | |
协议 域名 版本 资源 资源ID 子资源 子资源ID
扁平结构(推荐):
GET /orders?userId=123&status=pending
GET /order-items?orderId=456
嵌套结构(谨慎使用):
GET /users/123/orders/456/items
嵌套层数建议不超过 2 层,过深的嵌套会导致 URL 冗长、缓存效率降低、权限控制复杂。
| Method | 语义 | 幂等性 | 安全性 | 典型用途 |
|---|---|---|---|---|
| GET | 获取资源 | ✅ 幂等 | ✅ 安全 | 查询数据 |
| POST | 创建资源 | ❌ 非幂等 | ❌ 不安全 | 创建新资源 |
| PUT | 全量更新 | ✅ 幂等 | ❌ 不安全 | 替换整个资源 |
| PATCH | 部分更新 | ❌ 非幂等 | ❌ 不安全 | 修改部分字段 |
| DELETE | 删除资源 | ✅ 幂等 | ❌ 不安全 | 删除资源 |
| HEAD | 获取元数据 | ✅ 幂等 | ✅ 安全 | 检查资源是否存在 |
| OPTIONS | 获取支持的方法 | ✅ 幂等 | ✅ 安全 | CORS 预检请求 |
对于不适合用标准 HTTP Method 表达的业务动作,有两种主流方案:
方案一:URL 动词(推荐用于简单动作)
POST /orders/{id}/cancel
POST /orders/{id}/approve
POST /orders/{id}/refund
方案二:状态机驱动(推荐用于复杂业务)
PATCH /orders/{id}
Body: { "status": "CANCELLED", "reason": "用户取消" }
选择建议:
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 OK | 请求成功 | GET、PUT、PATCH 成功 |
| 201 Created | 创建成功 | POST 创建资源成功 |
| 204 No Content | 成功但无返回体 | DELETE 成功、PUT 更新成功 |
| 400 Bad Request | 请求参数错误 | 参数校验失败、JSON 格式错误 |
| 401 Unauthorized | 未认证 | 缺少认证信息或认证失败 |
| 403 Forbidden | 无权限 | 认证通过但无权访问 |
| 404 Not Found | 资源不存在 | URL 错误或资源已删除 |
| 409 Conflict | 资源冲突 | 重复创建、并发修改冲突 |
| 422 Unprocessable Entity | 语义错误 | 业务规则校验失败 |
| 429 Too Many Requests | 限流 | 请求频率超过限制 |
| 500 Internal Server Error | 服务器错误 | 未预期的异常 |
| 502 Bad Gateway | 网关错误 | 下游服务不可用 |
| 503 Service Unavailable | 服务不可用 | 系统维护或过载 |
URL 路径版本(推荐):
/api/v1/users
/api/v2/users
请求头版本:
Accept: application/vnd.example.v1+json
查询参数版本(不推荐):
/api/users?version=2
Content-Type:
application/json:标准 JSON 请求application/x-www-form-urlencoded:表单提交(仅用于简单场景)multipart/form-data:文件上传请求体规范:
{
"fieldName": "value",
"nestedObject": {
"subField": "subValue"
},
"arrayField": ["item1", "item2"]
}
标准成功响应:
{
"id": "12345",
"name": "张三",
"email": "zhangsan@example.com",
"createdAt": "2023-03-23T08:00:00.000Z",
"updatedAt": "2023-03-23T08:00:00.000Z"
}
列表响应(带分页):
{
"data": [
{"id": "1", "name": "Item 1"},
{"id": "2", "name": "Item 2"}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 100,
"totalPages": 5
}
}
错误响应:
{
"error": {
"code": "INVALID_PARAMETER",
"message": "请求参数格式错误",
"details": [
{
"field": "email",
"issue": "格式不正确",
"suggestion": "请使用有效的邮箱地址"
}
],
"requestId": "req-202303230001",
"timestamp": "2023-03-23T08:00:00.000Z"
}
}
统一使用 ISO 8601 格式:
2023-03-23T08:00:00.000Z # UTC 时间(推荐)
2023-03-23T08:00:00.000+08:00 # 带时区偏移
严禁混用以下格式:
2023-03-23 08:00:00 # 无 T 分隔符
2023/03/23 08:00:00 # 斜杠分隔
1679558400000 # Unix 时间戳(毫秒)
| 风格 | 示例 | 适用场景 |
|---|---|---|
| camelCase | userName, createdAt |
JavaScript/TypeScript 项目 |
| snake_case | user_name, created_at |
Python/Ruby 项目 |
| PascalCase | UserName |
仅用于枚举值或类型名 |
原则:全公司统一一种风格,不要混用。
每个错误响应必须包含:
code:机器可读的错误码(用于程序处理)message:人类可读的错误描述(用于展示)requestId:请求唯一标识(用于排查)timestamp:错误发生时间可选字段:
details:详细错误信息(字段级校验错误)suggestion:修复建议documentation:相关文档链接分层错误码结构:
[模块][层级][序号]
示例:
AUTH001 - 认证模块,参数错误
AUTH002 - 认证模块,Token 过期
ORDER101 - 订单模块,库存不足
ORDER102 - 订单模块,价格变动
| 场景 | HTTP 状态码 | 错误码 | 处理建议 |
|---|---|---|---|
| 参数缺失 | 400 | INVALID_PARAMETER |
返回缺失字段列表 |
| 参数格式错误 | 400 | INVALID_FORMAT |
返回正确格式示例 |
| 认证失败 | 401 | AUTHENTICATION_FAILED |
引导重新登录 |
| 权限不足 | 403 | ACCESS_DENIED |
说明所需权限 |
| 资源不存在 | 404 | RESOURCE_NOT_FOUND |
提供相似资源建议 |
| 资源冲突 | 409 | RESOURCE_CONFLICT |
返回冲突详情 |
| 业务规则违反 | 422 | BUSINESS_RULE_VIOLATION |
说明违反的规则 |
| 限流 | 429 | RATE_LIMIT_EXCEEDED |
返回重试时间 |
| 服务内部错误 | 500 | INTERNAL_ERROR |
记录日志,返回 requestId |
| 方式 | 适用场景 | 说明 |
|---|---|---|
| API Key | 内部服务、低风险接口 | 简单但安全性较低 |
| OAuth 2.0 | 第三方接入、用户授权 | 行业标准,支持多种授权模式 |
| JWT | 微服务间调用、无状态认证 | 自包含,但无法主动失效 |
| mTLS | 高安全性要求 | 双向证书认证 |
Access-Control-Allow-Origin: https://trusted-domain.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
原则:
Access-Control-Allow-Origin: * 用于生产环境偏移量分页(推荐用于常规列表):
GET /api/users?page=1&pageSize=20
游标分页(推荐用于大数据量、实时数据):
GET /api/users?cursor=eyJpZCI6MTIzfQ==&limit=20
键集分页(推荐用于排序稳定的场景):
GET /api/users?lastId=123&limit=20
过滤:
GET /api/users?status=active&role=admin
GET /api/users?createdAt[gte]=2023-01-01&createdAt[lte]=2023-12-31
排序:
GET /api/users?sort=-createdAt,+name
# - 表示降序,+ 表示升序
字段选择:
GET /api/users?fields=id,name,email
在 Spring 框架中,使用具体的类型可以让框架帮我们完成更多的工作,减少样板代码,提高类型安全性。
fun call(@PathVariable("date") dateStr: String): ResponseEntity<String> {
val formatter = SimpleDateFormat("yyyyMMdd")
val date = formatter.parse(dateStr)
return ResponseEntity(date.toString(), HttpStatus.OK)
}
问题:
fun call(@PathVariable @DateTimeFormat(pattern = "yyyyMMdd") date: LocalDate): String {
return date.toString()
}
优势:
请求参数绑定:
@GetMapping("/users/{userId}")
fun getUser(
@PathVariable userId: UUID,
@RequestParam(required = false) includeDeleted: Boolean = false
): UserDTO {
return userService.findById(userId, includeDeleted)
}
请求体校验:
@PostMapping("/users")
fun createUser(
@RequestBody @Valid request: CreateUserRequest
): ResponseEntity<UserDTO> {
val user = userService.create(request)
return ResponseEntity.status(HttpStatus.CREATED).body(user)
}
分页参数:
@GetMapping("/users")
fun listUsers(
@PageableDefault(size = 20, sort = ["createdAt"], direction = Sort.Direction.DESC)
pageable: Pageable
): Page<UserDTO> {
return userService.findAll(pageable)
}
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationException(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val details = ex.bindingResult.fieldErrors.map {
FieldErrorDetail(it.field, it.defaultMessage ?: "Invalid value")
}
return ResponseEntity.badRequest().body(
ErrorResponse(
code = "INVALID_PARAMETER",
message = "请求参数校验失败",
details = details
)
)
}
@ExceptionHandler(BusinessException::class)
fun handleBusinessException(ex: BusinessException): ResponseEntity<ErrorResponse> {
return ResponseEntity.status(ex.statusCode).body(
ErrorResponse(
code = ex.errorCode,
message = ex.message ?: "业务处理失败"
)
)
}
}
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| 请求量 (QPS) | 每秒请求数 | 根据容量规划 |
| 响应时间 (P99) | 99% 分位响应时间 | > 500ms |
| 错误率 | 5xx 错误占比 | > 0.1% |
| 饱和度 | CPU/内存/连接池使用率 | > 80% |
requestId,便于全链路追踪