如何设计一个请求库的封装?

353 阅读6分钟

如何做一个请求库的封装?

虽然前端具有诸多成熟的请求库,但在实际项目开发中发现,它们很难完全契合实际的开发需求。

axios

axios虽然很成熟,但它只是一个基础库,没有提供诸多的上层功能,比如:

请求重试

顺带手写一个?

// 请求重试
async function retryRequest(url, retries = 3) {
  try {
    const response = await axios.get(url);
    return response.data;
  } catch (error) {
    if (retries > 0) {
      console.log('请求失败,重试中...');
      return retryRequest(url, retries - 1);
    }
    throw new Error('请求失败,重试次数已用完');
  }
}

请求并发

function concurRequest(urls,maxNum){
    return new Promise(resolve=>{
        if(urls.length === 0){
            resolve([])
            return
        }
        const result = []; //保存结果
        let index = 0 //下一个请求的坐标
        let count = 0 //请求完成数
        async function request(){
            const i = index
            index++
            try{
                const res = await fetch(urls[index])
                result[i] = res
            }catch(err){
                 result[i] = err
            }finally{
                count++
                if(count === urls.length){
                    resolve(result)
                }
            }
        }
        const times = Math.min(urls.length,maxNum)
        for(let i= 0;i<tiems;i++){
            request()
        }
    })
}

请求超时

// http请求超时参考代码
function timeout(fn, ms) {
  const mainPromise = fn();
  const timeoutPromise = new Promise((resolve, reject) => {
    setTimeout(() => reject('timeout...'), ms);
  });
  return Promise.race([mainPromise, timeoutPromise]);
}

VueRequest / SWR

它们虽然提供的功能很多,但仍然存在诸多问题:

  1. 与上层框架过度绑定导致开发场景受限,也无法提供统一的API
  2. 成熟度不够,issue的回复也难以做到及时,存在一定风险
  3. 它们没有聚合基础请求库,仍然需要手动整合

除此之外更重要的是

公共库不包含公司内部制定的协议规范,即便使用公共库,也必须针对它们做二次封装。

综上,需要自行封装一套适配公司业务的前端请求库

方案和实现

结构设计:

结构

整个库结构包含三层,从下往上依次是:

  • 请求实现层: 提供请求基本功能
  • request-core: 提供网络上层控制,比如请求串行、请求并行、请求重试、请求防重等功能
  • request-bus: 为请求绑定业务功能,该层接入公司内部协议规范和接口文档,向外提供业务接口API

层是一种对代码结构的逻辑划分,在具体实现上可以有多种方式:

  • 每个层一个npm包
  • 每个层一个项目子文件夹
  • ...

在三层中,请求实现层的实现有多种方式:

  • 基于XHR原生
  • 基于fetch原生
  • 基于axios等第三方库

这种实现的多样性可能导致这一次层的不稳定,而request-imp是基础层,它的不稳性会传导到上一层。

所以必须寻求一种方案来隔离这种不稳定性。

我们可以基于DIP(Dependence Inversion Principle,依赖倒置原则),彻底将request-core和请求的实现解耦,而typescript的类型系统让这一切的落地成为了可能,于是结构演变为:

优化结构

下面是示意代码:

request-core代码示意

// 定义接口,不负责实现
export interface Requestor {
  get(url:string, options:RequestOptions): Promise<Response>
  // 略...
}
​
// 本模块的大部分功能都需要使用到requestor
let req: Requestor;
export function inject(requestor: Requestor){
  req = requestor;
}
export function useRequestor(){
  return req;
}
​
// 创建一个可以重试的请求
export function createRetryRequestor(maxCount = 5){
  const req = useRequestor();
  // 进一步配置req
  return req;
}
​
// 创建一个并发请求
export function createParallelRequestor(maxCount = 4){
  const req = useRequestor();
  // 进一步配置req
  return req;
}

request-axios-imp代码示意

import { Requestor } from 'request-core'
import axios from 'axios';
​
const ins = axios.create();
​
export requestor: XRequestor = {
  get(url, options?){
    // 使用axios实现
  },
  // 其他请求方法
}

request-bus示意代码

// 为request-core注入requestor的具体实现
import { inject } from 'request-core';
import { requestor } from 'request-axios-imp';
inject(requestor);

这样一来,当将来如果实现改变时,无须对request-core做任何改动,仅需新增实现并改变依赖即可。

比如,将来如果改为使用fetch api完成请求,仅需做以下改动:

新增库request-fetch-imp

import { Requestor } from 'request-core'export requestor: Requestor = {
  get(url, options?){
    // 使用fetch实现
  },
  post(url, data, options?){
    // 使用fetch实现
  },
  // 其他请求方法
}

改变request-bus的依赖

- import { requestor } from 'request-axios-imp';
+ import { requestor } from 'request-fetch-imp';
inject(requestor);

请求缓存

请求结果怎么存?存在哪?缓存键是什么?

我们希望用户能够指定缓存方案(内存/持久化),同时也能够指定缓存键。

const req = createCacheRequestor({
  key: (config){
    // config为某次请求的配置
    return config.pathname; // 使用pathname作为缓存键
    },
  persist: true // 是否开启持久化缓存                     
});

存储有多种方案,不同的方案能够存储的格式不同、支持的功能不同、使用的API不同、兼容性不同。

为了抹平这种差异,避免将来存储方案变动时对其他代码造成影响,需要设计一个稳定的接口来屏蔽方案间的差异。

存储方案

export interface CacheStore{
  has(key: string): Promise<boolean>;
  set<T>(key: string, ...values: T[]): Promise<void>;
  get<T>(key: string): Promise<T>;
  // 其他字段
}
​
export function useCacheStore(isPersist): CacheStore{
  if(!isPersist){
    return createMemoryStore();
  }
  else{
    return createStorageStore();
  }
}

缓存何时失效?基于时间还是其他条件?

const req = createCacheRequestor({
  duration: 1000 * 60 * 60, // 指示缓存的时间,单位毫秒
  isValid(key, config){ // 自定义缓存是否有效,提供该配置后,duration配置失效
    // key表示缓存键, config表示此次请求配置
    // 返回true表示缓存有效,返回false缓存无效。
  },                  
});

如何实现?

核心逻辑:

function createCacheRequestor(cacheOptions){
  const options = normalizeOptions(cacheOptions); // 参数归一化
  const store = useCacheStore(options.persist); // 使用缓存仓库
  const req = useRequestor(); // 获得请求实例
  // 对请求进行配置(见后)
  return req;
}
// 对请求进行配置
// 注册请求发送前的事件
req.on('beforeRequest', async (config)=>{
  const key = options.key(config); // 获得缓存键
  const hasKey = await store.has(key); // 是否存在缓存
  if(hasKey && options.isValid(key, config)){  // 存在缓存并且缓存有效
    // 返回缓存结果
  }
})
​
req.on('responseBody', (config, resp)=>{
  const key = options.key(config); // 获得缓存键
  store.set(key, resp.toPlain()); 
})

请求幂等

幂等性是一个数学概念,常见于抽象代数

f(n)=1nf(n) = 1^n 无论n的值是多少,f(n)不变为1

在网络请求中,很多接口都要求幂等性,比如支付,同一订单多次支付和一次支付对用户余额的影响应该是一样的。

要解决这一问题,就必须保证: 要求幂等的请求不能重复提交

这里的关键问题就在于定义什么是重复

我们可以把重复定义为: 请求方法、请求头、请求体完全一致

因此,我们可以使用hash将它们编码成一个字符串。

function hashRequest(req){
  const spark = new SparkMD5();
  spark.append(req.url);
  for(const [key, value] of req.headers){
    spark.append(key);
    spark.append(value);
  }
  spark.append(req.body);
  return spark.end();
}

当请求幂等时,直接返回缓存结果即可。

样板代码

公司的API接口数量庞大并且时常变化,如果request-bus层全部人工处理不仅耗时,而且容易出错。

可以考虑通过一些标准化的工具让整个过程自动化~~~留给你们自行探索!!!