「在性能优化中妙用Promise」寒草🌿 教你对接口请求进行合并

5,554 阅读11分钟

大家好,我是寒草😈,一只草系码猿🐒。间歇性热血🔥,持续性沙雕🌟
如果喜欢我的文章,可以关注➕点赞,与我一同成长吧~

前言☀️

本文是妙用 Promise 实现 Deferred,并以此为基础实现对并发接口请求的合并,会在讲解思路的基础上手把手带大家实现这套代码。当然,在给大家看我的代码之前先给大家讲一下我为什么要去做合并接口调用的事情。

这篇文章希望大家可以看看结束语~拜托啦🌿

service层🌟

寒草最近换了组,用的一些技术栈也发生了改变,也是接触了grpc「此处开坑,以后会讲,但是在这个之前我会讲graphql」,现在我能体会到最大的变化就是服务端提供的接口粒度更细了。于是我们通常会对服务端的接口进行二次封装,以提供给视图层更好的接口调用服务。

image.png

这个service层的封装让前端需要做更多的业务逻辑,并且有以下几种用途(可能不全):

  • 接口合并,业务逻辑封装,为视图层提供更易用的服务
  • 将服务端不合理的设计屏蔽(服务端设计不合理,数据结构不合理,字段名不合理都在service层屏蔽掉)
  • 逻辑复用

接口粒度与一致性🌟

说了半天service层,那也只是铺垫,下面我继续说,这里我们期望的是我们通过一个标识去拿一个资源。

image.png

其实我们出于接口粒度与一致性考量,首先如果我们要支持处理批量和单个资源,我们可能要在service去暴露两套接口,并且这两套接口的粒度是不一样的,但是其实干的是一个事情,只不过一个是批量,一个是单个而已。

// 单个
export const getMeta = async (id) => {
    // xxx
    return meta;
}
// 批量
export const getMetas = async (ids) => {
    // xxxx
    return metas;
}

而且其中批量还是包括单个的。那么有人说了,那我们就向视图暴露批量接口不就行了么?

首先是获取单个资源要 getMetas([id]) ,这样用,即套一个[], 而且有的接口提供了batch,有的接口没有提供batch在使用上是割裂的。

所以我们拒绝冗余割裂的service层服务,并不会给视图层提供Batch接口,在视图层如果要调用同一个服务以使用不同的id拿到不同的多个同类资源(比如获取列表),就需要这样写:

const userMetas = Promise.all(UserIds.map(userId => getUserMeta(id));

这样整体的使用和实现上是一致的,即用单个id去拿对应的资源或者处理一件事。粒度和一致性都是统一的,和谐的。视图层(1 to 1)的接口调用逻辑上也是合理的。

性能问题🌟

但是如果只提供single的调用,不去做任何中间处理的话,会存在很大的问题:

  • 性能问题(可能同时发出大量的接口请求)
  • 开发体验问题

这里我解释一下开发体验的问题,因为我们grpc的接口调用在控制台的network中不能看到接口信息,不利于我们调试,所以我们会在控制台对接口信息进行打印。如果我们一次调用几十个接口,直接打印好几页,根本没法去定位问题。

于是服务端这个时候会提供对应的batch接口,说白了就是提供了批量处理的接口。这个batch接口其实相当于:

image.png

前端调了一个batch接口,服务端接收后再去拆成若干个调用。所以其实节省的其实是接口重复接口调用在数据传输中冗余数据和创建连接的时间消耗(不知道说的对不对,这是我的理解)

但是我们之前说了,我们从接口粒度和一致性考量不会给视图层暴露batch接口,所以视图层还是会调用获取单个资源的接口,那么我们就需要通过某种手段在service层对视图层调用的接口请求进行合并。

解决方案🌟

我想现在问题背景已经描述好了,现在需要的就是出一个结局方案了,其实很好想,我们完全可以做一个中转,将同一个接口的请求信息合并之后通过batch方法进行调用,内容如图所示:

image.png

  • 视图层还是一个id去获取一个资源
  • service层做参数收集转发调用服务端batch接口
  • 服务端对batch进行拆分去获取资源

那么整个的思路理清楚了,我们现在开始实现它✨~

正篇📖

Deferred方法实现📚

首先,我们需要一个延迟处理回调的方案,并可以传递一个参数,将batch方法的返回值返回给之前的接口调用,那么我们来实现一个Deferred,其实就相当于把Promise的resolve和reject方法提到了外层。

function Deferred() {
  if (typeof (Promise) != 'undefined' && Promise.defer) {
    return Promise.defer();
  } else if(this && this instanceof Deferred){
    this.resolve = null;
    this.reject = null;
    const _this = this;
    this.promise = new Promise((resolve, reject) => {
      _this.resolve = resolve;
      _this.reject = reject;
    });
    Object.freeze(this);
  } else {
    throw new Error();
  }
}

如何使用deferred

const deferred = new Deferred();
const promise = deferred.promise;
promise.then(res => {
    // xxxx 事件A
})
async function fn() {
    const arr = await xxx;// 事件B
    promise.resolve(arr);
}

解读一下,就是相当于我们的事件A要在事件B之后进行, 那么我们就可以在事件B结束之后,将 deferred.promise 进行 reslove ,并把结果返回给事件A

separate方法实现📚

下面,我们进入本篇文章的正题,将如何将请求合并,这里我提供了一个工具方法,为什么叫separate呢,这个词的意思是分割,因为我要用它将service调用的batch接口进行封装,使其在视图层用着还是一个id获取一个资源,但是调用服务的时候会合并为一个。所以它的含义其实是:batch接口的分割

那么,我把我要讲解的内容放在代码的注释里,方便大家一行一行的对照查看:

const separate = function (multipleApi, singleApi) {
  // 闭包,建立一个独立的作用域
  let length = 0;
  let argsList = [];
  let deferred = new Deferred();
  // 工具方法,用于在一次请求完成后对作用域变量初始化
  function init() {
    length = 0;
    argsList = [];
    deferred = new Deferred();
  }
  
  return (...args) => {
    // 收集参数
    argsList.push(args);
    return new Promise((resolve, reject) => {
      // 记录发出请求个数
      length++;
      // 记录当前请求是第几个,用作标识
      let index = length;
      // 设置定时器
      let timer = setTimeout(() => {
        // 清空定时器
        clearTimeout(timer);
        // 如果在定时器的回调中index和length相等,表示并发的请求结束了
        if (index == length) {
          // 有时候service不仅写了batchapi还写了single api,当只进行一次接口调用的时候调single api更高效
          if (length === 1 && singleApi) {
            // 兼容single api
            singleApi(...args).then(res => {
              init();
              resolve(res);
            }).catch(err => {
              reject(err);
            })
          } else {
            // 并发的多个请求结束,则调用batch接口
            multipleApi(argsList).then(resList => {
              // deferred进行resolve,通知前面调用的接口数据已经拿回来了,并把信息传递给它们
              deferred.resolve(resList);
              deferred.promise.then(resList => {
                init();
                resolve(resList[index - 1])
              });
            }).catch(err => {
              //如果batch接口报错,则reject
              deferred.reject(err);
              reject(err);
            });
          }
        } else {
          // 前面的接口调用的回调在deferred.promise的then中处理
          deferred.promise.then(resList => {
            resolve(resList[index - 1]);
          }).catch((err) => {
            // 处理错误
            reject(err);
          });
        }
      }, 0)
    });
  };
}

整体的流程图大概是:

image.png

整体的执行顺序从上到下:

  • 并发五个请求,同时进行信息收集
  • 请求结束,调用batch接口
  • 用batch接口的数据拆分并回填到之前五个请求的响应中

完整代码 + 示例 🌰

代码

function Deferred() {
  if (typeof (Promise) != 'undefined' && Promise.defer) {
    return Promise.defer();
  } else if(this && this instanceof Deferred){
    this.resolve = null;
    this.reject = null;
    const _this = this;
    this.promise = new Promise((resolve, reject) => {
      _this.resolve = resolve;
      _this.reject = reject;
    });
    Object.freeze(this);
  } else {
    throw new Error();
  }
}

const separate = function (multipleApi, singleApi) {
  let length = 0;
  let argsList = [];
  let deferred = new Deferred();

  function init() {
    length = 0;
    argsList = [];
    deferred = new Deferred();
  }

  return (...args) => {
    argsList.push(args);
    return new Promise((resolve, reject) => {
      length++;
      let index = length;
      let timer = setTimeout(() => {
        clearTimeout(timer);
        if (index == length) {
          if (length === 1 && singleApi) {
            // 兼容single api
            singleApi(...args).then(res => {
              init();
              resolve(res);
            }).catch(err => {
              reject(err);
            })
          } else {
            multipleApi(argsList).then(resList => {
              deferred.resolve(resList);
              deferred.promise.then(resList => {
                init();
                console.log(index - 1);
                resolve(resList[index - 1])
              });
            }).catch(err => {
              deferred.reject(err);
              reject(err);
            });
          }
        } else {
          deferred.promise.then(resList => {
            console.log(index - 1);
            resolve(resList[index - 1]);
          }).catch((err) => {
            reject(err);
          });
        }
      }, 0)
    });
  };
}

示例

async function multipleApi(arr) {
  console.log(arr)
  return arr;
}

async function singleApi(...arr) {
  console.log(arr)
  return arr;
}

const serviceApi = separate(multipleApi, singleApi);

const aa = async function() {
  const m = await Promise.all([serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n')]);
  console.log('promise.all', m);

  const l = await Promise.all([serviceApi('a','b','c'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n')]);
  console.log('promise.all', l);

  const n = await serviceApi(9, 1, 3);
  console.log('single', n);
}

aa();

运行结果

image.png

待办

当然现在它还并不完善的,比如:

  • 错误处理拆分
  • 重复请求的合并(key重复的请求,不是同一个api的请求) 最近比较忙,但是也会加入后续的计划中来哈

代码改进

增加了对重复请求的合并(key相同)

function Deferred() {
  if (typeof (Promise) != 'undefined' && Promise.defer) {
    return Promise.defer();
  } else if(this && this instanceof Deferred){
    this.resolve = null;
    this.reject = null;
    const _this = this;
    this.promise = new Promise((resolve, reject) => {
      _this.resolve = resolve;
      _this.reject = reject;
    });
    Object.freeze(this);
  } else {
    throw new Error();
  }
}

const separate = function (multipleApi, singleApi) {
  let length = 0;
  let argsList = [];
  let deferred = new Deferred();
  let paramsMap = new Map();

  function init() {
    length = 0;
    argsList = [];
    deferred = new Deferred();
    paramsMap = new Map();
  }

  return (...args) => {
    return new Promise((resolve, reject) => {
      length++;
      let requestIndex, responseIndex;
      const paramsStr = JSON.stringify(args);
      const _mapIndex = paramsMap.get(paramsStr);
      if(Number.isFinite(_mapIndex)) {
        responseIndex = _mapIndex;
      } else {
        responseIndex = length;
        argsList.push(args);
        paramsMap.set(paramsStr, responseIndex);
      }
      requestIndex = length;
      let timer = setTimeout(() => {
        clearTimeout(timer);
        if (requestIndex == length) {
          if (length === 1 && singleApi) {
            // 兼容single api
            singleApi(...args).then(res => {
              init();
              resolve(res);
            }).catch(err => {
              reject(err);
            })
          } else if(paramsMap.size === 1 && singleApi) {
            singleApi(...args).then(res => {
              deferred.resolve([res]);
              deferred.promise.then(res => {
                init();
                resolve(res)
              });
            }).catch(err => {
              deferred.reject(err);
              reject(err);
            })
          } else {
            multipleApi(argsList).then(resList => {
              deferred.resolve(resList);
              deferred.promise.then(resList => {
                init();
                resolve(resList[responseIndex - 1])
              });
            }).catch(err => {
              deferred.reject(err);
              reject(err);
            });
          }
        } else {
          deferred.promise.then(resList => {
            resolve(resList[responseIndex - 1]);
          }).catch((err) => {
            reject(err);
          });
        }
      }, 0)
    });
  };
}

async function multipleApi(arr) {
  console.log('api params', arr);
  return arr;
}

async function singleApi(...arr) {
  console.log('api params single', arr);
  return arr;
}

const serviceApi = separate(multipleApi, singleApi);

const aa = async function() {
  const m = await Promise.all([serviceApi('m', '0', 'n'), serviceApi('1'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n')]);
  console.log('M promise.all', m);

  // const l = await Promise.all([serviceApi('a','b','c'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n'), serviceApi('m', '0', 'n')]);

  // const n = await serviceApi(9, 1, 3);

  const n = await Promise.all([serviceApi('1'), serviceApi('1'), serviceApi('1')]);
  console.log('N promise.all', n);
}

aa();

// export default batchApi;


结束语☀️

image.png

那么这一篇文章就结束了,最近心情很不爽💢 ,想在结束语说一说「但是考虑到我的文章还是会有一些人看到,不能传递负能量,所以我在发之前删掉了一大串文字」,那我就说一点生活相关的吧,我最近感觉写文,编码,工作已经占满了我的生活,但是我也不是钢铁之躯,人是需要休息调整状态的,所以我有可能给自己放个假,放松放松,无论是心态还是身体~

我在 8.21 清晨写下了这两个计划,大家可以看出来我是个喜欢尝试的人,想做个自己的游戏,做个可视化生成html的平台,大家可以期待期待,或许有一天可以玩到我的游戏,用我的平台制作一个你们设计有你们自己风格的html贺卡,或许有一天更多人可以通过有趣的东西认识寒草🌿。

寒草计划通晒(非学习计划):

  1. vscode api翻译(workspace)
  2. 可视化拖拽生成html前端贺卡平台(支持创意工坊)
  3. hancao.Game(perhaps cocos)
  4. commiui (如何继续推进) 寒草,不只是个前端,请伙伴们期待

寒草文章计划通晒:

  1. 算法/计算机基础(计组,计算机网络,编译原理,操作系统)
  2. 更多领域的尝试,单一问题的更深层次探究
  3. 有我自己风格的demo/design
  4. 前端基础(但是我不太会讲我常用的技术栈) 寒草,不只是个前端,请伙伴们期待

以及,感谢掘金让我认识了很多伙伴,让我去做更多好玩的事,这一个月来认识了洛竹,飞哥,三心,大帅,大圣(祝大圣老师未来越来越耀眼,钞票数不停),羽飞,子弈,DevUI卡哥,零一, 风轮等等等等,以及各种草木飞钞火土龙系工程师,以及愿意与我一起努力成长交流的读者朋友,谢谢大家,有幸认识你们🌟

也感谢神光大佬的课题指示~

image.png

寒草🌿一直都在~
请对我保持期待,我还有一系列的大活儿,但是容我保密☀️,给我一个月的准备时间

写在最后
神佛济世于我有何用
大道了悟又能得谁称颂
烈焰成绫🔥
金棍玄甲撼天宫

敢问路在何方
路在脚下🌟

伙伴们,如果喜欢我的文章,可以点赞 👍 关注➕ ,这是对我最大的支持。

加我微信:hancao97,邀你进群,了解寒草🌿 的github小组现状,一起学习前端,成为更优秀的工程师~(群二维码在这里->前端晚晚睡, 二维码过期了的话看链接沸点中的评论,我会把最新的二维码放在评论区,当然也可以加我微信我拉你进群,毕竟我也是有趣的前端,认识我也不赖🌟~)