Получение общих данных в Next.js одним запросом

Просматривая процесс сборки своего текущего проекта, я обратил внимание на то, что при генерировании страниц с использованием getStaticProps выдается много запросов к одной конечной точке. Больших бед мне это не сулит, так как наш бэкенд довольно быстрый. Но, обратившись к поисковику с этой проблемой, я обнаружил, что у некоторых возникают такие же трудности. Например, проблема становится серьезной в случае медленной базы данных или когда бэкенд не справляется с сотней запросов в секунду. В общем я попробовал решить эту проблему и уменьшить время сборки проекта.

Почему так много запросов?

Прежде чем подробно разбирать процесс сборки Next.js, рассмотрим, как обстоят дела с этим у еще одного генератора статических сайтов  —  Gatsby. У Gatsby совсем другой механизм, ведь он осуществляет генерацию статических сайтов, тогда как Next.js изначально  —  фреймворк, использующий принцип отрисовки на стороне сервера.

В Gatsby процесс сборки проходит в несколько этапов. Среди них выделяются два основных: извлечение данных и заполнение ими компонентов, а затем отрисовка. Плагины Gatsby обычно используют слой данных GraphQL для хранения данных из источников данных (задействуют, конечно же, и неструктурированные данные, обходясь без слоя GraphQL). Gatsby запускает плагины данных, после чего они извлекают данные и добавляют их в слой данных. Затем он получает от страниц запросы GraphQL и подготавливает данные для шаблонов. А дальше заполняет компоненты. Этот способ создания страниц кажется предельно простым. Но Next.js работает по-другому.

Изначально это был фреймворк исключительно с отрисовкой на стороне сервера. Но позже в него была добавлена функция генератора статических сайтов. У Next.js есть API, где данные получаются и передаются на каждую страницу, но этот метод мешает оптимизации генератора статических сайтов. Здесь имеется в виду getInitialData, когда он используется внутри _app.js.

Получение страниц с применением генератора статических страниц происходит только с помощью getStaticProps, или страницы просто создаются без извлечения данных. Когда Next.js создает страницы, он запускает функцию извлечения данных, если она находится внутри файла страницы, а затем отображает страницу с этими данными. И этот процесс повторяется страница за страницей. Поэтому общие данные будут запрашиваться каждый раз для каждой страницы. С увеличением количества страниц в приложении растет и число запросов.

Моему приложению на каждой странице требуется отображать список городов, и эти города должны быть определены на сервере. Я знал, как работает Gatsby, и хотел добавить подобный механизм, но более простой и без GraphQL.

Мой первый подход

Первая идея была очень проста. Например, когда первая страница вызывает fetchCities, я сохраняю возвращаемый из fetch промис в переменной с областью видимости, ограниченной модулем. При вызове fetchCities следующей страницей я проверяю, указывает ли эта переменная на промис. Когда указывает, возвращаю его:

const { PUBLIC_NEXT_API_URL } = process.env;

let citiesRequest;

export function fetchCities() {
  if (!citiesRequest) {
    citiesRequest = fetch(`${PUBLIC_NEXT_API_URL}/cities`);
  }

  return citiesRequest;
}

Пример показывает, как сохранить запрос в области видимости модуля.

Здесь всё просто: сохраняем промис запроса в области видимости модуля, а затем возвращаем его всем следующим вызовам. Проблема в том, что этот подход не работает. Почему? Из-за воркеров. Next.js запускает сборку каждой страницы в собственном воркере. Это означает, что у меня нет возможности обмениваться данными со страницами и нет доступа к такому месту, где без огромных усилий возможен обмен данными с воркерами. Кроме того, я проверяю, работает ли запрос в непрерывной интеграции, потому что fetchCities вызывается и при повторной проверке. И в этом случае мне каждый раз нужно получать свежие данные.

Возможно, поможет поисковик?

У меня было еще несколько идей, но я решил заглянуть в поисковик. Может, не у меня одного возникает такая проблема? Так и есть: нашлось несколько человек, задававшихся тем же вопросом, но по разным причинам. Кто-то только начал использовать Next.js, и его бэкенд не справился с большим количеством запросов. А кто-то пробовал перейти от Gatsby к Next и понял, что его бэкенд не так быстр для 300 запросов в секунду.

Я знал, как работает Gatsby, потому что имел с ним дело на предыдущей работе. У Gatsby к этому другой подход. Прежде всего, он подготавливает сборку, собирая данные в локальный кеш. В этом случае все ресурсы извлекаются только один раз, так что каждая страница получает данные из этого кеша. Мой подход был аналогичен, только добавлять кеш в Next.js у меня не было необходимости.

Данные кеша в файлах

Один из возможных способов решения этой проблемы  —  кеширование в файлах. Для этого добавим в процесс сборки еще один этап и напишем что-то вроде этого:

const path = require("path");
const crypto = require("crypto");
const fs = require("fs/promises");
const fetch = require("isomorphic-unfetch");

const { API_URL, CACHE_DIR } = process.env;
const RESOUCES = ["/posts", "/albums"];

function getFilename(str) {
  const hash = crypto.createHash("sha1").update(str).digest("hex");

  return `${hash}.json`;
}

function writeResponseToFile(filepath, data) {
  return fs.writeFile(filepath, data, "utf-8");
}

function cacheRequests([endpoint, ...r]) {
  return new Promise((resolve, reject) => {
    if (!endpoint) {
      console.log("\nResources have been succesfully cached 📦");
      return resolve();
    }

    const url = `${API_URL}${endpoint}`;
    const filename = getFilename(endpoint);
    const filepath = path.resolve(__dirname, CACHE_DIR, filename);

    fetch(url)
      .then((r) => r.text())
      .then((d) => writeResponseToFile(filepath, d))
      .then(() => {
        console.log(`${endpoint} -> ${filepath} 💾`);
        cacheRequests(r);
      })
      .catch((e) => reject(e));
  });
}

cacheRequests(RESOUCES);

Мы запускаем скрипт, который извлекает данные и сохраняет их в файлы. Недостаток этого метода в том, что для извлечения данных нужно собрать в скрипте все конечные точки.

Собрав данные, запускаем сервер, который будет работать с кешированными данными для создания приложения:

const http = require("http");
const path = require("path");
const crypto = require("crypto");
const fs = require("fs/promises");

const { HOST, PORT, CACHE_DIR } = process.env;

function getFilename(str) {
  const hash = crypto.createHash("sha1").update(str).digest("hex");

  return `${hash}.json`;
}

async function handler(req, res) {
  const filename = getFilename(req.url);
  const filepath = path.resolve(__dirname, CACHE_DIR, filename);
  const data = await fs.readFile(filepath);

  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.write(data);
  res.end();
  console.log(`📤 ${req.method} ${req.url}`);
}

http.createServer(handler).listen(PORT, HOST, () => {
  console.log(`Server is running on 🌎 http://${HOST}:${PORT}`);
});

Эти варианты хороши, но не лишены недостатков. Например, для кеширования нужно собирать все конечные точки и обновлять их. Или же нужно ждать, пока все ресурсы не будут кешированы. С другой стороны, все ресурсы кешируются на диске и не остаются в памяти. Хотелось поделиться этим фрагментом кода, чтобы показать вам все варианты.

Кешируем прямо в памяти при сборке

Это самый простой и быстрый способ кеширования данных, и ничего ждать не нужно. Просто передаем другой API_PATH на этапе сборки и запускаем сервер кеша перед запуском next build (следующей сборки).

Код сервера будет выглядеть так:

const http = require("http");
const fetch = require("isomorphic-unfetch");

const { API_URL, PORT, HOST } = process.env;

const map = new Map();

async function handler(req, res) {
  if (map.has(req.url)) {
    const data = await map.get(req.url);

    res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
    res.write(data);
    res.end();
    console.log(`📦 Cached request to: ${req.url}`);

    return;
  }

  const request = fetch(`${API_URL}${req.url}`).then((res) => res.text());

  map.set(req.url, request);

  const data = await request;

  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.write(data);
  res.end();
  console.log(`📥 GET ${req.url}`);
}

http.createServer(handler).listen(PORT, HOST, () => {
  console.log(`Server is running on 🌎 http://${HOST}:${PORT}`);
});

Этот подход аналогичен моему первому подходу, только данные сохраняем на другом уровне. Все запросы будут вызывать прокси-сервер кеширования. При получении запроса он сохраняется в map, и все последующие запросы к одному и тому же ресурсу будут получать один и тот же ответ. Это очень быстрый подход, но при запрашивании большого объема данных нужно быть осторожным с памятью. Иначе получится гибрид этих двух подходов: кеширование данных по необходимости и сохранение их на диске, а не в памяти.

Подведем итоги

На самом деле команда Next.js планировала добавить возможность использования getStaticProps в _app.js. В случае реализации этой возможности обозначенная проблема будет решена без лишних усилий. Я слежу за этой дискуссией, но она еще не завершена.

Возможно, у вас аналогичные трудности с источником данных. Надеюсь, что материал, изложенный в этой статье, поможет вам их преодолеть. Но ее цель не только в том, чтобы решить проблему, но и попробовать применить различные подходы. Описанные в статье подходы пригодятся и для решения других задач, связанных с кешированием данных. Используйте их в соответствии с вашими потребностями.

Примеры в полном объеме хранятся на GitHub.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Pavel Mineev: Fetch Shared Data in Next.js With Single Request

Предыдущая статьяПять парадоксов с вероятностью, которые вас озадачат
Следующая статья7 советов для эффективной визуализации данных