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=active

AI 场景的非 CRUD 端点。 OpenAI 的 /chat/completions 不符合严格的资源模型——它更像一个「动作」。这类端点在 AI 后端中很常见,处理方式有两种:

  1. 接受它作为「操作端点」的特例。/chat/completions/embeddings/images/generations 都是业界已验证的模式。
  2. 如果资源模型能套,就套。比如把「对话」建模为资源: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=20

offset-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-15

URL 路径方式更直观,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 里,每种操作对应一组常用状态码:

操作成功常用错误
GET200404(不存在)、403(无权限)
POST201(创建成功)400(参数错误)、409(冲突/重复)
PUT200400、404
PATCH200400、404
DELETE204(无内容)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/jsonstream: 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 编码)。这带来两个实际问题:

  1. 请求体大小限制。 Cloudflare Workers 默认限制请求体 100MB,但大多数 AI API 会在更小的层面限制(比如 OpenAI 限制单次请求的 token 总量)。服务端应该在中间件层做前置检查,不要等请求体完全解析完才发现超了。

  2. 传输耗时。 大 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

这个结构做到了几件事:

  1. 路由按资源组织。 /v1/chat/completions 处理聊天请求,/v1/chat/conversations/:id/messages 处理历史查询。
  2. Zod 做请求验证。 zValidator 中间件在请求到达路由之前就校验 body 结构,不合法的请求直接返回 400。
  3. 幂等性由中间件和 KV 配合实现。 同一个 Idempotency-Key 不会重复调用大模型。
  4. 流式和非流式共用端点。 通过 stream 参数控制响应格式。
  5. 统一的错误格式。 onError 钩子把所有未捕获的异常包装成 OpenAI 风格的错误响应。

延伸阅读

总结

前三篇讲了 HTTP 协议的基础要素——Request/Response 模型、Headers、Body、Status Code。这篇把它们组装成 REST API 的设计规范:URL 用名词复数表达资源,HTTP 方法表达操作语义,状态码表达结果语义,统一响应格式让前端可以预测结构。

AI 场景给 REST 带来了一些边界情况——流式响应、工具调用、大请求体、长耗时任务。这些不是对 REST 的否定,而是在 REST 框架内做合理变通。stream: true 在同一个 POST 端点上切换响应格式,幂等 key 解决重复提交问题,异步轮询处理超时风险。

下一篇会进入 Hono 的路由系统,看它如何支持这些 REST 设计模式。