【代码鉴赏】简单优雅的JavaScript代码片段(二):流控和重试

本系列上一篇文章:【代码鉴赏】简单优雅的JavaScript代码片段(一):异步控制

流控(又称限流,控制调用频率)

后端为了保证系统稳定运行,往往会对调用频率进行限制(比如每人每秒不得超过10次)。为了避免造成资源浪费或者遭受系统惩罚,前端也需要主动限制自己调用API的频率。

前端需要大批量拉取列表时,或者需要对每一个列表项调用API查询详情时,尤其需要进行限流。

这里提供一个流控工具函数wrapFlowControl,它的好处是:

  • 使用简单、对调用者透明:只需要包装一下你原本的异步函数,即可得到拥有流控限制的函数,它与原本的异步函数使用方式相同。const apiWithFlowControl = wrapFlowControl(callAPI, 2);
  • 不会丢弃任何一次调用(不像防抖节流)。每一次调用都会被执行、得到相应的结果。只不过可能会为了控制频率而被延迟执行。

使用示例:

// 创建了一个调度队列
const apiWithFlowControl = wrapFlowControl(callAPI, 2);

// ......

<button
  onClick={() => {
    const count = ++countRef.current;
    // 请求调度队列安排一次函数调用
    apiWithFlowControl(count).then((result) => {
      // do something with api result
    });
  }}
>
  Call apiWithFlowControl
</button>

codesandbox在线示例

这个方案的本质是,先通过wrapFlowControl创建了一个调度队列,然后在每次调用apiWithFlowControl的时候,请求调度队列安排一次函数调用。

代码实现

wrapFlowControl的代码实现:

const ONE_SECOND_MS = 1000;

/**
 * 控制函数调用频率。在任何一个1秒的区间,调用fn的次数不会超过maxExecPerSec次。
 * 如果函数触发频率超过限制,则会延缓一部分调用,使得实际调用频率满足上面的要求。
 */
export function wrapFlowControl<Args extends any[], Ret>(
  fn: (...args: Args) => Promise<Ret>,
  maxExecPerSec: number
) {
  if (maxExecPerSec < 1) throw new Error(`invalid maxExecPerSec`);
  // 调度队列,记录将要执行的任务
  const queue: QueueItem[] = [];
  // 最近一秒钟的执行记录,用于判断执行频率是否超出限制
  const executed: ExecutedItem[] = [];

  return function wrapped(...args: Args): Promise<Ret> {
    return enqueue(args);
  };

  function enqueue(args: Args): Promise<Ret> {
    return new Promise((resolve, reject) => {
      queue.push({ args, resolve, reject });
      scheduleCheckQueue();
    });
  }

  function scheduleCheckQueue() {
    const nextTask = queue[0];
    // 仅在queue为空时,才会停止scheduleCheckQueue递归调用
    if (!nextTask) return;
    cleanExecuted();
    if (executed.length < maxExecPerSec) {
      // 最近一秒钟执行的数量少于阈值,才可以执行下一个task
      queue.shift();
      execute(nextTask);
      scheduleCheckQueue();
    } else {
      // 过一会再调度
      const earliestExecuted = executed[0];
      const now = new Date().valueOf();
      const waitTime = earliestExecuted.timestamp + ONE_SECOND_MS - now;
      setTimeout(() => {
        // 此时earliestExecuted已经可以被清除,给下一个task的执行提供配额
        scheduleCheckQueue();
      }, waitTime);
    }
  }

  function cleanExecuted() {
    const now = new Date().valueOf();
    const oneSecondAgo = now - ONE_SECOND_MS;
    while (executed[0]?.timestamp <= oneSecondAgo) {
      executed.shift();
    }
  }

  function execute({ args, resolve, reject }: QueueItem) {
    const timestamp = new Date().valueOf();
    fn(...args).then(resolve, reject);
    executed.push({ timestamp });
  }

  type QueueItem = {
    args: Args;
    resolve: (ret: Ret) => void;
    reject: (error: any) => void;
  };

  type ExecutedItem = {
    timestamp: number;
  };
}

延迟确定函数逻辑

从上面的示例可以看出,在使用wrapFlowControl的时候,你需要预先定义好异步函数callAPI的逻辑,才能得到流控函数。

但是在一些特殊场景中,我们要在发起调用的时候,才确定异步函数应该执行什么逻辑。即将“定义时确定”推迟到“调用时确定”。因此我们实现了另一个工具函数createFlowControlScheduler

在上面的使用示例中,DemoWrapFlowControl就是一个例子:我们在用户点击按钮的时候,才决定要调用API1还是API2。

// 创建一个调度队列
const scheduleCallWithFlowControl = createFlowControlScheduler(2);

// ......

<button
  onClick={() => {
    const count = ++countRef.current;
    // 在调用时才决定要执行的异步操作
    // 将异步操作加入调度队列
    // 这2个异步操作共用一个流控额度
    if (count % 2 === 1) {
      scheduleCallWithFlowControl(() => callAPI1(count)).then(
        (result) => {
          // do something with api1 result
        }
      );
    } else {
      scheduleCallWithFlowControl(() => callAPI2(count)).then(
        (result) => {
          // do something with api2 result
        }
      );
    }
  }}
>
  Call scheduleCallWithFlowControl
</button>

codesandbox在线示例

这个方案的本质是,先通过createFlowControlScheduler创建了一个调度队列,然后每当scheduleCallWithFlowControl接受到一个异步任务,就会将它加入调度队列。调度队列会确保所有异步任务都被调用(按照加入队列的顺序),并且任务执行频率不超过指定的值。

createFlowControlScheduler的实现其实非常简单,基于前面的wrapFlowControl实现:

/**
 * 类似于wrapFlowControl,只不过将task的定义延迟到调用wrapper时才提供,
 * 而不是在创建flowControl wrapper时就提供
 */
export function createFlowControlScheduler(maxExecPerSec: number) {
  return wrapFlowControl(async <T>(task: () => Promise<T>) => {
    return task();
  }, maxExecPerSec);
}

扩展思考

如何改造我们的工具函数,让它能够支持“每分钟不得超过n次”的频率限制?或者让它能够支持“进行中的任务数量不能超过n”的限制?
如何改造我们的工具函数,让它能够同时支持“每秒钟不得超过n次”且“每分钟不得超过m次”的频率限制?如何实现更灵活的调度队列,让不同的调度限制能够组合起来?

举个例子,频率限制为“每秒钟不得超过10次”且“每分钟不得超过30次”。它的意义在于,允许短时间内的突发高频调用(通过放松秒级限制),同时又阻止高频调用持续太长之间(通过分钟级限制)。

重试

前面我们已经得到了一个在前端限制调用频率的方案。但是,即使我们已经在前端限制了调用频率,依然可能遇到错误:

  1. 前端的流控无法完全满足后端的流控限制。后端可能会对所有用户的调用之和做一个整体限制。比如所有用户的调用频率不能超过每秒一万次,前端流控无法对齐这种限制。
  2. 非流控错误。比如后端服务或网络不稳定,造成的短暂不可用。

因此,面对这些前端不可避免的错误,需要通过重试来得到结果。这里提供一个重试工具函数wrapRetry,它的好处是:

  • 使用简单、对调用者透明:与前面的流控工具函数一样,只需要包装一下你原本的异步函数,即可得到自动重试的函数,它与原本的异步函数使用方式相同。
  • 支持自定义要重试的错误类型、重试次数、重试等待时间。

使用方式:

const apiWithRetry = wrapRetry(
  callAPI,
  (error, retryCount) => error.type === "throttle" && retryCount <= 5
);

它的使用方式与wrapFlowControl类似。

代码实现

wrapRetry代码实现:

/**
 * 捕获到特定的失败以后会重试。适合无副作用的操作。
 * 比如数据请求可能被流控拦截,就可以用它来做自动重试。
 */
export function wrapRetry<Args extends any[], Ret>(
  fn: (...args: Args) => Promise<Ret>,
  shouldRetry: (error: any, retryCount: number) => boolean,
  startRetryWait: number = 1000
) {
  return async function wrapped(...args: Args): Promise<Ret> {
    return callFn(args, startRetryWait, 0);
  };

  async function callFn(
    args: Args,
    wait: number,
    retryCount: number
  ): Promise<Ret> {
    try {
      return await fn(...args);
    } catch (error) {
      if (shouldRetry(error, retryCount)) {
        if (wait > 0) await timeout(wait);
        // nextWait是wait的 1 ~ 2 倍
        // 如果startRetryWait是0,则wait总是0
        const nextWait = wait * (Math.random() + 1);
        return callFn(args, nextWait, retryCount + 1);
      } else {
        throw error;
      }
    }
  }
}

function timeout(wait: number) {
  return new Promise((res) => {
    setTimeout(() => {
      res(null);
    }, wait);
  });
}

其中,我们增加了一个优化点:让重试等待时间逐步增加。比如,第2次重试的等待时间是第一次重试等待时间的1 ~ 2 倍。这是为了尽可能减少调用次数,避免给正处于不稳定的后端带来更多压力。

没有选择2倍增加,是为了避免重试等待时间太长,降低用户体验。

可组合性

值得一提的是,自动重试可以与前面的限流工具组合起来使用(得益于它们都对调用者透明,不改变函数使用方式):

const apiWithFlowControl = wrapFlowControl(callAPI, 2);
const apiWithRetry = wrapRetry(
  apiWithFlowControl,
  (error, retryCount) => error.type === "throttle" && retryCount <= 5
);

注意,限流包装在内部,重试包装在外部,这样才能保证重试发起的请求也能受到限流的控制。


csRyan的学习专栏
分享对于计算机科学的学习和思考,只发布有价值的文章: 对于那些网上已经有完整资料,且相关资料已经整...

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
0 条评论
推荐阅读
手写一个Parser - 代码简单而功能强大的Pratt Parsing
在编译的流程中,一个很重要的步骤是语法分析(又称解析,Parsing)。解析器(Parser)负责将Token流转化为抽象语法树(AST)。这篇文章介绍一种Parser的实现算法:Pratt Parsing,又称Top Down Operator Precede...

csRyan阅读 2.4k

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木140阅读 11.9k评论 10

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木60阅读 5.9k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.1k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木39阅读 7.1k评论 6

CSS 绘制一只思否猫
欢迎关注我的公众号:前端侦探练习 CSS 有一个比较有趣的方式,就是发挥想象,绘制各式各样的图案,比如来绘制一只思否猫?思否猫,SegmentFault 思否的吉祥物,是一只独一无二、特立独行、热爱自由的(&gt;^ω^&lt...

XboxYan41阅读 2.8k评论 14

封面图
还在用 JS 做节流吗?CSS 也可以防止按钮重复点击
举个例子:一个保存按钮,为了避免重复提交或者服务器考虑,往往需要对点击行为做一定的限制,比如只允许每300ms提交一次,这时候我想大部分同学都会到网上直接拷贝一段throttle函数,或者直接引用lodash工具库

XboxYan34阅读 2.2k评论 2

封面图

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart...

1.1k 声望
181 粉丝
宣传栏