一个HTTP接口的变更

接口向前兼容的演变思路.

假设接口 /api/group/usersGET 协议返回如下内容,意为:全量返回组内用户。

1
2
3
4
5
6
7
8
{
"data": [
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" },
{ "id": 3, "name": "c" },
{ "id": 4, "name": "d" }
]
}

现需要提供分页查询的功能,该如何实现?

分页查询的响应格式很多,也很简单,例如:

1
2
3
4
5
6
7
8
9
10
11
{
"current_page": 1,
"pages": 10,
"limit": 4,
"data": [
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" },
{ "id": 3, "name": "c" },
{ "id": 4, "name": "d" }
]
}

查询 API 也很容易构建,例如 /api/group/users?page=1&limit=4,同样保持 GET 协议。

一切很简单,但这就够了吗?

这可能会带来 BREAKING CHANGE

如果 pagelimit 后端给予默认值,比如 1 和 4,BREAKING CHANGE 就实际发生了。

这会导致低版本客户端无法正常使用,常见于后端更新发版、前端尚未发版、既有网站或移动端仍在使用时,异常发生。

而如果 pagelimit 后端不给予默认值,并根据是否传递该参数来判断接口的版本,BREAKING CHANGE 就不会发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# querys, request params, make it like a dict type for a example
if "page" in querys or "limit" in querys:
page = querys.get("page", 1)
limit = querys.get("limit", 4)
# do page query
return {
"pages": 10,
"current_page": page,
"limit": limit,
"data": [
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" },
{ "id": 3, "name": "c" },
{ "id": 4, "name": "d" }
]
}
else:
# do all query
return {
"data": [
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" },
{ "id": 3, "name": "c" },
{ "id": 4, "name": "d" }
]
}

现在,接口既能满足曾经的功能,又能额外提供分页查询的功能,够了吗?

够不够的关键点在于,是否需要丢弃全量查询的功能。

如果本就是为了丢弃全量查询,用分页查询来替代,那这里是不够的。

矛盾:要丢弃全量查询的功能,为何还要避免 BREAKING CHANGE

答:有计划的丢弃。

语义化的版本,如 m.n.j,j 通常应对 fix,即修订 BUG;n 通常对应 feat,即新增功能。

规范一些的项目中,n 和 j 变化,一般是向后兼容的,即兼容低版本。

但 m,通常表示存在一些不兼容的改动,不能完全向后兼容。

这里的不兼容,可能是 fix bug 时发生的,也可能是完成 feat 时带来的。

以 commit 规范为例,feat!: xxxxx 这里的 ! 就代表本次 commit 引入了不兼容改动。

当通过版本管理工具,比如 standard-version 来自动根据 commit 生成 tag 时,当发现 commit 中标注不兼容时,就会自动跃迁版本号。比如 1.3.2,就会变成 2.0.0

那进一步的改善空间为(假设当前版本号为 1.3.2):

  • 当版本号处于 1.3.2N.0.0 之间时,需要保留全量查询和分页查询的功能,伪代码如上。
  • 当版本号处于 N.0.0M.0.0 之间时,后台输出警告,提示接口仍然被使用。
  • 当版本号大于 M.0.0 时,后端只提供分页的功能,page 和 limit 给予默认值,不再提供全量查询。

到现在为止,够了吗?

如果把版本号判断,外加数据查询,都放在接口逻辑内部,功能上能满足。

但,还可以解耦。

以 Python 相关的 Web 框架为例,甚至可以把该接口的实现放到 URL 装载期间,不同版本时,挂载不同的函数处理入口。

甚至,django-rest-framework 这类的框架,提供了更简易的接口版本限制的功能。

至此,已经完成了分页查询,有计划丢弃(或者不丢弃),那,够了吗?

既然版本号的判断和数据查询,我们需要解耦,那么两个明显不同的功能(一个全量,一个分页)放到一个接口里面,也是可以拆开的。

/api/group/users 提供全量查询

/v2/api/group/users 提供分页查询

然后各自可以独立地通过版本号,进行限制。