谁是最酷炫的 API Style
又是 PPT 改,已经吐槽不动自己最近出的文章了。
来自公司内部分享。
引言
好的 API 设计都是相似的,差的 API 设计却各有各的槽点——敖天羽。
Free Style
让我们看一段在很多公司都容易看到的放飞自我型 API,你永远无法预测 API 会是什么鬼样子,而返回值则永远是 200 OK
:
GET post/list
GET post/list/v2
POST post/create
POST user/add
更郁闷的是,你能看到的 response code 永远长这样:
{
"code": 500,
"message": "500",
"data": {"list": []}
}
现在你开始纠结——我是谁,我在哪里,我为什么错了,我做错了什么才和这样的接口对接。
我把这种风格称为盲盒式 API,他有以下特点:
- 前端在看到接口的瞬间开始怀疑人生:你永远不知道下一个 API 应该长啥样
- 换了个页面,写个新接口
(草,谁用了我想用的接口命名) - 用户不知道发生了啥:500 啥 500,具体啥错了有吗
- 接口一多,难以治理:画风都不一样
JSON RPC Style
针对上文的内容,JSON RPC Style 做出了一些改动,要知道这个风格,你可能首先要了解一下,什么是 JSON RPC。
尽管这样的 http code 仍然是 200,至少你有了一个稍微标准化的结果:
至少我们的 API 可以 like /api/GetPosts
的函数名,而不用纠结子路径切割的问题,我们只要在函数名上有统一的规范,甚至可以自动生成。
而在返回值上也有一定的规范可循:
// success case
{"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
// error case
{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}
JSON RPC Style 可以发生错误时,在 response body 的 error 中自定义业务级别的错误码,类似:
错误码 | 消息 | 解释 |
---|---|---|
-32700 | Parse error | 语法解析错误,服务端接收到无效的 JSON。该错误发送于服务器尝试解析 JSON 文本 |
-32600 | Invalid Request | 无效请求,发送的 JSON 内容不是一个有效的请求对象。 |
-32601 | Method not found | 找不到方法,该方法不存在或无效。 |
-32602 | Invalid params | 无效的参数,无效的方法参数。 |
-32603 | Internal error | 内部错误,JSON-RPC 内部错误。 |
-32000 to -32099 | Server error | 服务端错误,预留用于自定义的服务器错误。 |
那么我们就可以通过 error code 和函数名式的路由进行一系列统计方便的业务错误统计,前端或者后端也可以根据 error code 进行错误内容的映射。形成统一的文案。
值得一提的是,尽管在这一套方案的实践上返回值和路由的定义可能均不相同,我在此并没有找到一个统一的、跟 Restful 一样的普世的解决方案,但本质上都能达成类似的效果。甚至可以用 grpc service 去生成 HTTP routers。
但是对于前端来说,业务 error code 并不能解决根本的交互问题:
对于前端来说,error code 可能会映射为参数错误,但是具体哪个参数错误——前端或者用户依旧是不知道的。
另一个难以解决的问题是,动名词结合永远会导致不标准(这一点的原因会在文末提到)。比如上文的 AddUser
和 CreatePost
,又或者会叫 PostEdit
,让人难以捉摸,随着接口的增多,魔幻命名带来的维护成本就是不乐观的了。
Restful API
因此在此,我们提出了一个新的观点:
所有不考虑前端场景的接口都是耍流氓——不愿透露姓名的敖天羽同学。
Restful API 对应的画风大概是这样的,如果你还不了解什么是 Restful API,建议阅读RESTful API 设计入门:
GET /api/v1/posts 获取列表
POST /api/v1/posts 创建资源
GET /api/v1/posts/{post_id} 获取某一资源
PUT /api/v1/posts/{post_id} 修改资源
PATCH /api/v1/posts/{post_id} 修改资源的部分字段
DELETE /api/v1/posts/{post_id} 删除资源
对于 API 来说 Restful 的理念同时兼顾了 HTTP Response Code 的语义:
CODE 200 OK
CODE 201 CREATED
CODE 204 NO CONTENT
CODE 400 BAD REQUEST
CODE 401 UNAUTHORIZED
CODE 403 FORBIDEEN
CODE 404 NOT FOUND
而同时,他保证了 response body 的最简化:
// success
[{"title": "Hello World"}, {"title": "谁是最酷炫的 API Style"}]
// error
{"error": "title 参数长度不得大于 50"}
Restful API 的理念确实解决了我们很多的问题:
- 资源即路由,妈妈再也不用担心版本化和路由命名了
- 前后端业务解耦,通常场景下不需要做针对页面的特殊接口
- 资源粒度的缓存
- 接口幂等性一目了然
- 语义化接口,减少前端和用户的心智负担,更友好的错误提示
统一的命名规范让 API 接口变的有规律可行,减少了前后端的管理成本,也不会遇到迷幻路由 post/list/v2
或者 post/listV2
了。
更重要的一点是,后端只需要关心资源的操作情况,而不需要关心具体内容,让后端从前端页面细节中解脱出来,写最少的 API,做最大的复用。
这个观点似乎和上文的不考虑「前端场景」有出入?其实并不矛盾,前端同样可以通过管理资源(实例对象)去进行数据的操作,也不用针对页面调整不断的接入奇怪的接口。
而由此带来的一个好处是,我们可以针对通用的资源接口进行统一管理和缓存(包括服务端缓存和客户端缓存),不用纠结具体页面。
此外,HTTP METHOD 本身也自带了接口幂等性的规范指引,如果是一个遵循规范的接口,那么无论是前端还是拿到手维护的后端,都能一目了然的知道这个接口的用途和操作风险。
更重要的是,从 Restful 开始,我们的错误提示总算变的人性化起来了。
但是既然这样,为什么 Restful 还是饱受诟病的学院派,很多后端往往会不考虑这个技术方案呢——原因也就在于,他引入了更大的问题:
- HTTP Code 无法覆盖全部语义
特殊业务接口无法满足:
- 搜索、翻译等「动作」
- 批量删除无法使用 DELETE Method(不知道为什么的建议重学 HTTP)
- 前端用不到资源的全部字段,非常浪费
- 监控需要改造成 method + path,对于单一资源的路由难以监控
- 错误码监控的统计难度增大
前面几个问题的缺陷可能是一目了然的,但问题也不大,对于前端来说,语义也不需要覆盖到业务维度,似乎并没有多大烦恼;但对于后端来说,对于代码层的管理带来了一些好处,而带来了更多问题,比如我们在 JSON RPC Style 章节中说到的一个最大的好处:监控与告警统计。
method + path
可能可以监控接口和统计,但是对于一般统计来说,可能就会变成 GET api/v1/posts/1
和 GET api/v1/posts/2
进行了分开统计,而实际上他们只是不同入口的同一操作;如果 method 没有纳入你们的监控当中,那么问题会变得更大。
同时,失去了业务维度 code 的支持,我们很有可能无法定位到具体的业务错误,大概率只能靠着 500 错误码去监控了。
GraphQL
我们发现了 Restful API 虽然纳入了对前端友好的行列,但是还是有一些值得优化的地方,比如我刚刚没有介绍的资源浪费,其实带来更大的问题是,我们要组合资源时,可能需要发送好多个请求,在 HTTP1.1 里,这绝对是一个 bad case。
而 GraphQL 可以解决一下上述问题,如果你还不了解什么是 GraphQL,可以通过官网稍作了解,也可以阅读GraphQL 从入门到入土,在这里我们不会从零开始介绍 GraphQL,只会稍微举几个例子:
比如这里我们请求了两个对象,如果在过去的设计中,我们需要发起两个 HTTP 请求,但在 GraphQL 中,我们只需要一个请求就可以了:
{
empireHero: hero(episode: EMPIRE) {
name
}
jediHero: hero(episode: JEDI) {
name
}
}
返回值形如:
{
"data": {
"empireHero": {
"name": "Luke Skywalker"
},
"jediHero": {
"name": "R2-D2"
}
}
}
此外,GraphQL 具有强类型定义的特性,比如这里我们定义了一个 Character 对象。
type Character {
name: String!
appearsIn: [Episode!]!
}
从上面两个例子,我们大致可以开除以下几个也都爱你:
- 最简化的路由形式,最强的表现力,通过
/graphql
路由一票解决 - 可以选择前端所需的字段,按需传输
- 强类型接口,前后端可以生成同样的类型约束,解放前端
- 支持字段预处理(比如格式化时间)
- 接口缝合机,任意拼接
- 接口即文档,直接生成
这里重点说一下解放前端的点,由于 GraphQL 具有类型定义,因此后端只要改动接口,就必然会有所体现,而不是文档型的弱约束;同时,你可以通过 GraphiQL 免于接口文档的烦恼;更强的是,既然有了类型,为什么我不能通过类型生成 Typescript 类型呢?
对于客户端来说,这是一种非常「爽」的表现方式,因此保守前端推崇,但是推管推,为什么推不动呢?因为后端以近乎绝望的姿势迎来了以下挑战:
- 无法有效利用 HTTP 缓存
- 监控困难(需要解析 GraphQL 查询)
- 服务端 GraphQL 解析开销大
- 需要人工约定 namespace,大型项目的治理有额外成本
在单一路径下,HTTP 自带的 Cache 机制形同虚设,人均 no-cache,增加了后端开发的成本。更蛋疼的是,Restful API 本来只是把接口弄散了,还是可以看出一定的趋势的,但是 /graphql
这个单一入口什么都看不出来,要做资源监控、日志,只能通过解析 GraphQL request body 中的结果。
同时,传入的 GraphQL 文本本质只是一段 plain text,需要类型映射和创建,才能变成最终的后端对象,这中间毫无疑问有一层解析成本。
最后,GraphQL 的单一入口不是说来听听,在应用中甚至没有设计一个 namespace/graphql
,导致 schema 的不断臃肿,最后无论是前端或者后端、生成、解析、处理起来都会造成严重的负担。
一体化 API
于是前端后端都叹了口气,要不咱们还是降级回 RPC Style 吧,有没有办法能够结合 Restful API 和 GraphQL 带来的好处去解决这个问题呢?说白了我们最初只是因为接口瞎几把写带来了一系列烦恼——如果干脆就不要接口了呢。
在前端的不懈努力之下,他们又想出了新的模式 midway.js,相关文档可见 一体化全栈方案。
现在,后盾这么定义自己的「接口」:
export async function get() {
return 'Hello Midway Hooks'
}
export async function post(name: string) {
return 'Hello ' + name
}
而前端这么使用
import { get, post } from './apis/lambda'
/**
* @method GET
* @url /api/get
*/
get().then((message) => {
// Display: Hello Midway Hooks
console.log(message)
})
/**
* @method POST
* @url /api/post
* @body { args: ['github'] }
*/
post('github').then((message) => {
// Display: Hello github
console.log(message)
})
对于整个路由来说,根本没有一个地方显式写了 plain text 路由,但是却精准的可以请求到了某个 HTTP。基于函数的调用让我们的 return value 变得非常的清晰,再也不怕后端随便改接口了,也继承了 GraphQL 解决的其中一个烦恼:我们不用再写接口文档了。
但是也带来了很多问题:
首先,我们需要前后端同仓库引用吗?上述 demo 中,前后端其实是同仓库中变更的,但是其实我们通过包管理机制完全可以解决这个问题,引用外部仓库同样可以解决这个问题。
其次,后端一定要是 TypeScript(JavaScript) 吗,因为如果不是的话似乎就没有办法直接引用了?当然不是,在我们过去的开发经验中,已经有大量的「基于注释生成文档」、「基于 schema 生成代码」的案例,类比一下,我们同样也可以从其他语言转向供应给前端的 JS SDK。
那么接下来,我们如何让多个版本的 API 共存呢?很明显,我们可以分版本进行下发,这也就是为什么会引入 Serverless 的原因,因为它能让多版本共存变的更低版本,当然,这并不代表我们不用 Serverless 就无法解决。只是做到这一点会相对的更加麻烦和高成本。(当然 Serverless 本身也有很多麻烦,这不在本文进行讨论和介绍。)
但是后端缺因此更加绝望了,现阶段的开发过程中,对于后端是极不友好的,因为他目前不是一个成熟的解决方案,也就是说,面对形形色色的公司基础设施和场景,大部分公司可能经不起折腾,或者没有必要花这么大的成本投入研发上述我们所说的「带来的问题」对应的解决方案。
但就我个人来看,这确实是一个不错的解决方案,在未来的一定时间内,当解决了「行业内多语言实践方案」的问题之后,可能能成为下一代的通用方案。但不一定是一个万能银弹。
总结
从目前的进程来看,API 设计仍然没有银弹,且很有可能在长期都不会有银弹,我们只能综合的考虑自己的业务场景去选择最适合我们的一种方案。
当然,上文似乎仍有一个问题没有解决,那就是「为什么我们的 API 往往会起名困难、魔幻命名」——原因是,英语水平感人之下多说多错,而这不是团队中某一个人能解决的问题,甚至比技术本身更难解决,所以后面我们的解决方案其实越来越多的去绕过 plain text 带来的英语问题。
好的,又水了一篇,下次见。
植入部分
如果您觉得文章不错,可以通过赞助支持我。
如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。