手记

在 JavaScript 中高效处理大量数据的 API

在处理大型数据集的API时,高效管理数据流并解决诸如分页、速率限制和内存使用等方面的问题至关重要。本文将介绍如何使用JavaScript的内置fetch函数来消费API。我们将讨论以下几个重要主题:

  • 处理大量数据:逐步检索大型数据集,以避免数据过多导致系统过载。
  • 分页:我们将探讨如何管理分页,实现高效的数据检索。
  • 速率限制:API通常会设置速率限制以防止滥用。我们将看到如何检测并处理这些限制。
  • 重试等待机制:如果API响应时返回429状态码(请求过多),我们将实现“重试等待机制”,这会指示等待多久后再重试,以确保平稳的数据获取。
  • 并发请求:并行获取多个页面可以加快进程。我们将使用JavaScript的Promise.all()来并发发送请求并提高性能。
  • 避免内存泄漏:处理大量数据需要谨慎的内存管理。我们将分块处理数据,并确保操作的内存效率,利用生成器

我们将通过Storyblok内容交付API来探索这些技术,并说明如何利用JavaScript中的fetch来处理所有这些因素。让我们开始写代码吧。

使用 Storyblok 内容分发 API 时需要注意的事项

在开始研究代码之前,这里有几个关于Storyblok API的重要点需要考虑,

  • CV 参数 : cv(内容版本)参数用于获取缓存的内容。cv 的值在首次请求中返回,请在后续请求中再次使用,以确保获取相同缓存版本的内容。
  • 使用 pageper_page 进行分页:通过使用 pageper_page 参数来控制每个请求返回的项目数量,并逐页获取结果。
  • 总数头部 :第一个响应的 total 头部表示总项目数量。这对于计算需要获取的页面数量非常重要。
  • 处理 429(速率限制) :Storyblok 限制速率;当你达到速率限制时,API 会返回 429 状态码。通过 Retry-After 头部(或默认等待时间)来确定重试请求前的等待时间。
使用 fetch() 处理大数据集的 JavaScript 示例

这里是如何使用JavaScript的原生fetch函数来实现这些概念的。注意:

  • 此代码片段将创建一个名为 stories.json 的新文件作为示例。如果文件已存在,请在代码片段中修改文件名。
  • 由于请求是并行执行的,故事的顺序无法保证。例如,如果第三页的响应比第二页的响应更快,生成器将先提供第三页的故事。
  • 我用 Bun 测试了这段代码 :)
    import { writeFile, appendFile } from "fs/promises";

    // 从环境变量中获取访问令牌
    const STORYBLOK_ACCESS_TOKEN = process.env.STORYBLOK_ACCESS_TOKEN;
    // 从环境变量中获取访问令牌
    const STORYBLOK_VERSION = process.env.STORYBLOK_VERSION;

    /**

* 从API获取单页数据,并针对速率限制(HTTP 429)实现重试机制。
     */
    async function fetchPage(url, page, perPage, cv) {
      let retryCount = 0;
      // 最大重试次数
      const maxRetries = 5;
      while (retryCount <= maxRetries) {
        try {
          const response = await fetch(
            `${url}&page=${page}&per_page=${perPage}&cv=${cv}`,
          );
          // 处理429 Too Many Requests(速率限制)
          if (response.status === 429) {
            // some APIs 提供 Retry-After 信息在头部中
            // Retry-After 表示重试前需要等待的时间间隔
            // Storyblok 使用固定的窗口计数器(1秒窗口)
            const retryAfter = response.headers.get("Retry-After") || 1;
            console.log(response.headers,
              `速率限制在第 ${page} 页。重试前等待 ${retryAfter} 秒...`,
            );
            retryCount++;
            // 在遭遇速率限制时,等待1秒即可。否则从第二次尝试开始,每次等待时间逐渐增加
            // setTimeout 接受毫秒,所以我们需要将乘数设置为1000
            await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * retryCount));
            continue;
          }

          if (!response.ok) {
            throw new Error(
              `获取第 ${page} 页失败: HTTP ${response.status}`,
            );
          }
          const data = await response.json();
          // 返回当前页的故事信息
          return data.stories || [];
        } catch (error) {
          console.error(`获取第 ${page} 页失败: ${error.message}`);
          return []; // 如果请求失败,返回空数组以不中断流程
        }
      }
      console.error(`未能在 ${maxRetries} 次尝试后获取第 ${page} 页`);
      return []; // 如果达到了最大重试次数,返回空数组
    }

    /**

* 并行获取所有数据,使用生成器(这是为什么我们使用 `*`)处理页面批次
     */
    async function* fetchAllDataInParallel(
      url,
      perPage = 25,
      numOfParallelRequests = 5,
    ) {

      let currentPage = 1;
      let totalPages = null;

      // 获取第一页以获取:
      // - 总条目数(HTTP头部的 `total`)
      // - 用于缓存的 CV(JSON响应负载中的 `cv` 属性)
      const firstResponse = await fetch(
        `${url}&page=${currentPage}&per_page=${perPage}`,
      );
      if (!firstResponse.ok) {
        console.log(`${url}&page=${currentPage}&per_page=${perPage}`);
        console.log(firstResponse);
        throw new Error(`获取数据失败: HTTP ${firstResponse.status}`);
      }
      console.timeLog("API", "第一响应之后");

      const firstData = await firstResponse.json();
      const total = parseInt(firstResponse.headers.get("total"), 10) || 0;
      totalPages = Math.ceil(total / perPage);

      // 生成第一页的故事
      for (const story of firstData.stories) {
        yield story;
      }

      const cv = firstData.cv;

      console.log(`总页数: ${totalPages}`);
      console.log(`用于缓存的 CV 参数: ${cv}`);

      currentPage++; // 从第二页开始

      while (currentPage <= totalPages) {
        // 获取当前批次需要获取的页面列表
        const pagesToFetch = [];
        for (
          let i = 0;
          i < numOfParallelRequests && currentPage <= totalPages;
          i++
        ) {
          pagesToFetch.push(currentPage);
          currentPage++;
        }

        // 并行获取页面
        const batchRequests = pagesToFetch.map((page) =>
          fetchPage(url, page, perPage, firstData, cv),
        );

        // 等待批次中的所有请求完成
        const batchResults = await Promise.all(batchRequests);
        console.timeLog("API", `获取到 ${batchResults.length} 个响应`);
        // 生成从每个批次请求获取的故事
        for (let result of batchResults) {
          for (const story of result) {
            yield story;
          }
        }
        console.log(`获取的页面: ${pagesToFetch.join(", ")}`);
      }
    }

    console.time("API");
    const apiUrl = `https://api.storyblok.com/v2/cdn/stories?token=${STORYBLOK_ACCESS_TOKEN}&version=${STORYBLOK_VERSION}`;
    // const apiUrl = `http://localhost:3000?token=${STORYBLOK_ACCESS_TOKEN}&version=${STORYBLOK_VERSION}`;

    const stories = fetchAllDataInParallel(apiUrl, 25,7);

    // 在追加之前,先创建一个空文件(如果文件已存在则覆盖)
    await writeFile('stories.json', '[', 'utf8'); // 开始JSON数组
    let i = 0;
    for await (const story of stories) {
      i++;
      console.log(story.name);
      // 如果不是第一个故事,则添加逗号以分隔JSON对象
      if (i > 1) {
        await appendFile('stories.json', ',', 'utf8');
      }
      // 将当前故事追加到文件中
      await appendFile('stories.json', JSON.stringify(story, null, 2), 'utf8');
    }
    // 在文件中关闭JSON数组
    await appendFile('stories.json', ']', 'utf8'); // 结束JSON数组
    console.log(`总故事数: ${i}`);

点击全屏可以进入,点击全屏可以退出

看来看看关键步骤

以下是对代码中确保使用Storyblok内容交付API进行高效可靠的数据获取的重要步骤的分解如下:

带有重试功能的页面获取,称为 fetchPage

此功能用于从API获取单页数据。它包括在API响应为429(请求过多)状态时重试的逻辑,这表明速率限制已被超出,需要等待一定时间才能再次请求。
retryAfter值指定了重试前等待的时间。我使用setTimeout来暂停一段时间,然后再进行后续请求,重试次数限制在5次以内。

2) 初始页面请求过程和CV参数(简历参数)

第一个API请求特别重要,因为它获取比如total头部(表示故事总数)和cv参数(这个参数用于缓存)。
你可以使用total头部来算出总页数,而这个cv参数确保缓存内容被使用。

3): 分页处理

分页是通过pageper_page这两个查询字符串参数来控制的。代码请求每页25个故事(您可以调整此数字),并且total头部帮助我们计算需要获取的页面数量。代码一次最多批量发起7个(您可以调整此数字)并行请求来获取故事,以提高性能并避免压垮API。

  1. 例如,使用Promise.all()进行并发请求

为了加快进程,通过JavaScript的Promise.all()并行获取多个页面。此方法同时发送多个请求,并等待所有响应,以确保所有请求完成。
每次并行请求完成后,处理结果以生成故事内容,这样可以避免一次性加载所有数据到内存中,从而节省内存。

5) 使用异步遍历 (for await...of),例如,进行内存管理

而不是将所有数据都收集到一个数组中,我们使用JavaScript生成器(function*for await...of)来逐个处理获取到的故事。这样在处理大数据集时可以防止内存超载。
通过逐个生成故事,代码保持高效并避免内存泄漏的问题。

6),限速处理:

如果 API 返回状态码 429 (表示速率限制),脚本会使用 retryAfter 提供的时间值。脚本会暂停指定的时间,然后重新发送请求。这确保脚本遵守 API 的速率限制,避免了请求发送过于频繁。

总结:

在这篇文章里,我们讨论了使用原生 fetch 函数来消费 API 时的关键考虑因素。我尝试解决以下问题:

  • 大型数据集 : 通过分页获取大型数据集。
  • 分页 : 使用 pageper_page 参数管理分页。
  • 速率限制和重试机制 : 处理速率限制并按照延迟时间重试请求。
  • 并发请求 : 使用 JavaScript 的 Promise.all() 并行获取页面,从而加快数据检索速度。
  • 内存管理 : 使用 JavaScript 生成器 (function*for await...of) 来处理数据,以避免占用过多内存。

通过应用这些技术,你可以高效、可扩展且内存安全地消费API。

欢迎留言评论或反馈。

参考资料
0人推荐
随时随地看视频
慕课网APP