不要纠结于无意义的规范

在开始本文之前,我想先说这么一句: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
3
api.xxx.com/users

version=v1

请求

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

  • GET /articles: 文章列表
  • GET /articles/id:文章详情
  • POST /articles/: 创建文章
  • PUT /articles/id:修改文章
  • DELETE /articles/id:删除文章

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

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

评论:

  • GET /articles/aid/comments: 某篇文章的评论列表
  • GET /comments/cid: 获取
  • POST /articles/aid/comments: 在某篇文章中创建评论
  • PUT /comments/cid: 修改评论
  • DELETE /comments/cid: 删除评论

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

    点赞:

  • GET /articles/id/like:查看文章是否被点赞

  • PUT /articles/id/like:点赞文章
  • DELETE /articles/id/like:取消点赞

RESTful 中不建议出现动词,所以可以将这种关系作为资源来映射。并且由于大部分的关系查询都与当前的登录用户有关,所以也可以直接在关系所属的资源中返回关系状态。例如点赞状态就可以直接在获取文章详情时返回。注意这里我选择了 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
4
5
/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 状态码,常用的有:

  • 200:请求成功
  • 201:创建、修改成功
  • 204:删除成功
  • 400:参数错误
  • 401:未登录
  • 403:禁止访问
  • 404:未找到
  • 500:系统错误

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

    成功时:

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

失败时:

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

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

返回数据

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,说明增量的数据太多所以只返回了第一页,需要清空旧的列表。