前言

去年的某个时候就想写一篇关于接口的吐槽,当时后端提出了接口方案对于我来说调用起来非常难受,但又说不上为什么,没有论点论据所以也就作罢。最近因为写全栈的缘故,团队内部也遇到了一些关于接口设计的问题,于是开始思考实现接口的最佳实践是什么。在参考了许多资料之后,逐渐对这个问题有了自己的理解。同时回想起过去的经验,终于恍然大悟自己当时的痛点在哪里。

既然是吐槽,那么请原谅我接下来态度的不友善。本文中列举的所有例子都是我个人的亲身经历。

谁应该主导接口的设计

或者更直白一些,应该是接口的消费方还是提供方来决定接口的设计?

当然是接口的消费方

「接口」最吊诡的地方在于提供方大费周章把它实现了,但它自己却(几乎)重来都不使用。于是这极易陷入一种自嗨的境地,因为他更本不知道接口的好坏。就好比一个从来不尝自己做的菜的厨子,你指望他的菜能好到哪里去,他的厨艺能好到哪里去。上面隐含的前提是(我认为)接口是有绝对好坏之分的,坏的接口消费者调用难受,提供者维护难受,还导致产品行为别扭体验变差。

然而接口的好坏与谁来主导设计有什么关系?因为坏接口产生的原因之一是提供方只站在开发者的角度解决问题:


例子一 ( Chatty API )

某次需要实现允许用户创建仪表盘页面的功能(如果你对仪表盘页面感到陌生的话,可以想象它是一张集中了不同图表的页面,比如柱状图、折线图、饼图等等。用户可以添加自己想要的图表到页面中,并且手动调整它们的尺寸和位置。仪表盘通常用于总览某个产品或者服务的运行状态)。后端同学的接口初步设计是,当用户填写完基本信息、添加完图表、点击创建按钮之后,我需要连续调用两次接口才能完成一次仪表盘的创建:

1. 利用用户填写的基本信息以及图表的尺寸和位置创建一个空的仪表盘
2. 再向仪表盘中填充图表的具体信息,比如图表类型,使用的维度和指标等

很明显看出他完全是按照自己后端的存储结构在设计接口,不仅是存储结构,甚至存储过程都一览无余。想象一种极端的情况,那不只提供一些更新数据库表的接口得了,前端自己把通过接口把数据插入库中

面对这类底层性质的接口,消费者在集成时需要考虑接口的调用步骤以及理解背后的原理。如果后端的底层结构一旦发生更改,接口很有可能也需要发生更改,前端的调用代码也需要随之更改。

后端研发可能会辩解说:后端用了微服务啊,不同类型的数据存储在不同的服务上,所以你需要和不同的服务通信才能实现完整的存储。他们始终没有明白的事情是,后端的实现导致了接口的碎片化,那是你的问题,而不应该把这部分负担转移到前端的开发者上,其实也是间接转移到了用户身上。不要欺负我不懂后端,至少我了解加一层类似于 BFF 的 Orchestration Layer 就能解决这个问题

Netflix 的工程师 Daniel Jacobson 在他的文章 The future of API design: The orchestration layer 中指出, API 无非是要面对两类受众:

1. LSUD: Large set of unknown developers
2. SSKD: Small set of known developers 

随着产品服务化的趋势,很有可能需要像 AWS 或者 Github 那样对公共开发者即 LSUD 暴露接口。且不说上面例子中的接口方案会不会被唾沫星子淹死,如此明显的暴露内部服务的细节是非常危险的事情。

所以在设计接口时,应该让消费者来主导。如果消费者没能给出很好的建议,那么至少提供者在设计时也应该站在消费者的立场上来思考问题。又或者,至少想一想如果你自己会乐意使用用你自己设计出来的接口吗?

使用后端思维设计接口不仅体现在 URI 的设计上,还有可能体现在请求参数和返回体结构上:


例子二

假设现在需要一个请求批量文章的接口,接口同时返回多篇文章的内容,包括这些文章的内容,作者信息,评论信息等等。

理想情况下,我们期望返回的数据是以文章为单位的,比如

articles: [
	{
  		id: ,
        author: {},
        comments: []
	},
    {
    	id:
        author: {},
        comments: []
    }
]

However, 后端的返回结果可能是以实体为单位:

{
    articles: [],
    authors: [],
    comments: []
}

comments 里包含不同文章的 comment,我必须通过类似于 articleId 的字段对它们执行 group by 操作才能分离出属于不同文章的评论。对其他实体做同样的操作,最终手动的拼接成前端代码需要的 articles 数据结构

很明显这又是按照后端库表关系返回的结果,严格来说这并不算是 anti-pattern,在 redux 中也鼓励将数据 normalize。但如果前端用不到原始数据,请不要返回原始数据。例如我需要在页面上展示一个百分比格式的数据,除非用户有动态调整数据格式的需求,例如千分位、小数或者是切换精度等等,否则就直接返回给我百分比的字符串就好了,不要返回给我原始的浮点数据。前端对数据的二次加工还会给问题排查带来干扰,如果任何数据都需要前端进行二次加工,那么所以问题的排查都必须从前端发起,前端确认无误后再进入后端排查流程,这始终会占用两个端的人力,并且 delay 了排查的进度

关于 meta 信息


例子三:

后端接口在返回时通常会带上 meta 信息,meta 信息包含接口的状态以及如果失败时的失败原因,便于调试使用。后端提供的接口的 meta 信息的数据结构如下:

{
    meta: {
      code: 0,
      error: null,
      host: "127.0.0.1"
    },
   	result: []
}

在我看来,以上数据结构有两个问题

meta 信息包含独立的状态信息

在包含状态码的 meta 信息接口设计中,一条默认的隐藏逻辑是:接口返回的 HTTP status code 一定是 200,数据是否真的获取成功需要通过 meta 里的自定状态码 code 进行判断(换句话说,上面你看到的接口实际上是“接口的接口”)。最终在前端的代码中也不需要通过 HTTP code 判断返回是否正常,只需要判断接口里返回的meta.code即可

但是谁给你们的自信保证后端接口一定是不会挂的?!无论后端如何保证接口的坚固,前端仍然需要首先判断 HTTP code 是否为 200,再判断meta.code是否与预期的符合一致。这和信任无关,和我程序的健壮有关。

既然无论如何都要对接口判断两次,那为什么不将meta.code与 HTTP code 合二为一?更何况我还需要再本地维护一份自定义 code 的枚举值,还需要和后端保证同步。这就涉及到下一个问题了:

meta 信息的存放位置

我们需要 meta 信息没有错,但是我们没有那么需要 meta 信息。这体现在几点:

  1. 我们真的需要一个平行于返回结果的字段展示 meta 信息吗?
  2. 每一次请求我们都需要 meta 信息吗?
  3. meta 信息一定要在 meta 字段里吗?

以请求失败的错误信息为例,错误信息只会出现在接口非正常返回的情况下,但我们应该始终在返回体中用一个字段为它预留位置吗?

在关于 meta 信息存在位置的这个问题上,我倾向于将它们整合进入 HTTP Header 中。例如meta.code完全可以使用 HTTP code 代替,我看不出始终要保证 200 返回以及自定义 code 的意义在哪里

而至于其它的 meta 信息,可以通过以X-开头的自定义 HTTP Header 进行传递。例如

Github API 中关于使用频率限制的信息就放在 HTTP Header 中:

Status: 200 OK
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4999
X-RateLimit-Reset: 1372700873

Design for today


例子四

我们需要为某个指标的折线图设计查询接口,查询以天为单位,也就是说该接口只会根据查询的日期返回指定日期的查询结果,后端提供的返回数据结构如下:

{
    data: [
        {
            date: "2019-06-08",
            result: [
                
            ]
        }
    ]
}

虽然需求很明确的指示只会返回某天的查询结果,但是后端还是决定给我返回一个数组。他这么设计的理由是为了防止日后需求发生改变需要返回多日的查询结果。

这看上去是很聪明决策:“看,我预见性的 cover 了一个未来的需求!”,但实际上愚蠢至极:你的确 cover 了一个需求,不过是一个当前并不存在,未来也不见得会发生的需求;而且如果你真的想写 future-proof 的代码,那么还有未来千千万万的需求等待着你实现。

问题在于没有人知道将来是否真的会允许同时查询多日数据,即使某天需要支持同时查询多日数据了,数据结构也不一定非要如此。在数据分析领域我们面临的查询需求并不是线性从单个到多个,在其他业务领域也是这样。

这样导致的后果是你花费多余的时间实现了不需要的代码,并且前端也需要配合这样的数据结构进行实现。并且在将来的维护中,每个看到返回体是数组的人都会纳闷为什么返回的结果明明只有一条,还需要用数组封装,是不是我遗漏了什么?于是不得不投入精力来验证是否真的有可能返回更多的数据。API 和代码应该是精准的,准确表达你想实现的一切而不存在有歧义

有人可能会说不就是多了一层封装吗?实现上也花不了多少的功夫何至于大惊小怪。抱歉我不是针对这一个 case,而是在强调任何场景下无论实现的难易都不应该添加无意义的代码,“勿以恶小而为之”就是这个道理

“关注当下”还有另一个维度含义:


例子五

目前我们已经有创建单个文章的接口,现在需要支持批量创建文章。后端给出的建议是:不如调单个接口多次?

例子六

目前已经有一个接口能够取得文章相关数据,比如内容、评论、作者等等。现在我们需要增加一个新的页面用于展示用户信息。后端给出的建议是:不如使用文章数据接口,里面已经包含了作者信息,这样就不用开发新的接口了


以上的例子看似都是想实现对接口的复用,但实际上起到的是事倍功半的效果

在例五中,虽然语义上“创建五篇文章”和“连续五次创建一篇文章”是等效的,但是在实现和操作层面并不是如此。且不说调用五次和调用一次的性能大不相同,批量创建的五篇文章可能存在顺序关系,可能需要事务操作。

在例六中虽然能够达到我们实现的效果,但这不能算是接口的复用,只能算是接口的 hack(hack 和复用的区别在于是否用物品的初衷功能做事情)。并且 hack 接口是有风险的,对于接口的提供者而言他们更关心接口服务“正统”的消费者,在这个 case 中接口的存在是为了展示完整的文章信息,如果有一天“文章信息”这个需求发生了变化很有可能会导致作者信息同时发生变化,缩减字段甚至取消字段。那么它们没有义务这些 hack 用户负责。一个接口本应该就专注一件事情

所以最理想的事情是,为当前专注的业务开发独立的接口。在例六的例子中,可能我们在开发一个独立请求作者的信息的接口时实现代码完全复制自另一个接口的实现,但是接口的隔离在长远看来能给功能的维护带来更大的便利

不仅限于 REST API

“接口”是一个概念。在概念之下如何实现它我们拥有很多种选择。目前看来绝大部分的方式是通过 REST API 来达成的,也并没有什么事情是 REST API 无法做到的,但事实上这几年技术的进步给了我们更多的选择,如果选择更有针对性的实现方案,效果会更好

例如在实时数据的场景下,理论上是由后端(有数据更新时)驱动前端视图的更新,这理应是 push 操作。但是在传统实现中,我们不得不仍然通过被动的等待和轮询实现功能。

对于事件驱动类型的需求使用 WebSocket 或者是 Streaming 似乎是更好的选择。如果是后端之间的交互还可以利用 WebHook。我通常对新技术持保留态度,但是不得不承认 GraphQL 在处理某些需求上也能够比 REST API 做的更好。并且大部分厂商对于 GraphQL 接口的支持表明它是可行的。

我了解实现 API 来只是后端实现功能的一个很小的环节,在接口背后是更多业务逻辑的修改和库表结构的更迭。甚至说接口部分有一半都是交给框架来实现的。但是,哪怕只有很小的机会,也应该把这个环节做到尽善尽美。

结束语

对于糟糕的接口设计我还能继续没完没了的抱怨下去,但突然然觉得洋洋洒洒的继续写下去似乎没有太大意义。讲真我不是来真的大吐苦水的,只是想表达接口设计也至关重要。在工作中痛心的看到很多问题明明用一些很基础的技巧就能够解决,而大家却对它熟视无睹以造成两败俱伤的境地。以上就是我认为的在接口设计中需要遵循的一些原则和考虑要素,相信能够解决大多数的痛点和避免部分的问题

后端同学们,如果你们有心让接口变得更好,多听听“消费者”的反馈。如果你们尝试使用过第三方接口开发过应用的话,例如 Slack、Github,你会发现它们的接口是在不断迭代的。不断有旧的接口被淘汰,新的接口投入使用。这种迭代背后不是闲着没事干,而是出于实际的用户的声音和需求

最后推荐我最近阅读的关于 API 设计的图书,收益匪浅:

  • Web API 的设计与开发
  • Designing Web APIs
  • APIs A Strategy Guide

##

深入理解 Webpack 打包分块(下)

前言随着前端代码需要处理的业务越来越繁重,我们不得不面临的一个问题是前端的代码体积也变得越来越庞大。这造成无论是在调式还是在上线时都需要花长时间等待编译完成,并且用户也不得不花额外的时间和带宽下载更大体积的脚本文件。然而仔细想想这完全是可以避免的:在开发时难道一行代码的修改...… Continue reading

深入理解 Webpack 打包分块(上)

发布于 2019年05月18日

仪表盘场景前端优化经验谈

发布于 2019年01月15日