约定
结构体名 RequestQuery 表示请求的 query 参数,实际业务应该名为 业务名+Input。
结构体名为 RequestBody 表示请求的 body 参数。
结构体名为 ResponseBody 表示响应的 body 参数。
设计原则
命名
名字的寿命可能比项目的生命周期还要长。从变量命名,结构体命名,包名,函数名,到业务名,无处不在,如果变量叫 Channel,业务名叫通道,销售经理叫管道,函数名叫 Pipe,这种割裂感,每位项目参与者真的能明白对方想表达的是什么东西吗?
代码内的命名还好,一旦是开放的 API ,就不能轻易的更名,所以选择一个清晰简洁通俗易懂的名称,是非常必要的。
数据请求/响应参数的命名,有大驼峰/小驼峰/蛇形,重点不在于选择哪种方式,而在于统一。看看以下 JSON,你会觉得很享受吗? 每次填写参数的时候,你是否要考虑一下,这个参数是小驼峰还是蛇形来着?
跟着公司旧项目的命名方式走即可,如果没有旧项目? 可以参考你喜欢的公司用怎样的命名方式,例如看看 ChatGPT ,Twitter(X),百度等等,选一个作为参考即可。
|
|
量词命名应当结尾带上单位,例如开始时间 startAtMs 开始时间戳毫秒,StartAtS 开始时间戳秒。文件大小 sizeBytes,这种明细的单位不用查询文档即可知道其含义。当接口发生变更时(例如更换单位),新增一个变量名即可,例如 SizeMbypes 。
简单性
好的 API 应该非常简单的调用,不应该为了一些隐藏或兼容功能,提高调用复杂度。API 不应试图过度减少接口数量,而应该尽可能以最直接的方式公开用户想实现业务的功能。
可预测性
在某些 API 中使用了 page 作为分页,那么在相同查询列表的接口中,也应该使用相同的单词,所有接口使用一致的命名规则能够使 API 的参数可以被预测。如果有些接口中叫 page
,有些接口叫 page_num
,有些接口叫 page_size
,另外的接口用 pageSize
,调用者会很混乱,同一个东西为什么要起这么多名字? 是有什么特殊性吗?
个人编写代码可能会出现这种情况,但更多是因为团队开发才出现这种情况,团队开发者如果明确知道这个模型已经定义了,应当先去了解已定义的模型,而不是自己想当然的创建新模型。
|
|
富有表现力
接口能够清楚的表达他们想做的事情。例如,将文本转换成另外一种语言,用户可能会频繁的调用接口去判断字符串属于哪个语言? 这属于业务上的需求,如果直接提供一个 detectLanguage
接口而不是让用户调用大量接口去猜测,情况会好得多。
标识符
通过标识符来区分资源。好的标识符应该有以下优点:
- 易于使用,不应该含有特殊符号和保留关键字
- 全局唯一
- 永久生命周期
- 生成快速,简单
- 不可预测,可预测的标识符更容易定位和利用潜在的漏洞
- 可读,可共享,可验证,应当避免 1,|,L,l,i,I 这些容易混淆,以下字符串中去掉了 容易与 数组
0
混淆的字母O
,去掉了容易与数字1
混淆的字母I
和L
。
|
|
在标识符前面应当增加资源,例如设备 /devices/5B82KZMO
,那如果想查询分页设备呢? 一般来说,API 仅当一种资源对另外一种资源拥有所有权时,才应依赖于层次关系。例如分页,分页属于资源的属性,并且经常变动不会持久化存储,不应该存在 /page/1
或 /devices/5B82KZMO/page/1
的情况,使用 /devices/5B82KZMO?page=1
更合理。
那想查询属于该设备的通道列表呢? 子资源是仅存在于父资源的上下文中的,否则会引出一个问题: 哪个设备的通道呢? 使用 /devices/5B82KZMO/channels
比较合理。
有一个较为矛盾的地方,当随着业务发展,可能会出现只想查询通道,并不关心通道属于谁。这时用 /channels
比较合理,当 device_id
作为查询条件时,此资源查询已包含 /devices/5B82KZMO/channels
的相同功能。
所以,层级划分时,必须明确子资源是仅存在于父资源的上下文中的。分层不宜过深,建议最大 2 层,例如 /users/1/messages/1
,用户和消息两层。当层数过多时,应该考虑是否应该将子资源剥离出来作为顶层资源。
请求方法
删除资源,正确删除时返回 200,如果资源不存在呢? 有人认为最终结果是正确的所以应该返回 200 结果。有人认为应该区分结果,尝试删除不存在应当返回 404。如果资源存在,尝试访问没有权限的资源怎么办呢? 返回 404 但实际资源是存在,返回 403 无权限但这会被恶意攻击者明确资源存在,可能会被探测并作为攻击目标。
在设计 API 时,经常会遇到这些选择题,接下来我们将讨论标准 API 应该如何设计,仅供参考,不应该所有实际业务都用标准 API 套用,遇到业务复杂的情况呢? 要灵活。以下内容不是解决问题的金手指,而在于引起一些思考。
GET (查询资源)
资源检索,一般通过唯一标识符检索资源,或通过关键信息过滤查找资源。
此方法应该是幂等的,假设没有发生其它更改的情况,则每次结果都应该相同。
访问控制,如果某些资源只能被特定用户访问,可以确保资源有单独的父级,例如 /users/:id/messages
。
在分布式项目中,计数很难获取精确的结果,提供这种查询容易给使用者误导,应当用估计值而非精确计数;
假设对分布在 100 个后端的 1 亿数据做排序,这种查询很容易导致服务器过载。这类微小的功能往往会在未来增加大量的复杂性,且对 API 使用者来说价值相对较小。为了实现一个价值相对较小的业务功能,而增加服务的复杂度,代码的维护复杂性,内存倍增,是值得的吗?
查询部分资源,大多数情况下,查询部分资源只会有 2 个版本,完整数据版和基础数据版。可以通过 query 参数来指定基础版 field=base
,当情况更复杂时,可以指定具体要哪些数据,两种方式应该是二选一实现。fields=name,remark,age
,注意当使用后者时,服务端应当小心的对待这些参数,避免 SQL 注入,或不存在的字段输入了 SQL 。
POST (创建资源)
资源创建,请求体包含资源创建信息,并生成对应的资源响应。目标要么是父资源,要么是顶级集合。例如 /users
,/devices
。
资源一旦创建成功,意味着应该允许查询或删除/修改等操作,要保证资源一致性。
Delete (删除资源)
通常使用资源唯一标识符删除,例如 DELETE /users/6n12312m
。
重复删除相同的资源,应当返回正确的结果,无论资源是否存在,其最终达到了删除的目的。可以响应被删除的数据,如果资源不存在时,可能只有资源标识符,资源属性为零值。
PATCH (部分更新)
在业务实际使用过程中,并不太需要明确部分更新还是全量更新的区别,建议使用 PUT 替代。
PUT (替换资源)
如果使用 PUT 包含 PATCH 的内容,会出现部分更新的状况,此时应该使用 query 参数 fields 来表示哪些参数需要更新,例如 PUT /users/n1i24km?fields=name
,此时表示只更新用户名。
自定义方法
REST ful API 是将 API 视为资源的设计方案,在实际业务中,有些行为是动作,比如导入导出,比如设备重启/格式化。有些动作并不一定会对资源属性发生更改。这些自定义的方法几乎都应该使用 POST HTTP Method,当然使用其它 Method 也有应用场景,不明确用什么时,那就应该用 POST 。
为了避免对资源造成混淆,应当避免使用 /
,可以使用 :
加动词来指示资源的操作,这可能看起来有点奇怪,但避免歧义很重要。冒号是 URL 中保留特殊字符,会被转码为 %3A
。例如导出设备通道 /devices/1/channels:export
,导入设备信息 /devices:import
。
如果对多个不同父级的一组资源操作应该怎么处理呢? 例如 /users/1/messages
并不关心用户是谁,而关心操作的子资源,此时可以将父标识替换为通配符,如/users/-/messages
,服务端不会去处理父资源,使用通配符从语义上更容易懂。
通常自定义方法不是幂等的,会有副作用,比如连续 2 次重启设备,第二次执行时设备已经离线了。使用 :<动词>
能够区分标准的资源,应当谨慎的对待这些接口。
分页模式
大量数据被同时查询,会增加接口耗时,对于用户体验不是很好,每次打开客户端,都要等几秒才能看到结果? 正确使用分页模式,将消息分片,每次返回一部分。
例如用户的消息。
GET /users/:id/messages?page=1&size=10
|
|
响应
|
|
items 表示内容列表,total 和 next 一般是二选一存在,当遇到支持跳页时,应该返回 total 表示消息总数,前端可以通过 total 来计算分多少页。 当遇到顺序翻页时( 滚动翻页 ),应当返回 next ,此值是获取下一页的方法。
导入/导出模式
通常导入导出涉及到查询进度,查询状态,历史记录,下载位置等问题。
以导出用户信息为例:
POST /users/:id/messages:export
导入/导出是一个行为动作,所以此处应用 POST 动词加上特殊语法来区分,这不是标准 REST API操作。
|
|
导入/导出模式应该持续响应进度,可以返回具体的量值,由前端根据需要是计算百分比,还是显示实际的量值。
|
|
最终任务完成时,返回文件信息。
|
|
其中导出模式,应当另外提供获取文件的接口。