Skip to content

RESTful 规范

RESTful 是目前最流行的 API 设计规范,用于 Web 数据接口的设计。

在 2000 年,Roy Fielding 提议使用表现层状态转换 (英语:Representational State Transfer,缩写:REST) 作为设计 Web 服务的体系性方法。REST 是一种基于超媒体构建分布式系统的架构风格。REST 独立于任何基础协议,并且不一定绑定到 HTTP。但是,最常见的 REST 实现使用 HTTP 作为应用程序协议。

基于 HTTP 的 REST 的主要优势在于它使用开放标准,不会绑定 API 的实现,也不会将客户端应用程序绑定到任何具体实现。例如,可以使用 ASP.NET 或者 Node.js 编写 REST Web 服务,而客户端应用程序能够使用任何语言或工具来发起 HTTP 请求和分析 HTTP 响应。

它的大原则容易把握,但是细节不容易做对。我们必须进行较多的工作来实施 REST API 中的最佳实践。大多数情况下,懒惰或缺乏时间意味着我们不会付出努力,如此为我们的用户留下一个个古怪的、难用的却又脆弱的 API。

本文总结 RESTful 的设计细节,介绍如何设计出易于理解和使用的 API。

下面是与 REST API 相关的非常重要的一些术语:

  • 资源 - 资源是某个事物的对象或表示形式,它与某事物有一些关联的数据,和可以对其进行操作的方法集。例如,用户、商品和订单是资源,添加、查询、更新、删除是要对这些资源执行的操作。
  • 集合 - 集合是指资源的集合,例如,products 是 product 资源的集合。
  • URI - 统一资源标识符(Uniform Resource Identifier)是一个用于标识某一互联网资源名称的字符串,可以通过它定位资源,并对其执行某些操作。
  • 端点 - 端点(Endpoint)是动词和 URI 的组合。例如:GET /products
  • 状态码 - 一个响应的状态由其状态代码(HTTP Status Code)指定。

二、URL 设计

2.1 一般约定

  • URI 都用小写,路径中连字符使用破折号“-”;
  • 使用 JSON 通信;
  • API 带版本控制;
  • 使用 JWT 令牌进行鉴权;

2.2 动词 + 宾语

RESTful 的核心思想就是,客户端发出的数据操作指令都是"动词 + 宾语"的结构。比如,GET /products 这个命令,GET 是动词,/products 是宾语。

动词通常就是如下五种 HTTP 方法,对应 CRUD 操作。

  • GET - 检索位于指定 URI 处的资源的表示形式。响应消息的正文包含所请求资源的详细信息。
  • POST - 在指定的 URI 处创建新资源。请求消息的正文将提供新资源的详细信息。请注意,POST 还用于触发不实际创建资源的操作,如登录、注销等。
  • PUT - 在指定的 URI 处创建或替换资源。请求消息的正文指定要创建或更新的资源。
  • PATCH - 对资源执行部分更新。请求正文包含要应用到资源的一组更改。
  • DELETE - 删除位于指定 URI 处的资源。

根据 HTTP 规范,动词一律大写。

特定请求的影响应取决于资源是集合还是单个项。下表汇总了使用电子商务示例的大多数 RESTful 实现所采用的常见约定。

资源POSTGETPUTDELETE
/products创建新商品检索所有商品批量更新商品删除所有商品
/products/1N/A检索商品 1 的详细信息如果商品 1 存在,则更新其详细信息删除商品 1
/products/1/orders创建商品 1 的新订单检索商品 1 的所有订单批量更新商品 1 的订单删除商品 1 的所有订单

2.3 动词的覆盖

有些客户端只能使用 GETPOST 这两种方法。服务器必须接受 POST 模拟其它三个方法(PUTPATCHDELETE)。

这时,客户端发出的 HTTP 请求,要加上 X-HTTP-Method-Override 属性,告诉服务器应该使用哪一个动词,覆盖 POST 方法。

POST /api/products/4 HTTP/1.1
X-HTTP-Method-Override: PUT

上面代码中,X-HTTP-Method-Override 指定本次请求的方法是 PUT,而不是 POST

2.4 宾语必须是名词

宾语就是 API 的 URL,是 HTTP 动词作用的对象。它应该是名词,不能是动词。比如,/products 这个 URL 就是正确的,而下面的 URL 不是名词,所以都是错误的。

GET /getAllProducts
GET /getProductById/1
POST /createNewProduct
POST /deleteAllProducts
POST /deleteProductById/1

2.5 使用复数做资源名称

既然 URL 是名词,那么应该使用复数,还是单数?

这没有统一的规定,但是常见的操作是读取一个集合,比如 GET /products(获取所有商品),这里明显应该是复数。

为了统一起见,建议都使用复数 URL,比如 GET /products/1 要好于 GET /product/1

2.6 避免多级 URL

常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个客户的某个订单。

GET /customers/1/orders/2

这种 URL 不利于扩展,语义也不明确,往往要想一会,才能明白含义。

更好的做法是,除了第一级,其它级别都用查询字符串表达。

GET /products?category_id=12

下面是另一个例子,查询已上架的商品。你可能会设计成下面的 URL。

GET /products/discontinued

其实使用下面的查询字符串的写法明显更好。

GET /products?discontinued=false

三、HTTP 状态码

3.1 始终使用精确的状态码

客户端的每一次请求,服务器都必须给出回应。回应包括 HTTP 状态码和数据两部分。

HTTP 状态码是一个三位数,分五个类别。

  • 1xx:Informational - 相关信息
  • 2xx:Success - 成功
  • 3xx:Redirection - 重定向
  • 4xx:Client Error - 客户端错误
  • 5xx:Server Error - 服务器错误

这五大类总共包含上百个状态码,覆盖了绝大部分可能遇到的情况。每一种状态码都有标准的(或者约定的)解释,客户端只需查看状态码,就可以判断出发生了什么情况,所以服务器应该始终返回尽可能精确的状态码。

API 不需要 1xx 状态码,下面介绍其它四类状态码的精确含义。

3.2 2xx 状态码

200 状态码表示操作成功,但是不同的方法可以返回更精确的状态码。

GET: 200 OK
POST: 201 Created
PUT: 200 OK
PATCH: 200 OK
DELETE: 204 No Content

上面代码中,

POST 返回 201 状态码,表示生成了新的资源;

DELETE 返回 204 状态码,表示资源已经不存在。

此外,202 Accepted 状态码表示服务器已经接受请求,但尚未进行处理,会在未来再处理,这个状态码被设计用来将请求交由另外一个进程或者服务器来进行处理,或者是对请求进行批处理的情形。

下面是一个例子。

HTTP/1.1 202 Accepted

{
  "task": {
    "href": "/api/jobs/12345",
    "id": "12345"
  }
}

有两种情况 202 Accepted 特别适合:

  • 如果资源将作为将来要处理的结果而创建 —— 例如在一个任务完成之后。
  • 如果资源已经以某种方式存在,但不应将其解释为错误。

3.3 3xx 状态码

API 用不到 301 状态码(永久重定向)和 302 状态码(暂时重定向,307 也是这个含义),因为它们可以由应用级别返回,浏览器会直接跳转,API 级别可以不考虑这两种情况。

API 用到的 3xx 状态码,主要是 303 See Other,表示参考另一个 URL。它与 302307 的含义一样,也是"暂时重定向",区别在于 302 307 用于 GET 请求,而 303 用于 POSTPUTDELETE 请求。收到 303 以后,浏览器不会自动跳转,而会让用户自己决定下一步怎么办。下面是一个例子。

HTTP/1.1 303 See Other
Location: /api/products/2

3.4 4xx 状态码

4xx 状态码表示客户端错误,适用于错误似乎是由客户端引起的情况。主要有下面几种。

  • 400 Bad Request:服务器无法理解客户端的请求,未做任何处理。当 4xx 没有适当的状态代码时,将使用此状态码。
  • 401 Unauthorized:用户未提供身份验证凭据,或者没有通过身份验证。
  • 403 Forbidden:用户通过了身份验证,但是不具有访问资源所需的权限。
  • 404 Not Found:所请求的资源不存在、不可用或不希望澄清其存在。
  • 405 Method Not Allowed:方法不允许,表示服务器明白请求中指定的的方法,但是目标资源不支持。
  • 406 Not Acceptable:用户请求的格式不可得(比如用户请求 JSON 格式,但是只有 XML 格式)。
  • 409 Conflict: 由于和被请求的资源的当前状态之间存在冲突而无法完成,用户被认为能够解决冲突,并且会重新提交新的请求。
  • 410 Gone:所请求的资源已从这个地址转移,不再可用。
  • 415 Unsupported Media Type:服务器无法处理请求消息的有效负载的 MIME 类型和内容编码,从而拒绝接受客户端的请求。
  • 422 Unprocessable Entity:请求的有效负载正文中包含的数据在语法上是正确的,但内容是推测性错误的,无法处理包含的内容。
  • 429 Too Many Requests:在一定的时间内客户端发送了太多的请求,即超出了“频次限制”。

3.5 5xx 状态码

5xx 状态码表示服务端错误,即服务器未能满足请求。一般来说,API 不会向用户透露服务器的详细信息,所以只要两个状态码就够了。

  • 500 Internal Server Error:客户端请求有效,服务器处理时发生了意外。
  • 503 Service Unavailable:服务器尚未处于可以接受请求的状态。

四、服务器回应

4.1 不要返回纯本文

API 返回的数据格式,不应该是纯文本,而应该是一个 JSON 对象,因为这样才能返回标准的结构化数据。所以,服务器回应的 HTTP 头的 Content-Type 属性要设为 application/json

客户端请求时,也要明确告诉服务器,可以接受 JSON 格式,即请求的 HTTP 头的 ACCEPT 属性也要设成 application/json。下面是一个例子。

GET /orders/2 HTTP/1.1
Accept: application/json

所有接口响应值采用 json 格式, 如无特殊说明,每次请求的返回值中,都包含下面字段:

参数名称类型描述
codeNumber接口调用状态,200:正常,其他值:调用出错,返回码见 响应返回码
msgString结果说明,如果接口调用出错,那么返回错误描述,成功返回 ok
dataString接口返回结果,各个接口自定义

当响应返回码(code)不为 200 时, 表示请求未正常执行,返回码描述 (msg) 对该结果进行了细化补充,用户可根据返回码判断 API 的执行情况。 所有接口调用返回值均包含 code 和 msg 字段, code 为返回码值,msg 为返回码描述信息,返回码表如下:

4.2 发生错误时,不要返回 200 状态码

有一种不恰当的做法是,即使发生错误,也返回 200 状态码,把错误信息放在数据体里面,就像下面这样。

HTTP/1.1 200 OK
Content-Type: application/json
json
{
  "status": "failure",
  "data": {
    "error": "Expected at least two items in list."
  }
}

参数错误以数组形式返回,并附带用户友好的提示

json
{
  "code": 40000,
  "status": 400,
  "message": "参数错误",
  "data": {
    "errors": [
      {
        "field": "name",
        "message": "缺失"
      }
    ]
  }
}

上面代码中,只有在解析数据体之后,才能得知操作失败。

这样的做法实际上取消了状态码,其糟糕的语义是完全不可取的。正确的做法是,状态码应反映发生的错误,具体的错误信息放在数据体里面返回。下面是一个例子。

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Invalid payload.",
  "detail": {
     "surname": "This field is required."
  }
}

4.3 提供链接

API 的使用者未必知道 URL 是怎么设计的。一个解决方法就是,在回应中,给出相关链接,便于下一步操作。这样用户只要记住一个 URL,就可以发现其它的 URL。这种方法叫做 HATEOAS。

举例来说,GitHub 的 API 都在 api.github.com 这个域名。访问它,就可以得到其它 URL。

json
{
  "feeds_url": "https://api.github.com/feeds",
  "followers_url": "https://api.github.com/user/followers",
  "following_url": "https://api.github.com/user/following{/target}",
  "gists_url": "https://api.github.com/gists{/gist_id}",
  "hub_url": "https://api.github.com/hub"
}

上面的回应中,挑一个 URL 访问,又可以得到别的 URL。对于用户来说,不需要记住 URL 设计,只要从 api.github.com 一步步查找就可以了。

HATEOAS 的格式没有统一规定,上面例子中,GitHub 将它们与其它属性放在一起。更好的做法应该是,将相关链接与其它属性分开。

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "In progress",
   "links": {[
    { "rel":"cancel", "method": "delete", "href":"/api/status/1000" } ,
    { "rel":"edit", "method": "put", "href":"/api/status/1000" }
  ]}
}

五、版本控制

和其他所有应用一样,我们的 API 也需要迭代、更新功能,所以给我们的 API 制定版本十分重要。 这样做最大的优势是当我们在创建新功能的时候并不影响客户端继续运行旧版本。 我们并不强迫用户直接使用我们的新版本,用户可以继续使用旧的版本,直到新版本稳定后再迁移到新版本。 当下版本和新版本并行运行,互不干扰。

http://api.ourdomain.com/v1/products/1 是一个很好的例子,它在路径中具有 API 的版本号。如果有任何重大更新,我们可以将一组新的 API 命名为 v2 或 v2.x.x。

根据语义化版本控制规范,版本格式:主版号.次版号.修订号,版本号递增规则如下:

  1. 主版号:当你做了不兼容的 API 修改;
  2. 次版号:当你做了向下兼容的功能性新增;
  3. 修订号:当你做了向下兼容的问题修正。

六、查询语言

查询语言 允许客户端通过向服务器发送特定参数来精确地请求所需数据,从而提高 API 的灵活性和效率。

在 RESTful API 设计中,可以使用查询语言来实现以下功能:

分页 (Pagination)

当数据集太大时,我们必须将数据集划分为更小的块,这有助于提高性能并更容易处理响应。

例如,GET /users?page=1&size=20 表示请求第一页数据,每页包含 20 个用户。

排序 (Sorting)

如果客户想要获取商品的排序列表,端点 GET /products 应该在查询中接受排序参数,例如 GET /products?sort=rank&order=desc 会按照商品评级降序排列(descend 表示降序,为 ascend 表示升序)。

过滤 (Filtering)

若要筛选数据集,我们可以通过查询参数传递各种选项。例如 GET /products?category=books&discontinued=false 将过滤指定类别的已上架的商品列表数据。

设计查询语言时需要注意以下几点:

语法:需要定义一个清晰、易于理解的语法,以便客户端可以轻鬆地构建查询。

支持的操作符:需要支持常用的比较操作符 (例如 =, !=, >, <, >=, <=)、逻辑操作符 (例如 AND, OR, NOT) 以及其他特定操作符 (例如 LIKE, IN)。

数据类型:需要考虑不同数据类型的查询语法和支持的操作符。

安全性:需要防止恶意查询,例如注入攻击。

ts
import { count, like } from 'drizzle-orm'
import { toInt } from 'radash'
import { z } from 'zod'
import { template } from '~/server/database/schema'

const paginationSchema = z.object({
  page: z
    .string()
    .regex(/^\d+$/, 'Page must be a positive integer') // 确保是数字字符串
    .transform(val => toInt(val, 10)) // 转换为数字
    .refine(val => val > 0, 'Page must be greater than 0') // 确保值大于 0
    .default('1') // 默认值为 "1"
    .transform(val => Math.max(val, 1)), // 确保转换后的数字至少为 1
  pageSize: z
    .string()
    .regex(/^\d+$/, 'Limit must be a positive integer') // 确保是数字字符串
    .transform(val => toInt(val, 10)) // 转换为数字
    .refine(val => val > 0, 'Limit must be greater than 0') // 确保值大于 0
    .default('10') // 默认值为 "10"
    .transform(val => Math.min(val, 100)), // 确保每页最大不超过 100
  sort: z.string().optional(),
  order: z.string().optional().default('asc'),
})

export default defineEventHandler(async (event) => {
  const body = await getValidatedQuery(event, paginationSchema.parse)

  const templates = await useDrizzle().query.template.findMany({
    limit: body.pageSize,
    offset: (body.page - 1) * body.pageSize,
  })

  const total = await useDrizzle()
    .select({ total: count() })
    .from(template)

  return {
    list: templates,
    page: body.page,
    pageSize: body.pageSize,
    total: total[0].total,
  }
})

创建类接口

创建完成后直接返回 id

json
{
  "code": 200,
  "msg": "创建成功",
  "data": {
    "id": 1
  }
}

文件类接口

对于要使用到文件的接口应该先上传文件后拿到 id 再进行操作

统一提供单文件上传接口(/api/files),支持上传所有类型文件

javascript
const formData = new FormData()
formData.append('file', File)

axios.post('https://httpbin.org/api/files', formData)
json
{
  "code": 200,
  "msg": "上传成功",
  "data": {
    "id": "bb313c99",
    "url": "https://cdn.xxx.com/files/bb313c99.png",
    "name": "合同.pdf" // 原文件的名称
  }
}

响应的文件路径至少补全至根路径 多文件上传

统一提供多文件上传接口(/api/multiple-files),支持上传所有类型文件

javascript
const formData = new FormData()
formData.append('file', [File, File])

axios.post('https://httpbin.org/api/multiple-files', formData)
json
{
  "code": 200,
  "msg": "上传成功",
  "data": [
    {
      "id": "bb313c99",
      "url": "/files/bb313c99.pdf",
      "name": "合同1.pdf" // 原文件的名称
    },
    {
      "id": "bb313c88",
      "url": "/files/bb313c88.pdf",
      "name": "合同2.pdf" // 原文件的名称
    }
  ]
}

图表类

曲线图、柱状图

json
{
  "code": 200,
  "msg": "请求成功",
  "data": {
    "x_axis": ["2022.04.20", "2022.04.21", "2022.04.22"],
    "series": [
      {
        "name": "上海用户",
        "data": [5000, 4000, 3000],
        "color": "#f5f5f5" // 可选,如果加上的话会使用该色值
      },
      {
        "name": "成都用户",
        "data": [3000, 4000, 5000], // 注意,没有数据时候也要使用 0 填充和 x_axis 一一对应
        "color": "#f5f5f5"
      }
    ]
  }
}

饼图

json
{
  "code": 200,
  "msg": "请求成功",
  "data": {
    "series": [
      {
        "name": "上线用户",
        "value": 1890,
        "color": "#f5f5f5" // 可选,如果加上的话会使用该色值
      },
      {
        "name": "下线用户",
        "value": 2000,
        "color": "#f5f5f5"
      }
    ]
  }
}