前言
对于一个前端工程师而言,每天都在面对的较多的需求场景就是调用后端的接口,但是因为众所周知的原因,前端目前已经有无数种调用接口的方式,例如:之前有基于 XHR、Axios、Fetch 进行封装的工具,大家都试图在统一接口的调用方式,但是他们看起来最后都需要再进行改造。于是,我们试图在 B 站开发一套能够综合上述工具之长处,并结合 B 站事实需要的工具, 推出一个具有统一错误处理、减少代码冗余、抹平风格差异、降低文档负担、优化代码提示等功能的统一请求库。
背景
为什么需要统一的请求库
作为一名研发,我们会面临各种各样的业务需求和技术场景,这导致我们不得不对大量的接口调用做差异化的设计和封装,再混合开发人员的风格的差异和历史问题,会导致各种各样问题的产生。
以下是比较典型的几个问题:
-
代码冗余或维护成本过大:受历史因素和业务需求影响,各团队和仓库中存在多版本请求库,比如为 SSR 和 CSR 定制的处理逻辑、基于 Vue2 和 Vue3 的封装,以及端内或 Web 的请求兼容处理等。这些库之间的模块相似但不同,导致维护和扩展性复杂;
-
存在性能问题: 公司之前的请求库存在功能堆砌导致代码过多、可能存在体积过大等问题而影响页面性能;
-
前后端无法协同进化:目前公司后端的基础/通用能力已经遵循统一的标准,因此每当后端提供一种基础能力需要前端接入时,前端由于是零散的并且没有一个统一的标准,带来的后果是不同的前端项目对应一种通用的后端能力需要各自开发,迭代成本高,这也是最严重的一个问题,其阻碍了前后端协同进化;
为了解决这些问题,我们设计了能够解决上述问题的统一请求库,并通过一些中间件模式的设计来尽可能减少包体积。
现状以及能力的对比
我们调研了社区已经有的一些耳熟能详的技术方案,看看是否已经符合我们的需求,同时剖析他们的优缺点以得出是否存在更佳方案的结论。
尽管市场上存在如 Axios 这样的成熟请求库,它们通过拦截器等机制提供了一定程度的扩展性,但在多端适配、中间件管理、动态配置等方面仍显不足。例如,Axios 虽然在 Web 开发中广受欢迎,但其在原生App内嵌H5页面的兼容处理、以及跨平台的灵活性方面存在局限。对比之下,我们新开发的基于中间件模式的统一请求库能够提供更为灵活的配置和扩展机制,不仅能够动态管理请求流程中的各个环节,还能更好地适应不同平台和应用场景的需求。
(client-server中请求库示意图)
我们再来看看浏览器原生提供的 XHR 和 fetch,XHR 的历史无需赘述,但 fetch 作为“下一代Ajax”标准,我们相信它能走的更远。但同时fetch目前只具备发起请求的核心能力,而在请求的前后处理方面,它并不直接负责这些部分,因为我们可以将其作为框架的一部分,即标准之一,因为这样才能让更多的团队适应以及接受这个约定。
社区似乎并不能完美覆盖我们的场景,不如我们先尝试定义一套协议,由于我们已经对 KOA 等服务框架比较了解,其设计模式启发了我们,因此我们决定以中间件模式作为我们的基础通用型协议来实现我们的“统一请求库”。
目标
首先,我们先为统一请求库下一个定义:
⭐️ 一个 标准、灵活、强大 的 服务调用工具集 ⭐️
我们主要从以下几个方面来看,统一请求库能够解决的问题:
1. 标准化和统一:统一不同前端仓库中的接口调用方式,从而降低因使用不同请求库而引发的行为差异和问题;
2. 完成场景优化:多平台代理优化( App 端 内嵌H5 场景的请求库) 彻底解决体积过大等历史问题;
3. 实现一键全局能力注册;
4. 集成基础设施:统一请求库与全团队的基础技术生态进行集成,形成生态;
设计思路与实现
模式选型思路
为了解决B站复杂的业务场景,我们先将应用拆成各个单一的场景。在各个单一的场景下,我们希望一个单例对应一个场景,并且我们考虑到业务的灵活多变性,需要提供集成插件式逻辑的能力。
我们首先确定的是需要基于面向对象开发。其次,我们将工厂集成方案放在一边,暂时因为这个在B站内部的历史方案的不足,已经得到了一定的验证。我们必须肯定的是 Axios 在前端领域网络请求库的翘楚地位,参考 Axios 中我们认为有价值的拦截器部分,以及 Koa 带来的中间件形式的启发,取二者做结合,这便有了前端领域的中间件模式。根据一开始的需求场景关系,有这么一张图可以理清工作原理。
(需求场景关系图)
这就是请求库基于面向对象设计,提供灵活插件能力,同时根据场景也能保持统一逻辑形式的原理。
中间件模式
在统一请求库中,中间件为我们提供了插件式粒度的集成能力。中间件模式通过将请求处理流程分解为一系列独立的功能单元,每个单元负责处理请求的一个特定方面,如日志记录、错误处理、数据转换等。这些中间件按照预定的顺序组成一个处理链,请求和响应数据在链中依次通过每个中间件,每个中间件可以对数据进行处理、修改或者直接终止请求。这种模式的灵感来源于 Koa 框架的洋葱模型,其中请求和响应像穿过洋葱的每一层一样,经过每个中间件的处理。这不仅增强了请求处理的灵活性和可扩展性,而且使得每个中间件都可以独立开发和测试,大大提高了代码的可维护性。它们在一定的秩序下这些中间件组合成一个请求逻辑,这个秩序,我们同样选择了洋葱模式。在开始我们的请求库示例之前,我们先介绍一下“洋葱模型”。
什么是洋葱模型?
想象一下,一个洋葱的结构——由许多层组成,每剥开一层就能看到下一层。在洋葱模式中,每个中间件都像是洋葱的一层,请求从最外层开始传递,依次穿过每一层中间件,直到达到核心处理逻辑,然后再逐层返回,每一层都有机会对请求和响应进行处理。
Koa的洋葱模型
我们的统一请求库也基于洋葱模型有自己的调用链:
请求库的洋葱模型
洋葱模式同时是一种责任链模式(Chain of Responsibility Pattern),这种设计模式相较于传统的大工厂模式优点十分明显,它能最大程度地降低耦合度,增强了每个节点的灵活性,并且职责极其明确。
当然,这种模式也有一定的缺点,例如调试较困难以及链的管理等问题,需要好的团队风格规范去约束。
因此我们需要约定请求库中间件的处理原则来尽量规避这些问题,例如我们原则上规定允许编辑上下文中的 request 对象,而不是 config 对象。
中间件的扩展性
按照职责的划分,我们有了明确的独立处理者。于是在职责明确前,做好基础协议的扩展也是必不可少的环节。为了实现统一的请求库,我们就要让它的各个部分做到可增、可减、可替换以及可拓展。
基础模型
提供一个中间件基础抽象类,作为所有插件的原型,这是面向对象开发中最重要的一步,同时也提供了类型检查以提示开发者实现必须的接口。
覆盖预设行为
从内部内置的中间件开始,我们就设计成可通过同名方式索引替换目标内置中间件,用户不仅可以通过自定义的方式创建同名中间件,也可以通过继承或者直接调用这些中间件的静态方法来快速实现内置能力的拓展。
另外,我们区分了 Fetch 中间件和其他所有中间件,因为从根源上这两类是有区别的。其他所有中间件在顺序上,按照先全局后临时的方式排列,最后的,也是作为洋葱中心的就是 Fetch 中间件,因此在覆盖这些预设行为上,我们也做了不同的接口区分。
例如实例化的时候,我们设计的接口默认是传入中间件列表,是因为修改 Fetch 是一个低频的行为,并且在传入对象配置时,可以让用户清晰的将二者分开。
...
/**
* 初始化
* @param initConfig 初始配置,参考interface
*/
(initConfig?: IHttpServiceInit | IHttpSvcMiddleware[]);
...
}
// interface
export interface IHttpServiceInit {
baseURL?: FetchBaseURL
fetch?: IHttpSvcMiddleware
middlewares?: IHttpSvcMiddleware[]
}
范围制定
我们提出了“全局”与“临时”这样的范围概念。全局中间件的作用范围在实例化的时候就已经确定,至于这个中间件逻辑是否真正作用于该实例发起的每一个请求,其实还是掌握在用户手中。通过基于基础抽象类去派生中间件,这种模式允许用户自行定义子类的更多行为,这从某种意义上也是扩展性的一部分。
例如,用户可以真正实现 Provide/Inject,即先注入,后按需使用,这完全取决于你在撰写中间件内部逻辑时是否默认开启功能,从而在后续调用请求时,是否允许通过一个激活的行为,将逻辑开关打开。
const globalMeta = (ctx, next, config) => {
if (config?.payload?.active) {
ctx.request.params["meta"] = {
platform: "web",
device: ""
}
}
await next()
}
class GlobalMeta extends Middleware {
name = "GLOBA_META"
constructor() {
super(globalMeta)
}
}
const httpSvc = new HttpService([new GlobalMeta()])
// 默认该能力注册,但是没有激活标识不会工作。
httpSvc.with("GLOBAL_META", { active: true })
// 通过指定激活全局注册过的中间件名来使其工作(使活跃)
从这些目标出发,可以找到一一对应的关系:
-
索引键——中间件名称
-
全局注册方式——Register API
-
临时携带处理者及状态——With API
-
通过索引中间件名称的方式,临时禁用全局能力——Disable API
Package的分离
我们将 middleware 作为一个独立的包,而不是作为请求库这个框架的子导出成员,考虑这样做的原因是可以减轻用户制作中间件的理解成本,并且允许这种方式的一个重要条件是中间件的基础形态已经确认。
控制器
我们为请求库设计了三个控制模块:
1. ConfigCtrl,配置控制器
2. AssembleCtrl,装配控制器
3. RequestCtrl,请求控制器
如若用一句话串联起这三个模块:在使用请求库发起请求时,调用装配控制器收集全部的临时中间件与 Payload(载荷),同时将 Payload 存入配置控制器中,收集完毕后通过组合中间件逻辑产出请求方法,同时配置控制器将产出中间件配置上下文,将这些物料一齐汇向请求控制器,创建请求上下文,发起请求。
通过依赖注入的方式,将三个职责分明的模块相互关联起来:
整体设计图
装配控制器(AssembleCtrl)
顾名思义,装配控制器负责装配各种物件,具体物件包括:
1. 中间件
2. 临时 Payload
它既可以为已经注册过的全局中间件装配 Payload,也可以携带临时中间件一同挂载 Payload;除了增,也能减;我们也提供了禁用功能,可以禁用那些已经全局注册的中间件;
这一增一减就能够灵活控制用于发起请求的所有中间件的活跃态;并且临时 Payload 的设计相较于传统的一个大 Config 对象模式更直观清晰,这有效地避免了配置成员无限增加这种不可维护的趋势,同时相较于大对象内置逻辑的这种方式,我们的中间件逻辑都是可热拔插的。
配置控制器(ConfigCtrl)
配置控制器为每次请求生成中间件上下文,所有的配置都记录在此,组装时参与各种设置配置,运行时为中间件注入配置。
请求控制器(RequestCtrl)
在装配控制器为核心的模式下,准备好一切条件,发起请求时调用请求控制器,请求控制器用于将组装好的请求函数(RequestFunction),结合中间件配置上下文以及初始请求配置生成 Context 后,执行请求。
小结
装配控制器作为链式模式最重要的控制器,也为 HTTP Service 提供直接的 API,实现它循环链式调用能力的核心需要实现一个 Dispatcher(调度器)。
通过这个调度器,三个控制器在一次请求调度发起时的工作流程如下:
调度流程图
实现组合排列
通过调度器的调度,无论是添加 Payload 还是设置禁用,我们都要有一套明确的组织规则去将调度后得到的资源进行有序整合。上文提到,我们确定了先全局、后临时的基调,这在直觉上也是符合规律的。
全局注册的时机相对是靠前的,这个时机远远早于临时携带的部分,所以才会有全局注册能力在前这一说法。当然,想到这里,我们也考虑到用户也可能有想操作全局已经固定位置的中间件顺序,因此我们可以继续向上扩展,于是有了全局中间件提权的逻辑。
全局提权行为
成果
直接收益
各个团队通过接入统一请求库,首先在接入公司级通用能力时,仅需通过安装 NPM 包即可快速集成,所需开发资源大大降低。在适配多种平台方面,我们提供了平替老 SDK 的专用 Fetch 中间件包,解放了受困于旧的封装形式,稳固了中间件拓展集成的风格,而且还为构建一个健康、可持续发展的前端技术生态打下了坚实的基础。
价值模型
横向对比
假设我们调用一个API,要实现:
1. 对入参进行 encode
2. 请求需要上报状态(成功or失败)
3. 需要获取 headers 里的某标记
4. 该接口返回的是非 JSON 格式(如纯文本)
5. 服务端渲染请求时需要透传 headers(user-agent,ip 等 kv)
传统方式
// 传统 API 形式
// http 内部需要实现 encode,responseType 等逻辑
http.request({
url: '/xxx',
params: { id: 1 },
encode: true,
report: true,
responseType: 'text',
responseAll: true,
headers: {
...(typeof window === 'undefined' ? context.headers : {})
},
})
每需要增加一个 config key,都要深入http内核里去增加一个 if case,内核会随着迭代越来越大直至耦合到难以拆分的地步。
统一请求库方式
// 请求库
httpSvc
.with(encodeHandler),
.with(reportHandler),
.with('RES_DATA', { type: 'text' }) // 对应 responseType
.disable('RES_EXTRACT') // 负责从 response 对象中取得data数据,我们此举会禁用该中间件从而实现获取到整个响应对象,对应上面的responseAll的输入
.with('SERVER_SIDE', { headers: context.headers || {} })
.request({
url: '/xxx',
params: { id: 1 },
})
对比传统方式的每增加一个功能就要增加一个配置键这种形式,请求库通过链式方式顺序一一调用指定中间件的方式,直观清晰,并且充分解耦,好维护。
结语
随着 HttpService 的不断成熟和完善,它已成为我们处理前端网络请求不可或缺的基础能力之一。它的灵活性和扩展性极大地简化了前端开发工作,使我们能够更加专注于创造出色的用户体验。随着技术的不断进步,我们相信,基于中间件模式的请求库将继续引领前端请求处理的创新,为开发者带来更多可能性。
通过上面的介绍,相信你已经理解如何使用请求库的几项基本能力了,如果想要更多案例,请前往我们的 Github(https://github.com/bilibili/http-service)站点,在那您将获得:
-
内置中间件定义及说明
-
社区已发布的公共中间件
-
更全的设计介绍,方案对比
-
玩坏中间件的N种方式
-
更多精彩等待您的发现与加入
-End-
作者丨Josper