1

谁是最酷炫的 API Style

 3 years ago
source link: https://www.codesky.me/archives/who-is-the-coolest-api-style.wind
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

又是 PPT 改,已经吐槽不动自己最近出的文章了。
来自公司内部分享。

好的 API 设计都是相似的,差的 API 设计却各有各的槽点——敖天羽。

Free Style

让我们看一段在很多公司都容易看到的放飞自我型 API,你永远无法预测 API 会是什么鬼样子,而返回值则永远是 200 OK

  1. GET post/list
  2. GET post/list/v2
  3. POST post/create
  4. POST user/add

更郁闷的是,你能看到的 response code 永远长这样:

  1. {
  2. "code": 500,
  3. "message": "500",
  4. "data": {"list": []}
  5. }

现在你开始纠结——我是谁,我在哪里,我为什么错了,我做错了什么才和这样的接口对接。

16126174850246.jpg

我把这种风格称为盲盒式 API,他有以下特点:

  • 前端在看到接口的瞬间开始怀疑人生:你永远不知道下一个 API 应该长啥样
  • 换了个页面,写个新接口 (草,谁用了我想用的接口命名)
  • 用户不知道发生了啥:500 啥 500,具体啥错了有吗
  • 接口一多,难以治理:画风都不一样

JSON RPC Style

针对上文的内容,JSON RPC Style 做出了一些改动,要知道这个风格,你可能首先要了解一下,什么是 JSON RPC

尽管这样的 http code 仍然是 200,至少你有了一个稍微标准化的结果:

至少我们的 API 可以 like /api/GetPosts 的函数名,而不用纠结子路径切割的问题,我们只要在函数名上有统一的规范,甚至可以自动生成。

而在返回值上也有一定的规范可循:

  1. // success case
  2. {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
  3. // error case
  4. {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}

JSON RPC Style 可以发生错误时,在 response body 的 error 中自定义业务级别的错误码,类似:

错误码消息解释-32700Parse error语法解析错误,服务端接收到无效的 JSON。该错误发送于服务器尝试解析 JSON 文本-32600Invalid Request无效请求,发送的 JSON 内容不是一个有效的请求对象。-32601Method not found找不到方法,该方法不存在或无效。-32602Invalid params无效的参数,无效的方法参数。-32603Internal error内部错误,JSON-RPC 内部错误。-32000 to -32099Server error服务端错误,预留用于自定义的服务器错误。

那么我们就可以通过 error code 和函数名式的路由进行一系列统计方便的业务错误统计,前端或者后端也可以根据 error code 进行错误内容的映射。形成统一的文案。

值得一提的是,尽管在这一套方案的实践上返回值和路由的定义可能均不相同,我在此并没有找到一个统一的、跟 Restful 一样的普世的解决方案,但本质上都能达成类似的效果。甚至可以用 grpc service 去生成 HTTP routers。

16126174850246.jpg

但是对于前端来说,业务 error code 并不能解决根本的交互问题:

对于前端来说,error code 可能会映射为参数错误,但是具体哪个参数错误——前端或者用户依旧是不知道的。

另一个难以解决的问题是,动名词结合永远会导致不标准(这一点的原因会在文末提到)。比如上文的 AddUserCreatePost,又或者会叫 PostEdit,让人难以捉摸,随着接口的增多,魔幻命名带来的维护成本就是不乐观的了。

Restful API

因此在此,我们提出了一个新的观点:

所有不考虑前端场景的接口都是耍流氓——不愿透露姓名的敖天羽同学。

Restful API 对应的画风大概是这样的,如果你还不了解什么是 Restful API,建议阅读RESTful API 设计入门

  1. GET /api/v1/posts 获取列表
  2. POST /api/v1/posts 创建资源
  3. GET /api/v1/posts/{post_id} 获取某一资源
  4. PUT /api/v1/posts/{post_id} 修改资源
  5. PATCH /api/v1/posts/{post_id} 修改资源的部分字段
  6. DELETE /api/v1/posts/{post_id} 删除资源

对于 API 来说 Restful 的理念同时兼顾了 HTTP Response Code 的语义:

  1. CODE 200 OK
  2. CODE 201 CREATED
  3. CODE 204 NO CONTENT
  4. CODE 400 BAD REQUEST
  5. CODE 401 UNAUTHORIZED
  6. CODE 403 FORBIDEEN
  7. CODE 404 NOT FOUND

而同时,他保证了 response body 的最简化:

  1. // success
  2. [{"title": "Hello World"}, {"title": "谁是最酷炫的 API Style"}]
  3. // error
  4. {"error": "title 参数长度不得大于 50"}

Restful API 的理念确实解决了我们很多的问题:

  • 资源即路由,妈妈再也不用担心版本化和路由命名了
  • 前后端业务解耦,通常场景下不需要做针对页面的特殊接口
  • 资源粒度的缓存
  • 接口幂等性一目了然
  • 语义化接口,减少前端和用户的心智负担,更友好的错误提示

统一的命名规范让 API 接口变的有规律可行,减少了前后端的管理成本,也不会遇到迷幻路由 post/list/v2 或者 post/listV2 了。

更重要的一点是,后端只需要关心资源的操作情况,而不需要关心具体内容,让后端从前端页面细节中解脱出来,写最少的 API,做最大的复用。

这个观点似乎和上文的不考虑「前端场景」有出入?其实并不矛盾,前端同样可以通过管理资源(实例对象)去进行数据的操作,也不用针对页面调整不断的接入奇怪的接口。

而由此带来的一个好处是,我们可以针对通用的资源接口进行统一管理和缓存(包括服务端缓存和客户端缓存),不用纠结具体页面。

此外,HTTP METHOD 本身也自带了接口幂等性的规范指引,如果是一个遵循规范的接口,那么无论是前端还是拿到手维护的后端,都能一目了然的知道这个接口的用途和操作风险。

更重要的是,从 Restful 开始,我们的错误提示总算变的人性化起来了。

16126194791708.jpg

但是既然这样,为什么 Restful 还是饱受诟病的学院派,很多后端往往会不考虑这个技术方案呢——原因也就在于,他引入了更大的问题:

  • HTTP Code 无法覆盖全部语义
  • 特殊业务接口无法满足:

    • 搜索、翻译等「动作」
    • 批量删除无法使用 DELETE Method(不知道为什么的建议重学 HTTP
  • 前端用不到资源的全部字段,非常浪费
  • 监控需要改造成 method + path,对于单一资源的路由难以监控
  • 错误码监控的统计难度增大

前面几个问题的缺陷可能是一目了然的,但问题也不大,对于前端来说,语义也不需要覆盖到业务维度,似乎并没有多大烦恼;但对于后端来说,对于代码层的管理带来了一些好处,而带来了更多问题,比如我们在 JSON RPC Style 章节中说到的一个最大的好处:监控与告警统计。

method + path 可能可以监控接口和统计,但是对于一般统计来说,可能就会变成 GET api/v1/posts/1GET api/v1/posts/2 进行了分开统计,而实际上他们只是不同入口的同一操作;如果 method 没有纳入你们的监控当中,那么问题会变得更大。

同时,失去了业务维度 code 的支持,我们很有可能无法定位到具体的业务错误,大概率只能靠着 500 错误码去监控了。

GraphQL

我们发现了 Restful API 虽然纳入了对前端友好的行列,但是还是有一些值得优化的地方,比如我刚刚没有介绍的资源浪费,其实带来更大的问题是,我们要组合资源时,可能需要发送好多个请求,在 HTTP1.1 里,这绝对是一个 bad case。

而 GraphQL 可以解决一下上述问题,如果你还不了解什么是 GraphQL,可以通过官网稍作了解,也可以阅读GraphQL 从入门到入土,在这里我们不会从零开始介绍 GraphQL,只会稍微举几个例子:

比如这里我们请求了两个对象,如果在过去的设计中,我们需要发起两个 HTTP 请求,但在 GraphQL 中,我们只需要一个请求就可以了:

  1. {
  2. empireHero: hero(episode: EMPIRE) {
  3. name
  4. }
  5. jediHero: hero(episode: JEDI) {
  6. name
  7. }
  8. }

返回值形如:

  1. {
  2. "data": {
  3. "empireHero": {
  4. "name": "Luke Skywalker"
  5. },
  6. "jediHero": {
  7. "name": "R2-D2"
  8. }
  9. }
  10. }

此外,GraphQL 具有强类型定义的特性,比如这里我们定义了一个 Character 对象。

  1. type Character {
  2. name: String!
  3. appearsIn: [Episode!]!
  4. }

从上面两个例子,我们大致可以开除以下几个也都爱你:

  • 最简化的路由形式,最强的表现力,通过 /graphql 路由一票解决
  • 可以选择前端所需的字段,按需传输
  • 强类型接口,前后端可以生成同样的类型约束,解放前端
  • 支持字段预处理(比如格式化时间)
  • 接口缝合机,任意拼接
  • 接口即文档,直接生成

这里重点说一下解放前端的点,由于 GraphQL 具有类型定义,因此后端只要改动接口,就必然会有所体现,而不是文档型的弱约束;同时,你可以通过 GraphiQL 免于接口文档的烦恼;更强的是,既然有了类型,为什么我不能通过类型生成 Typescript 类型呢?

16126194791708.jpg

对于客户端来说,这是一种非常「爽」的表现方式,因此保守前端推崇,但是推管推,为什么推不动呢?因为后端以近乎绝望的姿势迎来了以下挑战:

  • 无法有效利用 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,相关文档可见 一体化全栈方案

现在,后盾这么定义自己的「接口」:

  1. export async function get() {
  2. return 'Hello Midway Hooks'
  3. }
  4. export async function post(name: string) {
  5. return 'Hello ' + name
  6. }

而前端这么使用

  1. import { get, post } from './apis/lambda'
  2. /**
  3. * @method GET
  4. * @url /api/get
  5. */
  6. get().then((message) => {
  7. // Display: Hello Midway Hooks
  8. console.log(message)
  9. })
  10. /**
  11. * @method POST
  12. * @url /api/post
  13. * @body { args: ['github'] }
  14. */
  15. post('github').then((message) => {
  16. // Display: Hello github
  17. console.log(message)
  18. })

对于整个路由来说,根本没有一个地方显式写了 plain text 路由,但是却精准的可以请求到了某个 HTTP。基于函数的调用让我们的 return value 变得非常的清晰,再也不怕后端随便改接口了,也继承了 GraphQL 解决的其中一个烦恼:我们不用再写接口文档了。

但是也带来了很多问题:

首先,我们需要前后端同仓库引用吗?上述 demo 中,前后端其实是同仓库中变更的,但是其实我们通过包管理机制完全可以解决这个问题,引用外部仓库同样可以解决这个问题。

其次,后端一定要是 TypeScript(JavaScript) 吗,因为如果不是的话似乎就没有办法直接引用了?当然不是,在我们过去的开发经验中,已经有大量的「基于注释生成文档」、「基于 schema 生成代码」的案例,类比一下,我们同样也可以从其他语言转向供应给前端的 JS SDK。

那么接下来,我们如何让多个版本的 API 共存呢?很明显,我们可以分版本进行下发,这也就是为什么会引入 Serverless 的原因,因为它能让多版本共存变的更低版本,当然,这并不代表我们不用 Serverless 就无法解决。只是做到这一点会相对的更加麻烦和高成本。(当然 Serverless 本身也有很多麻烦,这不在本文进行讨论和介绍。)

16126194791708.jpg

但是后端缺因此更加绝望了,现阶段的开发过程中,对于后端是极不友好的,因为他目前不是一个成熟的解决方案,也就是说,面对形形色色的公司基础设施和场景,大部分公司可能经不起折腾,或者没有必要花这么大的成本投入研发上述我们所说的「带来的问题」对应的解决方案。

但就我个人来看,这确实是一个不错的解决方案,在未来的一定时间内,当解决了「行业内多语言实践方案」的问题之后,可能能成为下一代的通用方案。但不一定是一个万能银弹。

从目前的进程来看,API 设计仍然没有银弹,且很有可能在长期都不会有银弹,我们只能综合的考虑自己的业务场景去选择最适合我们的一种方案。

当然,上文似乎仍有一个问题没有解决,那就是「为什么我们的 API 往往会起名困难、魔幻命名」——原因是,英语水平感人之下多说多错,而这不是团队中某一个人能解决的问题,甚至比技术本身更难解决,所以后面我们的解决方案其实越来越多的去绕过 plain text 带来的英语问题。

好的,又水了一篇,下次见。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK