我所认为的RESTful API最佳实践
不要纠结于无意义的规范
在开始本文之前,我想先说这么一句: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 将它们用于提交应用市场的版本号传递到服务器,而是提供类似于 v1
、 v2
之类的 API 版本号。版本号只允许枚举,不允许判断区间。
版本号拼接在 URL 中或是放在 Header 中都可以。例如:
|
|
或:
|
|
请求
一般来说 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 更加灵活。但是对于过滤条件、排序方式等,不需要支持所有方式,只需要支持目前用得上的和以后可能会用上的方式即可,并通过字符串枚举解析,这样可见性要更好些。例如:
搜索,客户端只提供关键词,具体搜索的字段,和搜索方式(前缀、全文、精确)由服务端决定:
|
|
过滤,只需要对已有的情况进行支持:
|
|
对于某些特定且复杂的业务逻辑,不要试图让客户端用复杂的查询参数表示,而是在 URL 使用别名:
|
|
分页:
|
|
排序,只需要对已有的情况进行支持:
|
|
PS:我很喜欢这种在字段名前面加 -
表示降序排列的方式。
响应
尽量使用 HTTP 状态码,常用的有:
- 200:请求成功
- 201:创建、修改成功
- 204:删除成功
- 400:参数错误
- 401:未登录
- 403:禁止访问
- 404:未找到
-
500:系统错误
但是有些时候仅仅使用 HTTP 状态码没有办法明确的表达错误信息,所以我倾向于在里面再包一层自定义的返回码,例如:
成功时:
|
|
失败时:
|
|
data
是真正需要返回的数据,并且只会在请求成功时才存在, msg
只用在开发环境,并且只为了开发人员识别。客户端逻辑只允许识别 code
,并且不允许直接将 msg
的内容展示给用户。如果这个错误很复杂,无法使用一段话描述清楚,也可以在添加一个 doc
字段,包含指向该错误的文档的链接。
返回数据
JSON 比 XML 可视化更好,也更加节约流量,所以尽量不要使用 XML。
创建和修改操作成功后,需要返回该资源的全部信息。
返回数据不要和客户端界面强耦合,不要在设计 API 时就考虑少查询一张关联表或是少查询 / 返回几个字段能带来多大的性能提升。并且一定要以资源为单位,即使客户端一个页面需要展示多个资源,也不要在一个接口中全部返回,而是让客户端分别请求多个接口。
最好将返回数据进行加密和压缩,尤其是压缩在移动应用中还是比较重要的。
分页
在 APP 后端分页设计 中提到过,分页布局一般分为两种,一种是在 Web 端比较常见的有底部分页栏的电梯式分页,另一种是在 APP 中比较常见的上拉加载更多的流式分页。这两种分页的 API 到底该如何设计呢?
电梯式分页需要提供 page
(页数)和 pre_page
(每页的数量)。例如:
|
|
而服务端则需要额外返回 total_count
(总记录数),以及可选的当前页数、每页的数量(这两个与客户端提交的相同)、总页数、是否有下一页、是否有上一页(这三个都可以通过总记录数计算出)。例如:
|
|
流式布局也完全可以使用这种方式,并且不需要查询总记录数(好处是减少一次数据库操作,坏处时客户端需要多请求一次才能判断是否到最后一页)。但是会出现数据重复和缺失的情况,所以更推荐使用游标分页。
游标分页需要提供 cursor
(下一页的起点游标) 和 limit
(数量) 参数。例如:
|
|
如果文章列表默认是以创建时间为倒序排列的,那么 cursor
就是当前列表最后一条的创建时间(第一页为当前时间)。
服务端需要返回的数据也很简单,只需要以此游标为起点的总记录数和下一个起点游标就可以了。例如:
|
|
如果 total
小于 limit
,就说明已经没有数据了。
流式布局的分页 API 还有一种情况很常见,就是下拉刷新的增量更新。它的业务逻辑正好和游标分页相反,但是参数基本一样:
|
|
返回数据有两种可能,一种是增量更新的数据小于指定的数量,就直接将全部数据返回(这个数量可以设置的相对大一些),客户端会将这些增量更新的数据添加在已有列表的顶部。但是如果增量更新的数据要大于指定的数量,就会只返回最新的 n 条数据作为第一页,这时候客户端需要清空之前的列表。例如:
|
|
如果 total
大于 limit
,说明增量的数据太多所以只返回了第一页,需要清空旧的列表。