Как сконфигурировать NextJS сервер с полной поддержкой кэширования в Redis
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
Благодарю за прочтение. Буду рад вашим вопросам и комментариям.