在处理大型数据集的API时,高效管理数据流并解决诸如分页、速率限制和内存使用等方面的问题至关重要。本文将介绍如何使用JavaScript的内置fetch
函数来消费API。我们将讨论以下几个重要主题:
Promise.all()
来并发发送请求并提高性能。我们将通过Storyblok内容交付API来探索这些技术,并说明如何利用JavaScript中的fetch
来处理所有这些因素。让我们开始写代码吧。
在开始研究代码之前,这里有几个关于Storyblok API的重要点需要考虑,
cv
(内容版本)参数用于获取缓存的内容。cv
的值在首次请求中返回,请在后续请求中再次使用,以确保获取相同缓存版本的内容。page
和 per_page
进行分页:通过使用 page
和 per_page
参数来控制每个请求返回的项目数量,并逐页获取结果。total
头部表示总项目数量。这对于计算需要获取的页面数量非常重要。Retry-After
头部(或默认等待时间)来确定重试请求前的等待时间。fetch()
处理大数据集的 JavaScript 示例
这里是如何使用JavaScript的原生fetch函数来实现这些概念的。注意:
stories.json
的新文件作为示例。如果文件已存在,请在代码片段中修改文件名。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): 分页处理
分页是通过page
和per_page
这两个查询字符串参数来控制的。代码请求每页25个故事(您可以调整此数字),并且total
头部帮助我们计算需要获取的页面数量。代码一次最多批量发起7个(您可以调整此数字)并行请求来获取故事,以提高性能并避免压垮API。
Promise.all()
进行并发请求为了加快进程,通过JavaScript的Promise.all()
并行获取多个页面。此方法同时发送多个请求,并等待所有响应,以确保所有请求完成。
每次并行请求完成后,处理结果以生成故事内容,这样可以避免一次性加载所有数据到内存中,从而节省内存。
5) 使用异步遍历 (for await...of
),例如,进行内存管理:
而不是将所有数据都收集到一个数组中,我们使用JavaScript生成器(function*
和for await...of
)来逐个处理获取到的故事。这样在处理大数据集时可以防止内存超载。
通过逐个生成故事,代码保持高效并避免内存泄漏的问题。
6),限速处理:
如果 API 返回状态码 429
(表示速率限制),脚本会使用 retryAfter
提供的时间值。脚本会暂停指定的时间,然后重新发送请求。这确保脚本遵守 API 的速率限制,避免了请求发送过于频繁。
在这篇文章里,我们讨论了使用原生 fetch
函数来消费 API 时的关键考虑因素。我尝试解决以下问题:
page
和 per_page
参数管理分页。Promise.all()
并行获取页面,从而加快数据检索速度。function*
和 for await...of
) 来处理数据,以避免占用过多内存。通过应用这些技术,你可以高效、可扩展且内存安全地消费API。
欢迎留言评论或反馈。
参考资料