restful最佳实践

restful最佳实践

restful 是目前最流行的 API 设计规范,用于 Web 数据接口的设计。它的大原则容易把握,但是细节不容易做对。

不要纠结于无意义的规范

在开始本文之前,我想先说这么一句:RESTful 真的很好,但它只是一种软件架构风格,过度纠结如何遵守规范只是徒增烦恼,也违背了使用它的初衷。

就像 Elasticsearch 的 API 会在 GET 请求中直接传 JSON,但这是它的业务需要,因为普通的 Query Param 根本无法构造如此复杂的查询 DSL。Github 的 V3 API 中也有很多不符合标准的地方,这也并不会妨碍它成为业界 RESTful API 的参考标准。

我接下来要介绍的一些东西也会跟标准不符,但这是我在实际开发中遇到过、困扰过、思考过所得出的结论,所以才是我所认为的RESTful API 最佳实践。

为什么要用 RESTful

RESTful 给我的最大感觉就是规范、易懂和优雅,一个结构清晰、易于理解的 API 完全可以省去许多无意义的沟通和文档。并且 RESTful 现在越来越流行,也有越来越多优秀的周边工具(例如文档工具 Swagger)。

协议

如果能全站 HTTPS 当然是最好的,不能的话也请尽量将登录、注册等涉及密码的接口使用 HTTPS。

版本

API 的版本号和客户端 APP 的版本号是毫无关系的,不要让 APP 将它们用于提交应用市场的版本号传递到服务器,而是提供类似于v1v2之类的 API 版本号。版本号只允许枚举,不允许判断区间。

版本号拼接在 URL 中或是放在 Header 中都可以。例如:

1
api.xxx.com/v1/users

或:

1
2
api.xxx.com/users
version=v1

URL 设计

动词+宾语

RESTful 的核心思想就是,客户端发出的数据操作指令都是"动词 + 宾语"的结构。比如,GET /articles这个命令,GET是动词,/articles是宾语。 动词通常就是五种 HTTP 方法,对应 CRUD 操作。

动词的覆盖

有些客户端只能使用GET和POST这两种方法。服务器必须接受POST模拟其他三个方法(PUT、PATCH、DELETE)。 这时,客户端发出的 HTTP 请求,要加上X-HTTP-Method-Override属性,告诉服务器应该使用哪一个动词,覆盖POST方法。

宾语必须是名词

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

复数 URL

既然 URL 是名词,那么应该使用复数,还是单数? 这没有统一的规定,但是常见的操作是读取一个集合,比如GET /articles(读取所有文章),这里明显应该是复数。 为了统一起见,建议都使用复数 URL,比如GET /articles/2要好于GET /article/2。

避免多级 URL

常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个作者的某一类文章。

1
GET /authors/12/categories/2

这种 URL 不利于扩展,语义也不明确,往往要想一会,才能明白含义。 更好的做法是,除了第一级,其他级别都用查询字符串表达。

1
GET /authors/12?categories=2

请求

一般来说 API 的外在形式无非就是增删改查(当然具体的业务逻辑肯定要复杂得多),而查询又分为详情和列表两种,在 RESTful 中这就相当于通用的模板。例如针对文章(Article)设计 API,那么最基础的 URL 就是这几种:

1
2
3
4
5
GET /articles/list: 文章列表
GET /articles/detail/{id}:文章详情
POST /articles/create/: 创建文章
PUT /articles/update/{id}:修改文章
DELETE /articles/delete/{id}:删除文章

RESTful 中使用 GET、POST、PUT 和 DELETE 来表示资源的查询、创建、更改、删除,并且除了 POST 其他三种请求都具备幂等性(多次请求的效果相同)。需要注意的是 POST 和 PUT 最大的区别就是幂等性,所以 PUT 也可以用于创建操作,只要在创建前就可以确定资源的 id。

将 id 放在 URL 中而不是 Query Param 的其中一个好处是可以表示资源之间的层级关系,例如文章下面会有评论(Comment)和点赞(Like),这两项资源必然会属于一篇文章,所以它们的 URL 应该是这样的:

评论:

1
2
3
4
5
GET /articles/{aid}/comments: 某篇文章的评论列表
GET /comments/{cid}/get: 获取
POST /articles/{aid}/comments/create: 在某篇文章中创建评论
PUT /comments/{cid}/update: 修改评论
DELETE /comments/{cid}/delete: 删除评论

这里有一点比较特殊,永远去使用可以指向资源的的最短 URL 路径,也就是说既然/comments/{cid}已经可以指向一条评论了,就不需要再用/articles/{aid}/comments/{cid}特意的指出所属文章了。

点赞:

1
2
3
GET /articles/{id}/like:查看文章是否被点赞
PUT /articles/{id}/like:点赞文章
DELETE /articles/{id}/like:取消点赞

由于大部分的关系查询都与当前的登录用户有关,所以也可以直接在关系所属的资源中返回关系状态。例如点赞状态就可以直接在获取文章详情时返回。注意这里我选择了 PUT 而不是 POST,因为我觉得点赞这种行为应该是幂等的,多次操作的结果应该相同。

Token 和 Sign

API 需要设计成无状态,所以客户端在每次请求时都需要提供有效的 Token 和 Sign,在我看来它们的用途分别是:

  • Token 用于证明请求所属的用户,一般都是服务端在登录后随机生成一段字符串(UUID)和登录用户进行绑定,再将其返回给客户端。Token 的状态保持一般有两种方式实现:一种是在用户每次操作都会延长或重置 TOKEN 的生存时间(类似于缓存的机制),另一种是 Token 的生存时间固定不变,但是同时返回一个刷新用的 Token,当 Token 过期时可以将其刷新而不是重新登录。
  • Sign 用于证明该次请求合理,所以一般客户端会把请求参数拼接后并加密作为 Sign 传给服务端,这样即使被抓包了,对方只修改参数而无法生成对应的 Sign 也会被服务端识破。当然也可以将时间戳、请求地址和 Token 也混入 Sign,这样 Sign 也拥有了所属人、时效性和目的地。

统计性参数

我不太清楚这类参数具体该被称为什么,总之就是用户的各种隐私【误。类似于经纬度、手机系统、型号、IMEI、网络状态、客户端版本、渠道等,这些参数会经常收集然后用作运营、统计等平台,但是在大部分情况下他们是与业务无关的。这类参数变化不频繁的可以在登录时提交,变化比较频繁的可以用轮训或是在其他请求中附加提交。

业务参数

在 RESTful 的标准中,PUT 和 PATCH 都可以用于修改操作,它们的区别是 PUT 需要提交整个对象,而 PATCH 只需要提交修改的信息。但是在我看来实际应用中不需要这么麻烦,所以我一律使用 PUT,并且只提交修改的信息。 另一个问题是在 POST 创建对象时,究竟该用表单提交更好些还是用 JSON 提交更好些。其实两者都可以,在我看来它们唯一的区别是 JSON 可以比较方便的表示更为复杂的结构(有嵌套对象)。另外无论使用哪种,请保持统一,不要两者混用。 还有一个建议是最好将过滤、分页和排序的相关信息全权交给客户端,包括过滤条件、页数或是游标、每页的数量、排序方式、升降序等,这样可以使 API 更加灵活。但是对于过滤条件、排序方式等,不需要支持所有方式,只需要支持目前用得上的和以后可能会用上的方式即可,并通过字符串枚举解析,这样可见性要更好些。例如: 搜索,客户端只提供关键词,具体搜索的字段,和搜索方式(前缀、全文、精确)由服务端决定:

1
/users/?query=ScienJus

过滤,只需要对已有的情况进行支持:

1
/users/?gender=1

对于某些特定且复杂的业务逻辑,不要试图让客户端用复杂的查询参数表示,而是在 URL 使用别名:

1
/users/recommend

分页:

1
2
3
/users/?offset=10&limit=10
/articles/?cursor=2015-01-01 15:20:30&limit=10
/users/?page=2&pre_page=20

排序,只需要对已有的情况进行支持:

1
/articles/sort=-create_date

PS:我很喜欢这种在字段名前面加-表示降序排列的方式。

响应

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

1
2
3
4
5
1xx:相关信息
2xx:操作成功
3xx:重定向
4xx:客户端错误
5xx:服务器错误

这五大类总共包含100多种状态码,覆盖了绝大部分可能遇到的情况。每一种状态码都有标准的(或者约定的)解释,客户端只需查看状态码,就可以判断出发生了什么情况,所以服务器应该返回尽可能精确的状态码。 API 不需要1xx状态码,下面介绍其他四类状态码的精确含义。

2xx 状态码

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

1
2
3
4
5
    GET: 200 OK
    POST: 201 Created
    PUT: 200 OK
    PATCH: 200 OK
    DELETE: 204 No Content

上面代码中,POST返回201状态码,表示生成了新的资源;DELETE返回204状态码,表示资源已经不存在。 此外,202 Accepted状态码表示服务器已经收到请求,但还未进行处理,会在未来再处理,通常用于异步操作。下面是一个例子

1
2
3
4
5
6
7
8
HTTP/1.1 202 Accepted

{
  "task": {
    "href": "/api/company/job-management/jobs/2130040",
    "id": "2130040"
  }
}

3xx 状态码

API 用不到301状态码(永久重定向)和302状态码(暂时重定向,307也是这个含义),因为它们可以由应用级别返回,浏览器会直接跳转,API 级别可以不考虑这两种情况。 API 用到的3xx状态码,主要是303 See Other,表示参考另一个 URL。它与302和307的含义一样,也是"暂时重定向",区别在于302和307用于GET请求,而303用于POST、PUT和DELETE请求。收到303以后,浏览器不会自动跳转,而会让用户

1
2
HTTP/1.1 303 See Other
Location: /api/orders/12345

4xx 状态码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
4xx状态码表示客户端错误,主要有下面几种。
400 Bad Request:服务器不理解客户端的请求,未做任何处理。
401 Unauthorized:用户未提供身份验证凭据,或者没有通过身份验证。
403 Forbidden:用户通过了身份验证,但是不具有访问资源所需的权限。
404 Not Found:所请求的资源不存在,或不可用。
405 Method Not Allowed:用户已经通过身份验证,但是所用的 HTTP 方法不在他的权限之内。
410 Gone:所请求的资源已从这个地址转移,不再可用。
415 Unsupported Media Type:客户端要求的返回格式不支持。比如,API 只能返回 JSON 格式,但是客户端要求返回 XML 格式。
422 Unprocessable Entity :客户端上传的附件无法处理,导致请求失败。
429 Too Many Requests:客户端的请求次数超过限额。

5xx 状态码

1
2
3
5xx状态码表示服务端错误。一般来说,API 不会向用户透露服务器的详细信息,所以只要两个状态码就够了。
500 Internal Server Error:客户端请求有效,服务器处理时发生了意外。
503 Service Unavailable:服务器无法处理请求,一般用于网站维护状态。

但是有些时候仅仅使用 HTTP 状态码没有办法明确的表达错误信息,所以我倾向于在里面再包一层自定义的返回码,例如:

成功时:

1
2
3
4
5
{
   "code": 100,
   "msg": "成功",
   "data": {}
}

失败时:

1
2
3
4
{
   "code": -1000,
   "msg": "用户名或密码错误"
}

data是真正需要返回的数据,并且只会在请求成功时才存在,msg只用在开发环境,并且只为了开发人员识别。客户端逻辑只允许识别code,并且不允许直接将msg的内容展示给用户。如果这个错误很复杂,无法使用一段话描述清楚,也可以在添加一个doc字段,包含指向该错误的文档的链接。

返回数据

不要返回纯本文

API 返回的数据格式,不应该是纯文本,而应该是一个 JSON 对象,因为这样才能返回标准的结构化数据。所以,服务器回应的 HTTP 头的Content-Type属性要设为application/json。 客户端请求时,也要明确告诉服务器,可以接受 JSON 格式,即请求的 HTTP 头的ACCEPT属性也要设成application/json。下面是一个例子。

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

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

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

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "failure",
  "data": {
    "error": "Expected at least two items in list."
  }
}

上面代码中,解析数据体以后,才能得知操作失败。 这张做法实际上取消了状态码,这是完全不可取的。正确的做法是,状态码反映发生的错误,具体的错误信息放在数据体里面返回。下面是一个例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

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

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

提供链接

API 的使用者未必知道,URL 是怎么设计的。一个解决方法就是,在回应中,给出相关链接,便于下一步操作。这样的话,用户只要记住一个 URL,就可以发现其他的 URL。这种方法叫做 HATEOAS。 举例来说,GitHub 的 API 都在 api.github.com 这个域名。访问它,就可以得到其他 URL。

1
2
3
4
5
6
7
8
9
{
  ...
  "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 将它们与其他属性放在一起。更好的做法应该是,将相关链接与其他属性分开。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
HTTP/1.1 200 OK
Content-Type: application/json

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

JSON 比 XML 可视化更好,也更加节约流量,所以尽量不要使用 XML。 创建和修改操作成功后,需要返回该资源的全部信息。 返回数据不要和客户端界面强耦合,不要在设计 API 时就考虑少查询一张关联表或是少查询 / 返回几个字段能带来多大的性能提升。并且一定要以资源为单位,即使客户端一个页面需要展示多个资源,也不要在一个接口中全部返回,而是让客户端分别请求多个接口。 最好将返回数据进行加密和压缩,尤其是压缩在移动应用中还是比较重要的。

分页

APP 后端分页设计 中提到过,分页布局一般分为两种,一种是在 Web 端比较常见的有底部分页栏的电梯式分页,另一种是在 APP 中比较常见的上拉加载更多的流式分页。这两种分页的 API 到底该如何设计呢?

电梯式分页需要提供page(页数)和pre_page(每页的数量)。例如:

1
/users/?page=2&pre_page=20

而服务端则需要额外返回total_count(总记录数),以及可选的当前页数、每页的数量(这两个与客户端提交的相同)、总页数、是否有下一页、是否有上一页(这三个都可以通过总记录数计算出)。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
   "pagination": {
      "previous": 1,
      "next": 3,
      "current": 2,
      "per_page": 20,
      "total": 200,
      "pages": 10
   },
   "data": {}
}

流式布局也完全可以使用这种方式,并且不需要查询总记录数(好处是减少一次数据库操作,坏处时客户端需要多请求一次才能判断是否到最后一页)。但是会出现数据重复和缺失的情况,所以更推荐使用游标分页。

游标分页需要提供cursor(下一页的起点游标) 和limit(数量) 参数。例如:

1
/articles/?cursor=2015-01-01 15:20:30&limit=10

如果文章列表默认是以创建时间为倒序排列的,那么cursor就是当前列表最后一条的创建时间(第一页为当前时间)。

服务端需要返回的数据也很简单,只需要以此游标为起点的总记录数和下一个起点游标就可以了。例如:

1
2
3
4
5
6
7
8
{
   "pagination": {
      "next": "2015-01-01 12:20:30",
      "limit": 10,
      "total": 100,
   },
   "data": {}
}

如果total小于limit,就说明已经没有数据了。

流式布局的分页 API 还有一种情况很常见,就是下拉刷新的增量更新。它的业务逻辑正好和游标分页相反,但是参数基本一样:

1
/articles/?cursor=2015-01-01 15:20:30&limit=20

返回数据有两种可能,一种是增量更新的数据小于指定的数量,就直接将全部数据返回(这个数量可以设置的相对大一些),客户端会将这些增量更新的数据添加在已有列表的顶部。但是如果增量更新的数据要大于指定的数量,就会只返回最新的 n 条数据作为第一页,这时候客户端需要清空之前的列表。例如:

1
2
3
4
5
6
7
{
   "pagination": {
      "limit": 20,
      "total": 100,
   },
   "data": {}
}

如果total大于limit,说明增量的数据太多所以只返回了第一页,需要清空旧的列表。

署名 - 非商业性使用 - 禁止演绎 4.0