1

简单优雅的JavaScript代码片段文章系列:
简单优雅的JavaScript代码片段(一):异步控制
简单优雅的JavaScript代码片段(二):流控和重试
简单优雅的JavaScript代码片段(三):合并请求,成批发出

场景说明

后端提供的接口具备批量查询能力(比如,同时查询10个资源的详情信息),但是调用者每次只需要请求一个资源的详情信息。

比如:

import React from "react";

const List = ({ ids }: { ids: string[] }) => {
  return (
    <div>
      {ids.map((id) => (
        <ResourceDetail key={id} id={id} />
      ))}
    </div>
  );
};

const ResourceDetail = ({ id }: { id: string }) => {
  // 在这个组件请求资源详情并渲染...
  // 注意在这个组件中,你只关心这一个资源的信息,你不再具备”整个列表“的数据视角。
};

// 后端接口支持同时请求多个资源的信息
declare function api(ids: string[]): Promise<Record<string, { details: any }>>;

如何请求所有资源的信息呢?一般能想到2个方案:

  • ResourceDetail组件中,直接调用api来请求当前资源的信息:api([id]),只传入当前资源ID。也就是直接将批量请求接口当成单个请求的接口来用,忽略它的批量请求能力。

    • 好处是简单直接,易于理解;
    • 坏处是没有将接口本身的批量请求能力利用起来,发出过多请求,导致接口流控限制或效率低下。
  • 在上层的List组件中,批量请求多个资源的信息:api([id1, id2, ...]),然后将结果传递给子组件进行渲染。也就是将请求逻辑提升至更高的组件层次,在具备”整个列表“的数据视角的时候进行批量请求。

    • 好处是将接口本身的批量请求能力利用起来;
    • 坏处是代码耦合紧密(ResourceDetail依赖父组件帮它请求资源信息);代码逻辑设计迎合技术因素,不符合直觉造成List组件职责扩大、逻辑复杂(比如接口每次最多只能支持10个资源批量请求,因此你要将列表分为10个一组,分别请求)。

有没有两全其美方案呢?既能让组件职责简单清晰,又能将接口本身的批量请求能力利用起来?

解决方案

这篇文章介绍一个工具函数,将一个【批量请求】的接口转换成【单个请求】的接口:wrapBatchProcess。示例用法:

// 先使用wrapBatchProcess工具将api转换成单个请求的接口
// const wrappedAPI: (input: string) => Promise<{details: any}>
const wrappedAPI = wrapBatchProcess<string, { details: any }>(
  async (inputs, onResult, onError) => {
    // 工具函数将「汇集」成一大批请求,调用你提供的这个回调函数
    // 因此你就可以在这个回调中同时处理一大批请求了!
    const result = await api(inputs.map(({ input }) => input))
    Object.keys(result).forEach((key) => {
      // 请求到结果以后,将结果提供给工具函数,然后工具函数就会将结果提供给调用者
      onResult(result[key], key)
    })
  },
  // getKey函数,在这个场景下比较简单。实现中的代码注释有解释
  (input) => input
)

// 调用wrappedAPI,每次只需要传入一个请求。工具会自动合并请求,通过api来批量发出。
// 结果到达以后,wrappedAPI会将【对应于这个请求的结果】返回回来
wrappedAPI(id) // Promise<{details: any}>

wrappedAPI本质上是一个请求的缓冲队列(buffer),虽然它一个一个地接受请求,但是它不会立即将请求发出,而是等待一段时间(debounce),将请求累积起来,然后将累积起来的请求成批发出。

wrapBatchProcess就是一个创建wrappedAPI的工具函数。用户传入一个函数来定义【如何处理一批请求】,然后它给用户返回wrappedAPI,一个一个地接收请求。

优势

封装后的api非常简单:(input) => Promise<TheOutput>。因此api使用者(ResourceDetail)只需要简单地调用wrappedAPI,就可以拿到它想要的数据!

使用者(ResourceDetail)不需要关心【请求是如何合并发出的】,复杂的请求合并逻辑被抽离到了【wrapBatchProcess的调用代码】中,这些代码完全可以放在其他的文件中(关注点分离)。不会污染ResourceDetail和List组件。

接口本身的批量请求能力被充分利用,提升请求效率,减少请求数量。

实现代码

import debounce from "lodash.debounce";

/**
 * 「汇集」处理请求,成批处理。
 * 将「批量请求」接口封装成「逐个请求」的接口。
 * 让使用者享受「逐个处理」的简单性,同时在底层通过「批量请求」来获得效率提升。
 * 使用者只需要关心当前input的处理,而不需要关心这个input是如何与其他input一起成批请求的 (关注点分离)。
 */
export function wrapBatchProcess<InputItem, OutputItem>(
  // 之所以通过callback的方式来返回数据,是因为要支持分批多次返回(拿到一批响应就立刻返回给调用者),而不是等待所有结果到达以后再全部一次性返回
  fn: (
    inputs: { input: InputItem; key: string }[],
    /**
     * 返回结果的时候,需要返回key,与input的key对应,这样我们才能知道每个output对应于哪个input
     */
    onResult: (output: OutputItem, key: string) => void,
    onError: (error: any, key: string) => void
  ) => void,
  // input可能是一个复杂对象,而wrapBatchProcess需要一个string来标识一个请求
  getKey: (input: InputItem) => string
  /** 封装后函数的使用者不需要了解key的概念 */
): (input: InputItem) => Promise<OutputItem> {
  let buffer: Map<string, BufferItem> = new Map();

  const check = debounce(
    () => {
      if (buffer.size === 0) return;
      // 将整个buffer作为一批,发出请求,并清空buffer
      const batch = new Map(buffer);
      buffer = new Map();
      const inputs = Array.from(batch.values()).map((item) => ({
        key: item.key,
        input: item.input,
      }));
      fn(
        inputs,
        (output, key) => {
          const item = batch.get(key);
          if (!item) return;
          item.resolve(output);
          batch.delete(key);
        },
        (error, key) => {
          const item = batch.get(key);
          if (!item) return;
          item.reject(error);
          batch.delete(key);
        }
      );
    },
    // 等待若干毫秒作为一个请求收集窗口,然后将收集到的所有请求作为一批发出
    50,
    {
      leading: false,
      trailing: true,
      // 避免不断有请求到来,导致debounce一直无法被调用,这个参数可调
      maxWait: 200,
    }
  );

  function schedule(input: InputItem) {
    const key = getKey(input);
    // 如果已经有相同的input在buffer中,则不重复调度它,而是与前一个input共享同一个结果
    const existBufferItem = buffer.get(key);
    if (existBufferItem) return existBufferItem.promise;
    // 将input信息加入buffer中,准备调度
    const bufferItem: BufferItem = {
      input,
      key,
      ...createControllablePromise<OutputItem>(),
    };
    buffer.set(key, bufferItem);
    check();
    return bufferItem.promise;
  }

  return schedule;

  type BufferItem = {
    input: InputItem;
    key: string;
    promise: Promise<OutputItem>;
    resolve: (ret: OutputItem) => void;
    reject: (error: any) => void;
  };
}

function createControllablePromise<T>(): {
  promise: Promise<T>;
  resolve: (ret: T) => void;
  reject: (error: any) => void;
} {
  let result: any = {};
  result.promise = new Promise<T>((resolve, reject) => {
    result.resolve = resolve;
    result.reject = reject;
  });
  return result;
}

适配“接口批量能力”

很多时候,虽然后端接口支持在一个请求中同时查询多个资源ID的信息,但是这个支持的数量也会有一个上限。比如每个请求最多只支持查询10个资源ID。

那么我们在调用api之前也需要做对应的适配:

const wrappedAPI = wrapBatchProcess<string, { details: any }>(
  async (inputs, onResult, onError) => {
    // 由于api接口每次最多只能支持10个资源查询,而inputs数组可能很多
    // 因此要先将inputs切分成多个组,每10个资源一组,确保api能接受
    const groups: string[][] = splitArrayIntoGroups(
      inputs.map(({ input }) => input),
      10
    );
    groups.forEach(async (group: string[]) => {
      const result = await api(group);
      Object.keys(result).forEach((key) => {
        onResult(result[key], key);
      });
    });
  },
  (input) => input
);

function splitArrayIntoGroups<T>(array: T[], groupSize: number): T[][] {
  const result: T[][] = [];
  for (let i = 0; i < array.length; i += groupSize) {
    const group = array.slice(i, i + groupSize);
    result.push(group);
  }
  return result;
}

可组合性

这个工具函数可以与简单优雅的JavaScript代码片段(二):流控和重试介绍的工具函数组合使用。因为它们本质上都是”函数转换器“(将一个函数转换成另一个函数,即高阶函数)。

比如,在我们前面介绍的”接口批量能力有限“的例子中,假如一次性到来了110个资源ID,那么在成批发出的时候,就会同时发出11个请求(每个请求包含10个资源ID查询)。

但是我们的api10次/秒的流控限制。如何避免超出这个限制?

你可以利用上篇文章介绍的wrapFlowControl,将api增强成apiWithFlowControl(适配流控的请求方法):

const apiWithFlowControl = wrapFlowControl(api, 10);

const wrappedAPI = wrapBatchProcess<string, { details: any }>(
  async (inputs, onResult, onError) => {
    const groups: string[][] = splitArrayIntoGroups(
      inputs.map(({ input }) => input),
      10
    );
    groups.forEach(async (group: string[]) => {
      // 这一行替换成apiWithFlowControl即可!
      const result = await apiWithFlowControl(group);
      Object.keys(result).forEach((key) => {
        onResult(result[key], key);
      });
    });
  },
  (input) => input
);

这样,你获得了wrapBatchProcess的所有好处,同时还能确保它会在流控允许的范围内调度api


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.