API 设计模式

约定

结构体名 RequestQuery 表示请求的 query 参数,实际业务应该名为 业务名+Input。

结构体名为 RequestBody 表示请求的 body 参数。

结构体名为 ResponseBody 表示响应的 body 参数。

设计原则

命名

名字的寿命可能比项目的生命周期还要长。从变量命名,结构体命名,包名,函数名,到业务名,无处不在,如果变量叫 Channel,业务名叫通道,销售经理叫管道,函数名叫 Pipe,这种割裂感,每位项目参与者真的能明白对方想表达的是什么东西吗?

代码内的命名还好,一旦是开放的 API ,就不能轻易的更名,所以选择一个清晰简洁通俗易懂的名称,是非常必要的。

数据请求/响应参数的命名,有大驼峰/小驼峰/蛇形,重点不在于选择哪种方式,而在于统一。看看以下 JSON,你会觉得很享受吗? 每次填写参数的时候,你是否要考虑一下,这个参数是小驼峰还是蛇形来着?

跟着公司旧项目的命名方式走即可,如果没有旧项目? 可以参考你喜欢的公司用怎样的命名方式,例如看看 ChatGPT ,Twitter(X),百度等等,选一个作为参考即可。

1
2
3
4
{
  "page_number":1,
  "maxPageLimit" 2
}

量词命名应当结尾带上单位,例如开始时间 startAtMs 开始时间戳毫秒,StartAtS 开始时间戳秒。文件大小 sizeBytes,这种明细的单位不用查询文档即可知道其含义。当接口发生变更时(例如更换单位),新增一个变量名即可,例如 SizeMbypes 。

简单性

好的 API 应该非常简单的调用,不应该为了一些隐藏或兼容功能,提高调用复杂度。API 不应试图过度减少接口数量,而应该尽可能以最直接的方式公开用户想实现业务的功能。

可预测性

在某些 API 中使用了 page 作为分页,那么在相同查询列表的接口中,也应该使用相同的单词,所有接口使用一致的命名规则能够使 API 的参数可以被预测。如果有些接口中叫 page,有些接口叫 page_num,有些接口叫 page_size ,另外的接口用 pageSize,调用者会很混乱,同一个东西为什么要起这么多名字? 是有什么特殊性吗?

个人编写代码可能会出现这种情况,但更多是因为团队开发才出现这种情况,团队开发者如果明确知道这个模型已经定义了,应当先去了解已定义的模型,而不是自己想当然的创建新模型。

1
2
3
4
5
6
// 以下函数都是为了分页查询消息。
func findMessages(page,size int) ([]Message,error){}
func findMessagesByUserID(pageNum,maxSize int) ([]Message,error){}
func findMessagesByUsername(pageSize,max_limit int) ([]Message,error){}
func findInformatiI( size,limit int) ([]Message,error){}
// What Fuck?

富有表现力

接口能够清楚的表达他们想做的事情。例如,将文本转换成另外一种语言,用户可能会频繁的调用接口去判断字符串属于哪个语言? 这属于业务上的需求,如果直接提供一个 detectLanguage 接口而不是让用户调用大量接口去猜测,情况会好得多。

标识符

通过标识符来区分资源。好的标识符应该有以下优点:

  • 易于使用,不应该含有特殊符号和保留关键字
  • 全局唯一
  • 永久生命周期
  • 生成快速,简单
  • 不可预测,可预测的标识符更容易定位和利用潜在的漏洞
  • 可读,可共享,可验证,应当避免 1,|,L,l,i,I 这些容易混淆,以下字符串中去掉了 容易与 数组 0 混淆的字母 O,去掉了容易与数字 1 混淆的字母IL
1
0123456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz

在标识符前面应当增加资源,例如设备 /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

1
2
3
4
type RequestQuery struct{
  Page int // page 用来表示请求的哪一页
  Size int // size 表示最大取多少条数据
}

响应

1
{ "items":[], "total": 200, "next":""}

items 表示内容列表,total 和 next 一般是二选一存在,当遇到支持跳页时,应该返回 total 表示消息总数,前端可以通过 total 来计算分多少页。 当遇到顺序翻页时( 滚动翻页 ),应当返回 next ,此值是获取下一页的方法。

导入/导出模式

通常导入导出涉及到查询进度,查询状态,历史记录,下载位置等问题。

以导出用户信息为例:

POST /users/:id/messages:export

导入/导出是一个行为动作,所以此处应用 POST 动词加上特殊语法来区分,这不是标准 REST API操作。

1
2
3
4
type RequestBody struct{
   Compression int  // 指定文件压缩级别 <=0 不压缩,1-9 压缩级别
   Filters string // 过滤导出哪些字段
}

导入/导出模式应该持续响应进度,可以返回具体的量值,由前端根据需要是计算百分比,还是显示实际的量值。

1
2
3
4
5
6
7
type ResponseMetadata struct{
  Total int  // 总任务量
  Current int  // 当前执行到第一个任务?
  Success int  // 顺利完成任务总量
  Failure int  // 操作失败的任务总量
  Err  string  // 如果当前任务执行失败时存在此信息,否则为 undefined
}

最终任务完成时,返回文件信息。

1
2
3
4
type ResponseBody struct{
  Path string  // 文件地址(如果是本地文件应当返回 path 路径,若是 s3 存储应当返回完整 url 路径)
  Compression int // 压缩信息
}

其中导出模式,应当另外提供获取文件的接口。

Licensed under CC BY-NC-SA 4.0
本文阅读量 次, 总访问量 ,总访客数
Built with Hugo .   Theme Stack designed by Jimmy