前端业务开发的通用经验 - 接口篇

4,589 阅读12分钟

接口复用导致的问题

接口设计有三个层面的复用,分别是:跨项目复用、跨页面复用、跨端复用。正确的复用,能提高效率,而错误的复用,只会降低效率。


1、跨项目复用

除非明确抽象为独立的公共服务,接口是不应该跨项目复用的。

从业务建模的角度看,复用的基本前提是同步,即服务的迭代必须同步应用到所有依赖方,只要不同步,仅一部分依赖方需要新逻辑,另一部分不需要,那么复用就一定会变成多套逻辑的杂糅。既然已经划分项目了,那么业务逻辑大概率会走向两个分支,仍然复用,只会增加系统负债。

从组织划分角度看,项目拆分后,维护方可能不再是同一个团队,如果仍然存在复用接口,会导致权责边界不清晰,还可能因为解耦不彻底,导致互相干扰,产生故障。

接口跨项目复用,通常是因为新开项目需要紧急上线,为了求快,直接在新项目里复用老接口,说是后面要拆,但大概率没时间拆,TODO 为啥往往没有结果?因为没有明确责任人和 deadline 的 TODO 就等于完全无效。还有一种情况,就是项目拆分时,没有拆干净,这种暗坑,迟早会显露出来。

所以每个项目,尤其是从某个已有系统拆分或克隆出来的新项目,必须厘清依赖,避免因切割不彻底产生问题。如果仍然保持复用,并且还在不断往里迭代逻辑,等于不断积累债务,后面再搞拆分,成本将成倍增加。


2、跨页面复用

除非是明确的同类场景,接口跨页面复用也会存在问题。

比如某些公共配置,因为每个页面都需要其中一部分信息,于是全部融合到一个公共接口。如果某一个页面要单独增加配置信息,就不得不继续加到公共接口里。越到后面,公共接口的逻辑越膨胀,性能越差,所有页面都受影响。而且公共逻辑的维护成本和变动风险也越来越大,历史逻辑越堆越多,就算看不懂也不敢改,一旦出错,所有页面都受影响。

此外,静态的配置信息因为不常变动,因此适合做缓存。所有页面都共用一个配置接口也不利于缓存,因为哪怕只改了某个页面的某一项配置,整个数据缓存都将失效。

因此接口设计上,复用的层级应该降到微服务层或者 service 层,然后在 controller 层进行组装,单独为每个页面提供各自的接口。


3、跨端复用

接口一旦有 native 端使用,就应该视为“被封印”的接口,为了兼容历史版本,通常后续开发只能加字段,既不能删字段,也不能改字段(除非接口作版本控制,但这个很恶心)。

一定程度上这给前端带来了某种好处,因为很多时候前端可以凭此特性,强势要求把逻辑挪到后端:“因为 native 有版本问题,一旦上线没法改 😁”。哈哈,其实更合理的理由是:跨端复用。相比 native + 前端重复实现,把逻辑挪到后端,整体上效率更高,而且问题排查的边界也更清晰:无需纠结逻辑在前端还是后端实现,前端就是纯粹的接口数据可视化界面。


接口格式不统一的问题

前端作为聚合各类资源的生产链终端,只要对接超过 1 个后端团队,大概率会遇到接口格式不统一的问题。具体表现有:

  • 数据格式不一致。主要是状态码和异常信息的定义不一致,比如状态码,不仅用哪个 Int 表示【正常】无法统一,连是否用 Int 格式的状态码都不一定统一,确实就有人用字符串格式的状态码
  • content-type 不一致,有的 x-www-form-urlencoded,有的 json
  • 参数位置不一致。有些 post 接口偏要在 query 里取参,有些接口遵照 restful 规范把参数定义到了 path 里,有些明明可以也应该从 cookie 里取的参数偏要在请求时传参
  • 多种不同的异常,共用同一个状态码
  • ...

面临上述问题的情况下,请问公共的网络请求方法怎么封装?如何做统一的异常处理与监控?

前端是页面接口的需求方,后端作为供应方按照页面需要的数据拼装接口。供应方通常只关心数据是否够用,而不关心数据格式,因为格式是需求方才关心的事,按照【谁痛谁解决】的原则,所以得由前端去定标准和卡验收,比如前端来出接口文档,就是一种办法。

前端也可以自己写接口(写 controller),不过这可能带来一些问题,不仅仅只是增加工作量的问题,后面有一节会讲。

前端还需要一些灵活的应对方案,比如将公共的网络请求方法,做成可配置的:

const requestServiceA = new Request({
    contentType: 'json',
    normalStatus: 0,
    commonParams,
    host
})
const requestServiceB = new Request({
    contentType: 'form',
    normalStatus: 200,
    commonParams,
    host
})
requestServiceA.get(...)
requestServiceB.post(...)

还有一种方案是在网关层或代理层做一次适配改造,当然,前提是得有网关或代理的能力。


字段复用与二义性问题

假设有三个页面用到了同样的内容,复用同一个字段是否合理?是否复用同样取决是否【同步】,假设这个内容是在后台配置的,那么修改配置的人,知不知道改了这个配置,不仅会影响到页面 A,还会影响页面 B 和 C?假设页面 B 的内容要换成另一个怎么办?

以“提高效率、省事”为理由的复用,大概率是会出问题的。

另一种类型的复用,是语义上的复用,这个问题就更大了。举个例子,假设公交卡有三种类型:普通卡、学生卡、老年卡,普通卡都是按金额计价,学生卡都是按次计价,老年卡都是免费卡,那么能用【卡类型】来判断【计价模式】与【是否免费】吗?

const isFree = cardType === '老年卡' // 是否免费
const isPayPerUse = cardType === '学生卡' // 是否按次计价

如果你这么做了,需求一定会出来狠狠打你的脸,一定会出现非免费的老年卡、以次数计价的老年卡、以金额计价的学生卡。

每个字段应该只具备一个语义,不要用一个字段干两件不同的事情。


用 Get 还是 Post 的问题

有时候后端可能认为,get 和 post 不都一样用来请求数据吗,而且 post 还更安全,所以全用 post 好了。

opera 浏览器 CTO 罗志宇解释 post 与 get 请求的区别,应该很权威了吧,基本结论是:该用 get 还得用 get,因为有这样几个 post 没有的好处:

  • 可以直接在地址栏输入 url 打开,方便查看请求结果
  • 可以被缓存,提高性能
  • 网络失败时可以重试,因此 get 请求的网络成功率更高

接口请求的防重、节流和竞态问题

如果一个按钮只是绑定了请求事件,没做任何处理,那么短时间咣咣点击,连续发送多个异步请求,很容易出问题。如果是 create 型接口,那么会导致重复创建;如果是 get 型接口,那么可能遇到竞态问题

原则上,非幂等接口,要考虑防重;幂等接口,要考虑节流和竞态处理。

从监控的角度看,应该考虑对短时间内大量重复请求进行主动上报,因为这可能意味着出现死循环或者其他某种故障了。


breaking change 与版本控制问题

什么是接口的 breaking change?从技术层面上看,这些都是:

  • 参数的数据结构、字段类型变化
  • 新增参数字段
  • 响应的数据变化、字段类型变化
  • 删除响应的某个字段
  • method、content-type、其他 header 字段变化

从业务逻辑看,如果一个字段的语义发生变化,也是一种 breaking change。比如说接口返回公交车站点列表 stationList,某天产品提需求要加上地铁站点,如果还是直接加到 stationList,那么这个 stationList 就不再是原来的 stationList 了。原先的字段含义是【公交车类型的站点】,改后变成了【公共交通站点】,不同的语义,对应业务逻辑的处理肯定不一样。

一般 native 开发都知道,字段是绝对不能随便改也不能随便删的,只能加字段,因为 breaking change 意味着不兼容线上历史版本。只要接口发生 breaking change,同时使用方存在多版本的情况,就必须考虑版本控制。

native 和 RN 都存在多版本,Web 也可能存在多版本,比如前端采用灰度发布,那么已灰度发布的页面和线上已有页面就构成了新旧两个版本。

那么怎么做版本控制以兼容老版本呢?主要思路有三个:

  • 接口直接通过版本参数来控制。该方法有个局限,假设某接口存在多个调用方(比如几个不同的 app),每个调用方的版本号都不一样,处理起来就麻烦了
  • 通过参数的有无(而不是具体的值)来区分新老版本,加了新参数的,就是新版本,没加的,就是老版本,这就避免了上一种方法的局限
  • 新增接口,新版本调新接口,老版本调老接口。这种方法也可能有局限,比如有些操作依赖于接口的 path,path 变了,会对已有功能(缓存、预加载、报警配置、自动化用例等)产生影响,反爬和风控的同学可能也要骂娘(基于接口建设的特征需要重新适配)

接口文档的管理与维护问题

凡是靠人工编写的接口文档,时间一久,大概率是不会更新的。而且接口一多,往往不容易找到。一般来说,后端并不关心接口文档(尤其是面向前端的接口文档),因为看代码就可以了。后端也不喜欢写文档,因为程序员都不喜欢写文档。

接口文档,是非常标准化、非常适合用工具自动化管理的,理应不再手工维护,不过很多时候仍然无法全面推行,可能有这些原因:这个痛点不够大、工具太难用、路径依赖没人推动变化、必须先写代码才能生成文档...

这算是前后端分离带来的额外信息成本之一。有些问题不是技术问题,而是团队文化建设的问题。如果大家都能忍受接口文档,那么大概率事情就会一直如此。


前端 BFF 层的问题

前端搞 BFF 有很多可能的原因:

  • 前端想自己调 thrift 服务拼接口
  • 前端做接口代理转发,避免跨域问题
  • 前端搞服务,拓展技术领域

不管有多少理由,在我看来,就一个原因:前端找不到事干,又想干点写页面之外的事。

除非是重前端型业务(技术上服务端较轻、组织上前端配置重),前后端最好彻底分离。小型业务或者某些独立功能,前端可以折腾下服务,但是重后端的大型业务,前端最好克制与收敛下创造冲动,不要去搞与后端业务紧耦合的 BFF 层。

因为很难证明有什么额外收益(帮后端写 controller 这肯定不是收益),同时会增加维护成本。

  • 部署:前端不仅要处理静态资源的部署,还要额外操心 node 服务的部署
  • 监控:node 层怎么保证服务的可用性、稳定性,怎么监控异常?
  • 维护:当初搞出这套东西的人,很可能不再继续维护,拍屁股到其它地方造轮子去了,大概率连个文档也没留,注释也不写,接手的人,怎么去搞维护?怎么知道 node 层都做了啥?假设某个需求要改 node 层的某个地方,怎么知道改哪里?
  • 故障排查:因为多了一道 node 环节,导致故障排查也多了一个环节,前端说是接口问题,后端说是 node 问题,结果前后端都得去查日志、梳理链路,如果前后端彻底分离就不用费这功夫
  • 组织能力:前端写接口,意味着切入了后端服务模型,如果后端模块很多,业务逻辑复杂,这就对人产生了更高的要求(既要有意愿,还有有能力去承接 BFF 层的开发与维护工作),市场上这样的人多吗,能确保招得到吗