04-REST API设计规范
要点
- 前三篇讲了 HTTP 协议的 Request/Response 模型、Headers、Body、Status Code,这篇把这些要素组装成一套可用的 REST API
- REST 的核心约束:资源作为一等公民、统一接口、无状态
- URL 用名词复数,HTTP 方法表达操作语义,状态码表达结果语义
- AI 场景带来一些非典型 REST 端点(如
/chat/completions),但整体框架仍然适用 - 幂等性在 AI 请求中容易被忽略——同一个聊天请求重复提交需要防护
- 流式响应(SSE)不属于传统 REST,但可以通过合理的端点设计与 REST 共存
1. REST 的核心思想
REST(Representational State Transfer)是 Roy Fielding 在 2000 年的博士论文中提出的架构风格。它不是协议,也不是框架,而是一组约束条件。满足这些约束的系统被称为「RESTful」。
核心约束有六条:
| 约束 | 含义 |
|---|---|
| 资源(Resource) | 一切皆资源,用 URI 标识。/users/42 指向一个确定的资源 |
| 统一接口(Uniform Interface) | 客户端和服务端通过约定的接口风格交互,不依赖自定义协议 |
| 无状态(Stateless) | 每个请求自包含全部信息,服务端不在请求之间保存会话状态 |
| 分层系统(Layered System) | 客户端不需要知道中间有多少层代理、网关、负载均衡 |
| 缓存(Cache) | 可选。响应可以声明自己是否可缓存 |
| 按需代码(Code on Demand) | 可选。服务端可以下发可执行代码(如 JavaScript),实际很少用 |
对后端开发来说,前三条是最日常的约束。资源决定了 URL 怎么组织,统一接口决定了 HTTP 方法怎么选,无状态决定了认证信息怎么传递。
2. URL 设计
URL 是 REST 里最直观的部分。基本原则:URL 里放名词,不放动词。
# 好
GET /users
POST /users
GET /users/42
PATCH /users/42
DELETE /users/42
# 不好
GET /getUsers
POST /createUser
POST /deleteUser动词留给 HTTP 方法。URL 只描述「你在操作什么资源」。
几条具体规则:
复数形式保持一致。 /users 而不是 /user,即使只查一个。/users/42 里的 42 已经是标识符,不需要靠单复数区分。
层级关系表达从属。 用户的对话列表:
GET /users/42/conversations
GET /users/42/conversations/7层级的深度一般不超过两层。再深就该考虑是不是拆成独立资源了。
查询参数用于过滤、排序、分页:
GET /users?page=2&per_page=20
GET /users?sort=created_at&order=desc
GET /users?role=admin&status=activeAI 场景的非 CRUD 端点。 OpenAI 的 /chat/completions 不符合严格的资源模型——它更像一个「动作」。这类端点在 AI 后端中很常见,处理方式有两种:
- 接受它作为「操作端点」的特例。
/chat/completions、/embeddings、/images/generations都是业界已验证的模式。 - 如果资源模型能套,就套。比如把「对话」建模为资源:
POST /conversations/:id/messages。
两种做法可以共存。关键原则是:能用资源模型的地方用资源模型,不能硬套的地方不要硬套。
3. HTTP 方法与操作映射
五种常用方法和它们的操作语义:
| 方法 | 操作 | 语义 |
|---|---|---|
| GET | 读取 | 获取资源或集合 |
| POST | 创建 | 新建资源,或执行一个操作 |
| PUT | 全量更新 | 用请求体完整替换目标资源 |
| PATCH | 部分更新 | 只修改请求体中指定的字段 |
| DELETE | 删除 | 移除目标资源 |
一个容易混淆的点:PUT 和 PATCH 的区别。PUT 要求你传完整的资源表示——如果你只传了 {"name": "new"},意味着其他字段都被清空了。PATCH 只改你传的那些字段,其余保持不变。
实际项目中 PATCH 用得远比 PUT 多。前端很少会「我想把用户的所有字段都替换掉」,更多是「改个昵称」或「更新一下状态」。
4. 请求与响应设计
统一响应格式
API 的消费者(通常是前端)需要可预测的响应结构。一个常见的模式:
// 成功响应
{
"data": {
"id": 42,
"name": "白川",
"role": "admin"
}
}
// 列表响应
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"page": 1,
"per_page": 20,
"total": 156
}
}
// 错误响应(OpenAI 风格)
{
"error": {
"message": "Invalid API key provided",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}data 放业务数据,error 放错误信息,meta 放分页或元信息。三者不会同时出现——成功时有 data,失败时有 error。
错误格式参考 OpenAI 的风格有三个好处:(1)前端已经熟悉了;(2)type 字段可以做程序化处理;(3)code 字段可以映射到具体的错误码文档。
分页:cursor-based vs offset-based
两种分页方式:
# offset-based(传统)
GET /users?page=3&per_page=20
# cursor-based(游标)
GET /users?cursor=eyJpZCI6NjB9&limit=20offset-based 实现简单,但在数据频繁变动时会出现跳页或重复。cursor-based 用上一个结果的标识作为下一页的起点,天然回避这个问题。
AI 聊天场景推荐 cursor-based。原因很直接:聊天记录是不断追加的。用户翻历史消息时,如果用 page=5,中间新产生的消息会把第 5 页的内容挤掉。用 cursor 定位到某条消息的 ID,往后取 N 条,结果是稳定的。
版本控制
两种方式:
# URL 路径
GET /v1/users
GET /v2/users
# Header
GET /users
Api-Version: 2024-01-15URL 路径方式更直观,OpenAI、Stripe、Cloudflare 都采用这种方式。Header 方式更「干净」,但对调试工具不友好——你没法在浏览器地址栏里直接看到版本。
AI API 几乎都走 URL 路径。/v1/chat/completions 已经是事实标准。
5. 幂等性与安全性
两个重要概念:
- 安全性(Safe):方法不会改变服务端状态。GET 是安全的——你不应该用 GET 请求去删除数据或创建订单。
- 幂等性(Idempotent):同一个请求执行一次和执行多次,效果相同。
对照表:
| 方法 | 安全性 | 幂等性 |
|---|---|---|
| GET | ✅ | ✅ |
| POST | ❌ | ❌ |
| PUT | ❌ | ✅ |
| PATCH | ❌ | 取决于实现 |
| DELETE | ❌ | ✅ |
POST 既不安全也不幂等。这是它被选中用来「创建资源」的原因——每次调用都产生新资源。但这也意味着网络重试是危险的:如果 POST 请求超时了,你不知道服务端到底处没处理,重试可能创建两条记录。
解决方案是 幂等 key。客户端生成一个唯一标识(通常是 UUID),放在请求头里:
POST /users
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "name": "白川" }服务端收到这个 key 后,先查有没有处理过。如果处理过,直接返回上次的结果;如果没处理过,正常处理后把结果缓存起来。Stripe 的 API 就用了这个模式。
AI 场景的重复提交问题。 用户点了一次「发送」,网络卡顿,前端又发了一次。如果没有防护,同一句「帮我写一篇文章」会让后端调两次大模型,浪费 token 和时间。
处理方式跟幂等 key 一样:
// 前端生成请求 ID
const requestId = crypto.randomUUID()
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': requestId,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '帮我写一篇文章' }],
}),
})服务端用这个 ID 去重。在 Hono 里可以用中间件实现:
// middleware/idempotency.ts
import { Context, Next } from 'hono'
export async function idempotencyMiddleware(c: Context, next: Next) {
const key = c.req.header('Idempotency-Key')
if (key) {
// 查缓存/数据库,看这个 key 是否已经处理过
const cached = await c.env.IDEMPOTENCY_KV.get(`req:${key}`)
if (cached) {
return c.json(JSON.parse(cached), 200)
}
}
await next()
// 请求成功后,缓存结果
if (key && c.res.status === 200) {
const body = await c.res.clone().json()
await c.env.IDEMPOTENCY_KV.put(`req:${key}`, JSON.stringify(body), {
expirationTtl: 86400, // 24 小时过期
})
}
}6. 状态码的合理使用
前面一篇讲了 Status Code 的分类(2xx 成功、3xx 重定向、4xx 客户端错误、5xx 服务端错误)。在 REST API 里,每种操作对应一组常用状态码:
| 操作 | 成功 | 常用错误 |
|---|---|---|
| GET | 200 | 404(不存在)、403(无权限) |
| POST | 201(创建成功) | 400(参数错误)、409(冲突/重复) |
| PUT | 200 | 400、404 |
| PATCH | 200 | 400、404 |
| DELETE | 204(无内容) | 404 |
几个容易出错的地方:
201 vs 200。 POST 创建资源成功应该返回 201,不是 200。201 明确告诉客户端「你创建了一个新东西」,响应体的 Location 头指向新资源的 URL。
204 的使用场景。 DELETE 成功且不需要返回 body 时,用 204。PUT/PATCH 如果需要返回更新后的资源,用 200。
429 限流响应。 AI API 的限流尤其常见——模型调用有 QPM(Queries Per Minute)和 TPM(Tokens Per Minute)限制。429 响应需要带两个关键信息:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1719000060
{
"error": {
"message": "Rate limit exceeded. Please retry after 30 seconds.",
"type": "rate_limit_error",
"code": "rate_limit_exceeded"
}
}Retry-After 告诉客户端多久后可以重试。X-RateLimit-* 系列头让客户端可以主动做限流感知,不用等到 429 才反应。
7. AI 后端的 REST 设计特殊考量
传统 REST 模型在 AI 场景下会遇到一些边界情况。逐一说。
流式响应与 REST 的共存
SSE(Server-Sent Events)是 AI 聊天的标配——用户希望看到模型逐字输出,而不是等 30 秒才拿到完整回复。SSE 的技术本质是一个长连接,持续推送 text/event-stream 数据。
从 REST 角度看,SSE 端点打破了「一个请求对应一个完整响应」的假设。处理方式是在同一个 POST 端点上通过请求参数控制:
# 非流式
POST /v1/chat/completions
{ "stream": false }
# 流式
POST /v1/chat/completions
{ "stream": true }同一个 URL、同一个方法、同一个请求体结构,只是 stream 参数不同。响应的 Content-Type 也不同:stream: false 返回 application/json,stream: true 返回 text/event-stream。
这不是严格的 REST,但它是业界验证过的实用模式。OpenAI、Anthropic、Google 都这么做。
工具调用(Tool Use)的请求/响应格式
当模型需要调用外部工具时,请求和响应都会变复杂。请求里需要描述可用工具:
// 请求
{
"model": "gpt-4",
"messages": [
{ "role": "user", "content": "北京今天天气怎么样?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"]
}
}
}
]
}模型响应里会要求调用工具:
// 响应
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"北京\"}"
}
}]
}
}]
}客户端执行工具后,把结果作为 tool 角色的消息追加到对话中,再次请求模型。这个多轮交互完全发生在 REST 的请求/响应模型内,不需要额外的协议。
大请求体的处理
AI 请求经常包含长文本(RAG 场景下可能塞入几万 token 的上下文)或多模态内容(图片的 base64 编码)。这带来两个实际问题:
-
请求体大小限制。 Cloudflare Workers 默认限制请求体 100MB,但大多数 AI API 会在更小的层面限制(比如 OpenAI 限制单次请求的 token 总量)。服务端应该在中间件层做前置检查,不要等请求体完全解析完才发现超了。
-
传输耗时。 大 base64 编码的图片会让请求体膨胀 33%。如果前端需要频繁发送图片,考虑用 multipart/form-data 而不是 JSON,或者先上传图片到对象存储再传 URL。
异步任务:同步还是轮询
一次 AI 请求可能耗时 30 秒甚至更久(生成图片、处理长文档)。HTTP 连接在 30 秒后可能超时。
两种处理策略:
# 方案 A:同步等待(短任务)
POST /v1/chat/completions
→ 等待 5-15 秒 → 200 OK + 结果
# 方案 B:异步轮询(长任务)
POST /v1/images/generations
→ 202 Accepted + { "task_id": "xxx", "status": "processing" }
GET /v1/tasks/xxx
→ 200 OK + { "task_id": "xxx", "status": "completed", "result": {...} }判断标准很直接:如果任务能在 30 秒内完成,用同步 + SSE 流式。如果可能超过 30 秒,用异步轮询。
图片生成、批量文档处理、模型微调这类任务适合异步。普通聊天对话适合同步 + 流式。
8. 在 Hono 中组织 REST API
把前面的设计原则落地到 Hono 代码。一个 AI 聊天 API 的完整路由示例:
// routes/chat.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
type Bindings = {
OPENAI_API_KEY: string
IDEMPOTENCY_KV: KVNamespace
}
const chatSchema = z.object({
model: z.string(),
messages: z.array(z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
})),
stream: z.boolean().optional().default(false),
temperature: z.number().min(0).max(2).optional(),
max_tokens: z.number().positive().optional(),
})
const chat = new Hono<{ Bindings: Bindings }>()
// POST /v1/chat/completions
chat.post('/completions', zValidator('json', chatSchema), async (c) => {
const body = c.req.valid('json')
const idempotencyKey = c.req.header('Idempotency-Key')
// 幂等性检查
if (idempotencyKey) {
const cached = await c.env.IDEMPOTENCY_KV.get(`chat:${idempotencyKey}`)
if (cached) {
return c.json(JSON.parse(cached))
}
}
// 调用大模型 API
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
},
body: JSON.stringify(body),
})
if (!body.stream) {
// 非流式:直接返回 JSON
const result = await response.json()
// 缓存结果用于幂等
if (idempotencyKey) {
await c.env.IDEMPOTENCY_KV.put(
`chat:${idempotencyKey}`,
JSON.stringify(result),
{ expirationTtl: 86400 }
)
}
return c.json(result)
}
// 流式:透传 SSE
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
})
// GET /v1/chat/conversations/:id/messages
// 获取对话历史(cursor-based 分页)
chat.get('/conversations/:id/messages', async (c) => {
const conversationId = c.req.param('id')
const cursor = c.req.query('cursor')
const limit = Number(c.req.query('limit') ?? 20)
// 从数据库查询,cursor 指向某条消息的 ID
// ... 省略数据库查询逻辑
return c.json({
data: messages,
meta: {
next_cursor: messages.length === limit
? messages[messages.length - 1].id
: null,
},
})
})
export default chat主入口把路由挂载到版本前缀下:
// index.ts
import { Hono } from 'hono'
import chat from './routes/chat'
const app = new Hono()
// 版本路由
const v1 = new Hono()
v1.route('/chat', chat)
app.route('/v1', v1)
// 全局错误处理
app.onError((err, c) => {
console.error(err)
return c.json({
error: {
message: err.message ?? 'Internal server error',
type: 'server_error',
code: 'internal_error',
},
}, 500)
})
export default app这个结构做到了几件事:
- 路由按资源组织。
/v1/chat/completions处理聊天请求,/v1/chat/conversations/:id/messages处理历史查询。 - Zod 做请求验证。
zValidator中间件在请求到达路由之前就校验 body 结构,不合法的请求直接返回 400。 - 幂等性由中间件和 KV 配合实现。 同一个
Idempotency-Key不会重复调用大模型。 - 流式和非流式共用端点。 通过
stream参数控制响应格式。 - 统一的错误格式。
onError钩子把所有未捕获的异常包装成 OpenAI 风格的错误响应。
延伸阅读
总结
前三篇讲了 HTTP 协议的基础要素——Request/Response 模型、Headers、Body、Status Code。这篇把它们组装成 REST API 的设计规范:URL 用名词复数表达资源,HTTP 方法表达操作语义,状态码表达结果语义,统一响应格式让前端可以预测结构。
AI 场景给 REST 带来了一些边界情况——流式响应、工具调用、大请求体、长耗时任务。这些不是对 REST 的否定,而是在 REST 框架内做合理变通。stream: true 在同一个 POST 端点上切换响应格式,幂等 key 解决重复提交问题,异步轮询处理超时风险。
下一篇会进入 Hono 的路由系统,看它如何支持这些 REST 设计模式。