Next.js — JS фреймворк, созданный поверх React.js для создания веб-приложения с поддержкой функционала отрисовки приложения на стороне сервера
Redis (Remote Dictionary Server)- это быстрое хранилище данных типа «ключ‑значение» в памяти, активно используемое в разработке с целью повышения производительности сервисов
Кэш Redis позволяет максимально эффективно применять горизонтальное масштабирование вашего приложения, поскольку заставляет все инстансы приложения смотреть в единый источник данных, вместо использования локального стейта
В рамках данного гайда мы соберем собственный вариант сервера для фреймворка NextJS, добавив кэширование сгенерированых страниц и json файлов, содержащих пропсы этих страниц ( для ускорения навигации за счет префетчей)
Шаг 1. Установка NextJS
yarn create next-app
Шаг 2. Установка пакетов:
yarn add express redis node-gzip
Шаг 3. Создаем в корне проекта файл server.js
const express = require('express')
const next = require('next')
const { promisify } = require('util')
const {gzip} = require('node-gzip');
const client = redis.createClient('redis://localhost:6379')
client.get = promisify(client.get)
const app = next({ dev: false })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = express()
server.get('*', (req, res) => handle(req, res))
server.listen(3000, (err) => {
if ( err ) throw err
console.log(`> Ready on http://localhost:3000`)
})
})
Подключаем express для создания сервера
Подключаем next для создания базового обработчика запросов NextJS
Подключаем пакет для сжатия
Устанавливаем соединение с redis, и используем promisify, чтобы работать с промисами вместо колбэков.
Запускаем сервер и устанавливаем базовый обработчик для всех запросов ( * ) через handle
Шаг 4. Добавляем команду запуска сервера в package.json
"scripts": {
...
"start": "node server.js",
...
},
Шаг 5. Формирование ключей
Для того, чтобы в полной мере использовать мощь Redis в NextJS, нам необходимо кэшировать 2 вида файлов:
HTML, который отдается в браузер при серверной генерации страницы
JSON-файл для каждой страницы, который генерирует NextJS для клиентской навигации ( когда мы переходим на страницу NextJS запрашивает этот JSON файл и использует для передачи пропсов страницы
Шаг 6. Создаем ключ для SSR
const getSsrKey = (req) => {
return req.url
}
Для данной задачи самым простым решением для формирования ключа будет взять url страницы
Шаг 7. Создаем ключ для JSON
const getJsonKey = (req) =>
req.path
.match(/\/([^\/]+)+/g)
.slice(3)
.join('')
Путь к JSON файлам в рамках фреймворка NextJS имеет вид /_next/data/<BUILD_ID>/your-page-name.json
Поэтому самой полезной частью этого пути является то, что идет после идентификатора сборки - your-page-name.json, поэтому его мы и будем брать для формирования ключа
Шаг 8. Создаем кэш HTML
async function ssrCache(req, res) {
const key = getSsrKey(req)
const cache = await client.get(key)
if ( cache ) {
return res.send(cache)
}
const data = await app.renderToHTML(req, res, req.path, { ...req.query, ...req.params })
if ( res.statusCode === 200 && data ) client.set(key, data)
return res.send(data)
}
Для рендеринга страниц nextJS предоставляет метод renderToHTML. Поэтому наша задача состоит в том, чтобы:
Попытаться получить значение из кэша Redis по ключу.
Если кэш существует, то мгновенно отдать в браузер
Если же кэша нет, то отрендерить страницу с помощью метода renderToHTML
Если страница была отрендерена успешно, закэшировать полученный HTML, после чего отдать в браузер
Шаг 9. Создаем кэш JSON
async function jsonCache(req, res) {
const key = getJsonKey(req)
const cache = await client.get(key)
if ( cache ) {
const headersToWrite = {
'content-type': 'application/json',
'content-encoding': 'gzip'
}
const buffer = JSON.parse(cache, (k, v) => {
if ( v !== null && typeof v === 'object' && 'type' in v &&
v.type === 'Buffer' && 'data' in v && Array.isArray(v.data) ) {
return Buffer.from(v.data)
}
return v
})
Object.entries(headersToWrite).forEach(([ key, value ]) => res.setHeader(key, value))
return res.send(buffer)
}
const rawResEnd = res.end
const rawResWrite = res.write
const chunks = []
const proxyWrite = new Proxy(res.write, {
apply(target, thisArg, args) {
const chunk = Buffer.from(args[ 0 ])
chunks.push(chunk)
}
})
res.write = proxyWrite
const data = await new Promise(async (resolve) => {
res.end = async (res) => {
resolve(res || chunks)
}
await app.render(req, res, req.path, {
...req.query,
...req.params
})
})
res.write = rawResWrite
res.end = rawResEnd
const isChunked = Array.isArray(data)
const response = isChunked ? Buffer.concat(data) : (Buffer.from(data))
const serializedResponse = isChunked ? JSON.stringify(response) : (JSON.stringify(await gzip(response)))
if ( res.statusCode === 200 && data ) client.set(key, serializedResponse)
return res.end(response)
}
С JSON кэшем ситуация немного сложнее. Для рендера JSON мы можем воспользоваться методом render. Проблема заключается в том, что этот метод не возвращает никакого значения, а сразу отдает его в браузер. Таким образом, для решения данной задачи нам необходимо подменить функциональность следующих методов, которые используются для выдачи в браузер:
res.write - вместо того, чтобы выводить данные мы сделаем так, чтобы эти данные попадали в массив chunks.
res.end - вместо того, чтобы завершать процесс ответа будет сигнализировать нам о том, что в chunks уже лежат все необходимые для вывода данные, и мы можем начать с ним работать
Теперь мы делаем ту же самую проверку на наличие кэша, и если он существует, то парсим JSON в Buffer, добавляем заголовки о типе и кодировке данных и отдаем в браузер
Если кэша нет, то запоминаем базовые функции res.write и res.end в переменную, чтобы потом иметь возможность их восстановить
Переопределяем write и end прокси функциями, которые обсудили в пунктах 1 и 2
Рендерим контент с помощью render, но благодаря нашим прокси функциям он не будет отдан браузер, а удобно окажется в специально созданной нами переменной
Восстанавливаем базовые версии функций write и end
Далее может быть два варианта того, как мы можем собрать ответ - если контент не чанкался, то тогда на выходе мы получим строку(JSON) и создадим буфер из нее, в ином случае соединяем кусочки, которые собрали с помощью проксирования функции write в единый буфер.
Если мы получили успешный ответ, то сериализуем буфер, сжимаем (в случае необходимости) и сохраняем в кэше редиса
Завершаем ответ методом end
Шаг 11. Собираем все вместе
Для SSR страниц мы применяем ssrCache, для JSON файлов - jsonCache
server.get('/', (req, res) => {
return ssrCache(req, res)
})
server.get('/_next/data/*', async (req, res) => {
return jsonCache(req, res)
})
server.get('*', (req, res) => handle(req, res))
Ссылка на репозиторий: https://github.com/IAlexanderI1994/next-redis-article
Благодарю за прочтение. Буду рад вашим вопросам и комментариям.
bohdan-shulha
Неплохо было бы обозначить, для чего это надо, поскольку в nextjs из коробки есть сразу несколько решений, которые делают практически то же самое:
- static html export - для получения полностью статической версии сайта
- automatic static optimization - для генерирования статических страниц с возможностью подгрузки новых данных после загрузки страницы.
Конечно, они работают без Redis, но я не понимаю, для чего необходимо делать лишний round-trip по сети для отображения статических ресурсов.
Alexander-Kiryushin Автор
Богдан, добрый день. Здесь речь идет больше о SSR рендеринге, а не SSG.
Во-первых, тут решается проблема горизонтального скейлинга. Все инстансы вашего приложения будут смотреть в одну точку, вместо генерации локального стейта.
Как это почувствовать? При прямом заходе на страницу весь "подкапотный" рендеринг будет проигнорирован, и пользователь получит данные напрямую из Redis.
Во-вторых, Вы получите огромный плюс при переходах за счет кэширования json файлов, которые под капотом используются ( и фетчат данные ) при навигации
Bobrovnik
Добрый день,
Немного не улавливаю решение. Если данные не зависят от того, кто их запрашивают и не меняются с течением времени, почему не использовать SSG? А если все же данные зависят от пользователя, который их запрашивает, то будет ли такое решение работать верно?
Так же будет ли это решение работать верно, после очередного билда? Когда содержимое страницы обновится, но в качестве ключа кэша мы используем лишь ссылку к странице, не учитывая версию билда?
Alexander-Kiryushin Автор
Приветствую. Рассматривается кейс, что данные меняются с течением времени. Для этого существуют различные механизмы инвалидации кэша, но это отдельная тема. По поводу пользователей. Тут зависит от того, как Вы формируете страницу. Если, например, Вы запрашиваете страницу вида profile/123, то тут все будет работать идеально - идентификатор отлично подойдет для кэширования. В иных случаях, например, в варианте /dashboard можно расширить состав ключа, до кук и иных косвенных параметров, ведь Вы имеете полный доступ к содержимому реквеста. Данный пример - лишь шаблон, по которому Вы можете строить свою систему кэширования. Если под билдом Вы подразумеваете сборку приложения, то после каждой поставки новой версии приложения необходимо будет сбрасывать кэш полностью. Если под билдом Вы понимаете фазу сборки страницы, то при изменении данных Вам необходимо будет сбросить кэш, тогда при следующем запросе страница будет пересобрана и заново закэширована.
vcebotari
если запустить в cluster, или multi-worker, без базы никак. cache будет разница.