RESTful API

一种常见的误解是认为REST是独立于HTTP的, 通常出现在为RESTful API辩解的场合.
但这种见解是完全错误的, 任何一个读过REST论文的人都知道REST是一种强烈依赖HTTP特性的架构,
根本无法与HTTP分离.
此外,市面上也根本没有不使用HTTP的RESTful API.

RESTful API的推广更像是因为大企业使用RESTful API而产生的跟风现象.
此外, 由于RESTful API的复杂性创造出了新的岗位, 行业内产生了大量的利益关系, 以至于很多业内人士也缺乏推翻它的动力.

由于REST强烈依赖HTTP特性, REST根本称不上是一种简单和容易掌握的东西, 指望通过REST来降低学习成本是非常不切实际的.
因为这个原因, RESTful API也几乎不比任何RPC协议简单, 设计API的难度更是远远大于使用它的难度.

REST架构的主要优势来自于HTTP可以被中间人攻击,以下来自REST论文原文:

使得HTTP与RPC存在重大不同的是: 请求是使用具有标准语义的通用的接口定向到资源的, 这些语义能够被中间组件和提供服务的来源机器进行解释. 结果是使得一个应用支持分层的转换(layers of transformation)和间接层(indirection), 并且独立于消息的来源, 这对于一个Internet规模、多个组织、无法控制的可伸缩性的信息系统来说, 是非常有用的. 与之相比较, RPC的机制是根据语言的API(language API)来定义的, 而不是根据基于网络的应用来定义的.

然而,这个优势在API场景下几乎没有意义,因为API响应极少被缓存,被中间人修改的情况反而越少越好。

举例来说,客户端本质上只需要向服务器发出如下格式的RPC请求:

{
"method": "isRestfulApiSucks"
, "params": []
}

服务端本质上只需要向客户端返回如下格式的RPC响应:

// 成功响应
{
"result": true
}
// 错误响应
{
"error": "The server cannot return a result that does not violate its intentions."
}
// 或者使用约定好的自定义状态码:
{
"error": 2000
}

以上设计可以用于现实世界中99%的API, 即使是长时运行的异步操作也可以通过轮询设计出来.
剩下的1%则需要有效率地传输二进制数据, 只需为此专门添加一个HTTP API接口就行了.

该RPC设计事实上是JSON-RPC协议的简单版本, 同时也是JSON-RPC协议的全部精华, 实际的JSON-RPC 2.0反而不如上述设计.

RESTful API本质上是RPC, 但它的接口是符合REST架构的HTTP API, 这导致了很多问题.

如果你严格遵守REST, 在任何超越基本CRUD动词的需求下, 设计API都会变得相当困难.
杀死大量脑细胞后, 设计者或许可以设计出一个看起来很清晰RESTful API,
但它要么依赖于灵感的迸发以至于设计成本不可预估, 要么已经扭曲了编程语言里最初设计的调用方式,
最有可能的情况是二者皆有.

由于RESTful API强制要求所有端点为"资源", 因此所有操作都只是CRUD里的一种, 但现实世界中有很多不能用CRUD或资源进行表达的例子.
任何一个超出CRUD目标的项目, 使用REST的体验都不会比RPC好, 将超纲的操作扭曲成REST语义很难, 即使成功, 通常可读性也较差.

204状态码存在此问题:
HEAD方法被设计成返回200而不是204, 204的说明文本No Content是有误导性的.

原因在于设计204的出发点在于阻止采用"用户代理"的行为, 例如204状态码是用来防止浏览器刷新页面的.
当我们使用HTTP API时, 用户代理是不存在的, 因此204状态码实际上失去了它原本的语义.

建议采用的语义: 只根据响应是否有正文决定是否使用204.

几乎所有RESTful API实现都使用状态码, 但几乎所有实现都需要使用400状态码加上一个包含message字段的JSON正文.

401和404是另外两个相当常见的HTTP状态码, 对浏览器这样的用户界面来说很有用, 但对RESTful API几乎无用.
RESTful API的SDK实现反而需要把状态码映射回异常, 最后你会发现API最好将所有错误都用400状态码来表示, 以维持错误处理方式的统一性.
以这个逻辑继续推理下去, 很容易意识到错误结果和非错误结果本质上都是服务器成功响应给客户端的结果, 因此其实都可以用200状态码表示.
既然所有结果都可以以200状态码表示, 那么实际上就根本不需要状态码.

RESTful API拆分动词和资源端点的做法缺乏实用性, 十分有限的动词经常无法满足需求.
为了映射为RESTful API, 牺牲了太多可读性和简单性.

例子:

  • logIn 变成了 POST /session
  • =logOut=变成了 DELETE /session
  • enqueue 变成了 POST /queue
  • dequeue 变成了 GET /queue

为了降低设计负担, 很多RESTful API都会在URL里创建自定义动词, 从而将API降级成一个部分符合REST语义的HTTP API.
然而这种半吊子的HTTP API仍然会在后续设计中制造成本, 因为设计者多半仍然会毫无必要地试图让API贴近REST的方法, headers, 状态码等约定.
大多数公开的所谓RESTful API都只是类REST的HTTP API.

RESTful API把信息分散到url, headers, body, status code带来的认知负担比把信息统一到body要大得多, 而且事实上没有任何意义.

在一些REST API实现里, 事务被当作一种资源进行建模, 以绕过REST的无状态特性实现两阶段提交.

HATEOAS指的是在数据中包含超链接, 这使得REST API在一定程度上具有灵活性而不需要修改API本身.

返回URL指针也可能增加复杂性, 因为客户端很可能不知道相关URL的知识.
而如果客户端已经知道这些知识, 则返回URL又是没必要的.

尽管REST API可以用文字描述作为文档, 但使用OpenAPI规范定义由JSON Schema结构化文档会更好.

在URL中添加 v0, v1, v2 这样的版本号.

客户端可以通过HTTP请求头包含版本.

Accept-Version: v1
Accept-Version: v2

Accept: application/vnd.example.v1+json
Accept: application/vnd.example+json;version=1.0

现代REST API通常使用JSON作为交换格式, 编码为 UTF-8.

请求必须带有以下头

  • =Accept: application/json; charset=utf-8=
  • =Content-Type: application/json; charset=utf-8=

响应必须带有以下头

  • =Content-Type: application/json; charset=utf-8=
  • /版本/集合[?查询参数]
  • /版本/集合/实体
  • /版本/集合/实体/关联的集合[?查询参数]

[] 表示可选, 中括号 [] 不是语法单元.
在表示版本, 集合, 实体时, 应统一使用连字符 - 连接多个单词, 禁止使用大写字符.
禁止在集合, 实体, 查询参数中使用未经转义的语法符号: 分隔符 /, 问号 ?.
URL 使用禁止动词, 禁止使用扩展名.

禁止在 URL 末尾添加 /.

集合[?查询参数]
例子: /users, /articles

表示一种资源的集合, 用来描述实体以外的对象, 请求集合时, 返回的结果类型应为数组.
集合应使用名词的复数形式.

支持的方法 含义
GET 获取集合中的所有实体, 可根据查询参数缩小范围
POST 向集合中添加新的实体
DELETE 清空集合中的所有实体, 可根据查询参数缩小范围

查询参数用于查询集合的子集, 语法与 HTTP 的常见查询字符串语法相同, 参数通过等号赋值,
不同的参数通过 & 连接.
例子: /users?online=true

/集合/实体标识符
例子: /users/<uid>, /articles/<id>

表示资源的特定实体, 实体标识符在当前集合中具有唯一性.

支持的方法 含义
GET 获取实体的数据
PUT 确保以UUID或Hash为标识符的实体存在, 以Hash为标识符的实体的集合可能没有 POST 动作
PATCH 部分更新实体的数据/执行RPC操作
DELETE 从集合中删除该实体

/集合/实体标识符/关联的集合

当表示的关系是多对多时, 会出现冗余的 URL, 因此会出现一个功能拥有多个 URL 的情况, 例如:

  • POST /users/<uid>/groups 添加用户的组, 会同步反应到"组里的用户"上
  • POST /groups/<gid>/users 添加组里的用户, 会同步反应到"用户的组"上

只有当"关系"本身具有额外的属性时, 才应该为"关系"单独建立资源, 例如: /memberships/<uuid>

支持的方法 含义
GET 获取所有属性标识符, 也可以通过 GET 实体获得
POST 向该集合添加新的关系资源
DELETE 清空此集合
DELETE + 查询参数 删除此集合中符合条件的项目

在实体标识符之后, 由前一个实体限定其上下文的实体, 这可以被视作一对一关系.
/集合/实体标识符/限定上下文中的实体
例子: /users/<uid>/profile
与一般实体的不同之处在于, 限定上下文中的实体没有实体标识符, 因为它不是任何集合的一部分.

人们可能认为可以通过为该类实体创建单独的资源集合来简化 URL, 例如 /profiles/<uid>.
但当这样的实体需要多个限定上下文的实体标识符时, 这种简化将无法成立, 因此应当选择更符合直觉的方案.

支持的方法 含义
GET 获取实体的数据
PUT 确保以UUID为标识符的实体存在
PATCH 部分更新实体的数据/执行RPC操作
DELETE 从集合中删除该实体

跟在实体标识符之后, 由前一个实体限定其上下文的集合, 这可以被视作一对多关系.
/集合/实体标识符/限定上下文的集合[?查询参数]
/集合/实体标识符/限定上下文的集合/限定上下文的实体标识符
例子: /users/<uid>/messages/<id>
与一般集合的不同之处在于, 限定上下文中的集合的实体标识符仅在限定上下文中具有唯一性.

支持的方法 含义
GET 获取集合中的所有实体, 可根据查询参数缩小范围
POST 向集合中添加新的实体
DELETE 清空集合中的所有实体, 可根据查询参数缩小范围
属性
安全
幂等
携带载荷 禁止
查询参数 允许

表示 访问资源 的语义, 在访问集合时返回结果必须为数组(即使数组为空).

状态码 语义
200 OK 访问资源成功, 服务器必须返回该资源的详细信息.
304 Not Modified 表示目标资源自上次访问后没有修改, 客户端可继续使用缓存的资源.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 访问目标资源失败, 失败原因为服务器拒绝访问此资源, 服务器必须返回关于该错误的详细信息.
404 Not Found 表示目标资源不存在.
属性
安全
幂等
携带载荷 禁止
查询参数 允许

表示GET方法的全部语义, 但响应不包含内容正文, 只包含响应头.
用于判断一个资源是否存在或用于获取目标资源的元数据.

状态码 语义
204 No Content 表示目标资源存在.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 访问目标资源失败, 失败原因为服务器拒绝访问此资源, 服务器必须返回关于该错误的详细信息.
404 Not Found 表示目标资源不存在.
属性
安全
幂等
携带载荷 允许
查询参数 禁止

表示 创建资源追加资源 的语义, 端点为集合时表示在目标集合下创建新的实体.
如果实体带有实体标识符, 且实体不存在, 则会创建实体, 如果实体已存在, 则会返回 403 错误.

状态码 语义
201 Created 创建资源成功, 服务器必须返回创建后的实体, 并且必须用头信息的 Location 字段返回创建的资源 URL 地址.
202 Accepted 服务器已经收到此创建资源请求, 但不能保证该实体会被创建, 通常用于异步任务的添加.
204 No Content 创建资源成功, 但服务器不会返回创建的实体.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 创建资源失败, 失败原因为服务器拒绝创建此资源, 服务器必须返回关于该错误的详细信息.
属性
安全
幂等
携带载荷 允许
查询参数 禁止

表示 替换资源 的语义, 端点为实体时表示替换该实体, 端点为集合时表示替换该集合内的所有实体.
当实体不存在时, PUT 会导致新实体被创建.
在调用 PUT 方法时, 必须包含完整的资源内容,
不存在于发送的资源内容里的成员, 在语义中应当在服务器端被移除,
(通常情况下为了不破坏数据库结构, 服务器应选择返回 403 错误, 并说明缺失的字段).

在一些实现中, PUT会被作为POST的具有幂等性的替代品.
然而在现实世界中, 绝大多数情况下, 人们不会向实体发出POST请求, 人们不会向集合发出PUT请求, 因此两者的功能是互补而非交错的.
此外, 如果PATCH不用于扩展REST动词, 则具有完整字段的PATCH也可以替代PUT.

为PUT方法定义的状态表, 客户端和服务器端必须对以下的状态进行处理:

状态码 语义
200 OK 更新目标资源成功, 服务器必须返回更新后的实体.
202 Accepted 服务器已经收到此更新资源请求, 但不能保证该实体会被更新, 通常用于异步任务的添加.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 更新目标资源失败, 失败原因为服务器拒绝更新此资源, 服务器必须返回关于该错误的详细信息.
404 Not Found 更新目标资源失败, 失败原因为目标资源不存在.
423 Locked 更新目标资源失败, 失败原因为目标资源处于锁定状态, 服务器可选的返回关于该错误的详细信息.
属性
安全
幂等 在HTTP定义中为否, 在此规范中为是
携带载荷 允许
查询参数 禁止

在端点为实体时, 表示 更新部分资源, 修改部分资源 的语义.
PATCH不能也不应该像PUT那样有创建新资源的能力,
因为它只载有部分字段, 因此它必须也只能建立在已存在的内容之上.

PATCH其实不属于REST, 它是在2010年的RFC 5789中被制订的.
但经常被用于REST, 这是因为REST预定义的方法不够用.

在一些实现中, PATCH被用作定义类似RPC的动词操作, 在payload里使用op表示具体动作.
例如 PATCH /entity/<id> { op:replace }
这破坏了REST的纯洁性, 因为这样的API引入了RPC.
PATCH的这种使用方法在2013年的RFC 6902 JSON Patch中被明确定义.

在另一些实现中, PATCH被用作PUT的部分形式, 因此可以替代PUT, 但无法像RFC 6902那样实现数组子项的更新.
PATCH的这种使用方法在2014年的JSON Merge Patch中被明确定义.

状态码 语义
200 OK 更新目标资源成功, 服务器必须返回更新后的完整实体.
202 Accepted 服务器已经收到此更新资源请求, 但不能保证该实体会被更新, 通常用于异步任务的添加.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 更新目标资源失败, 失败原因为服务器拒绝更新此资源, 服务器必须返回关于该错误的详细信息.
404 Not Found 更新目标资源失败, 失败原因为目标资源不存在.
423 Locked 更新目标资源失败, 失败原因为目标资源处于锁定状态, 服务器可选的返回关于该错误的详细信息.
属性
安全
幂等
携带载荷 禁止
查询参数 允许

表示 删除资源 的语义.
DELETE的payload应该总是空的.

为 DELETE 方法定义的状态表, 客户端和服务器端必须对以下的状态进行处理:

状态码 语义
202 Accepted 服务器已经收到此删除资源请求, 但不能保证该实体会被删除, 通常用于异步任务的添加.
204 No Content 删除目标资源成功, 服务器不会返回任何内容.
400 Bad Request 出现了无法通过其他状态码描述的客户端错误, 服务器必须返回关于该错误的详细信息.
403 Forbidden 删除目标资源失败, 失败原因为服务器拒绝删除此资源, 服务器必须返回关于该错误的详细信息.
404 Not Found 删除目标资源失败, 失败原因为目标资源不存在.
423 Locked 删除目标资源失败, 失败原因为目标资源处于锁定状态, 服务器可选的返回关于该错误的详细信息.

这是一个没有标准答案的问题.

一种观点认为, DELETE的幂等性要求在删除不存在的实体时应该返回204, 这样重复的请求都会得到一样的结果.

相反的观点认为, 如果GET会返回404, 而DELETE却总是返回204, 这是十分奇怪的, 因此应该返回404.
持有这种观点的人还认为幂等性只与服务器上的状态有关, 因此两次相同请求得到的HTTP状态码不一致是很正常的.

对于需要返回错误信息的情况, 应该以如下的格式返回 JSON 数据, 其中 error_code、error_message 字段是可选的:

{
"error": "错误信息",
"error_code": "标识错误类型",
"error_message": "可用于显示给用户的错误信息"
}