谁是最酷炫的 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": []}
}

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

16126174850246.jpg

我把这种风格称为盲盒式 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 中自定义业务级别的错误码,类似:

错误码消息解释
-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 设计入门

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 开始,我们的错误提示总算变的人性化起来了。

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 中,我们只需要一个请求就可以了:

{
  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 类型呢?

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,相关文档可见 一体化全栈方案

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

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 本身也有很多麻烦,这不在本文进行讨论和介绍。)

16126194791708.jpg

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

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

总结

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

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

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

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: 知识

添加新评论