Next.js: кешируем страницы с помощью кастомного сервера и Redis

от автора

Привет, друзья!

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

Репозиторий с исходным кодом проекта.

Если вам это интересно, прошу под кат.

Подготовка и настройка проекта

Обратите внимание: для успешного прохождения туториала на вашей машине должны быть установлены Node.js и Docker.

Создаем шаблон приложения с помощью create-next-app:

# next-redis - название проекта yarn create next-app next-redis # or npx create-next-app next-redis

Переходим в созданную директорию и устанавливаем несколько дополнительных зависимостей:

yarn add cron dotenv express redis  yarn add -D nodemon # or npm i ... npm i -D nodemon

  • cron — утилита для выполнения отложенных и периодических задач;
  • dotenv — утилита для доступа к переменным среды окружения;
  • expressNode.js-фреймворк для разработки веб-серверов;
  • redis — библиотека для работы с Redis;
  • nodemon — утилита для запуска сервера для разработки.

Создаем в корневой директории файл .env следующего содержания:

# название приложения APP_NAME=my-app  # дефолтная среда разработки ENV=development  # версия `Node.js` NODE_VERSION=16.13.1  # пароль для доступа к `redis` REDIS_PASSWORD=qwerty  # код подтверждения, который мы будем использовать для очистки кеша, хранящегося в `redis` VERIFICATION_CODE=super-secret

Настроим Docker-сервис для redis.

Создаем в корневой директории файл docker-compose.yml следующего содержания:

# версия `Compose` version: '3.9' # сервисы приложения services:   # название сервиса   redis:     # файл, содержащий переменные среды окружения     env_file: .env     # название контейнера     container_name: ${APP_NAME}_redis     # используемый образ     image: bitnami/redis:latest     # том для хранения данных     volumes:       - ./data_redis:/data     # порты `хост:контейнер`     ports:       - 6379:6379     # политика перезапуска контейнера     restart: on-failure

Выполняем команду docker compose up -d для запуска сервиса.

Получаем сообщение от redis о его готовности к работе.

На этом подготовка и настройка проекта завершены.

Переходим к разработке клиентской части приложения.

Клиентская часть приложения

Клиентская часть нашего приложения будет максимально простой:

components - компоненты   Nav.js - панель навигации pages - страницы   _app.js - основной компонент приложения   about.js   catalog.js   index.js public   favicon.ico styles   global.css

components/Nav.js:

/* eslint-disable */ export default function Nav() {   return (     <nav>       <ul>         <li>           <a href='/'>Home</a>         </li>         <li>           <a href='/catalog'>Catalog</a>         </li>         <li>           <a href='/about'>About</a>         </li>       </ul>     </nav>   ) }

О том, почему в данном случае мы используем тег a, а не компонент Link из пакета next/link, см. ниже.

pages/index.js:

export default function Home() {   return <h2>Welcome to Home Page</h2> }

pages/about.js:

export default function Home() {   return <h2>This is About Page</h2> }

Эти страницы являются статическими (dumb/глупыми в терминологии React).

pages/catalog.js:

// адрес сервера const SERVER_URI = process.env.SERVER_URI || 'http://localhost:5000'  // наличие функции `getServerSideProps` указывает на // серверный рендеринг данной страницы // // мы хотим получать от сервера список/массив категорий export async function getServerSideProps() {   let categories = []    try {     const res = await fetch(`${SERVER_URI}/current-categories`)     categories = await res.json()   } catch (err) {     console.error(err)   }    return {     props: {        categories     }   } }  export default function Catalog({ categories }) {   return (     <>       <h2>This is Catalog Page</h2>       {/* рендерим категории, полученные от сервера */}       <ul>         {categories.map((category) => (           <li key={category.id}>{category.title}</li>         ))}       </ul>     </>   ) }

Эта страница рендерится на сервере при каждом запросе. Что это означает?

На самом высоком уровне это означает следующее:

  • при переходе на данную страницу клиент отправляет серверу next запрос на получение разметки в виде строки;
  • сервер выполняет код функции getServerSideProps для получения необходимых для формирования разметки данных;
  • сервер рендерит страницу (с помощью метода renderToHtml);
  • готовая разметка возвращается клиенту в виде строки;
  • клиент выполняет гидратацию/гидрацию (hydration), преобразуя строку в «настоящую» разметку.

pages/_app.js:

import '../styles/globals.css' import Nav from '../components/Nav'  function MyApp({ Component, pageProps }) {   return (     <div className='app'>       <header>         <h1>Next.js + Redis</h1>         <Nav />       </header>       <main>         <Component {...pageProps} />       </main>       <footer>         <p>&copy; 2022. Not all rights reserved</p>       </footer>     </div>   ) }  export default MyApp

Без комментариев.

Минимальные глобальные стили (global.css):

html, body {   padding: 0;   margin: 0;   font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,     Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; }  * {   box-sizing: border-box; }  .app {   min-height: 100vh;   display: flex;   flex-direction: column;   text-align: center; }  ul {   margin: 0;   padding: 0;   list-style: none;   display: flex;   justify-content: center;   gap: 1rem; }  a {   text-decoration: none; }  main {   flex-grow: 1;   display: grid;   place-content: center; }

На этом разработка клиентской части нашего приложения завершена.

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

Серверная часть приложения

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

server   utils - утилиты     pageController.js - контроллер страниц, посредник/middleware для взаимодействия с `redis`     renderPage.js - утилита для рендеринга страниц   index.js - основной файл сервера

Начнем с утилит.

utils/renderPage.js:

async function renderPage(app, req, res) {   // объединяем параметры и строку запроса из объекта запроса   const query = { ...req.params, ...req.query }    try {     // рендерим страницу     const html = await app.renderToHTML(req, res, req.path, query)      // записываем ее в `redis`     // данный метод добавляется в объект ответа соответствующим посредником     res.saveHtmlToCache(html)      // и возвращаем клиенту     res.send(html)   } catch (err) {     console.error(err)      // рендерим дефолтную страницу ошибки     await app.renderError(err, req, res, req.path, query)   } }  module.exports = renderPage

utils/pageController.js.

Импортируем библиотеку для взаимодействия с redis, получаем доступ к переменным среды окружения, формируем url для доступа к серверу redis и определяем функцию для создания клиента redis

const redis = require('redis') require('dotenv').config()  const redisConfig = {   // обратите внимание на символ `:` после `//`   // без него будет выброшено исключение `invalid user-password pair`   url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST || 'localhost'}:6379` }  async function createClient() {   // создаем клиента `redis`   const client = redis.createClient(redisConfig)    // регистрируем обработчики   client.on('error', (err) => {     console.error('@redis error', err)   })    client.on('connect', () => {     console.log('@redis connect')   })    client.on('reconnecting', () => {     console.log('@redis reconnecting')   })    client.on('end', () => {     console.log('@redis disconnect')   })    try {     // выполняем подключение к серверу `redis`     await client.connect()   } catch (err) {     console.error(err)   }    // и возвращаем клиента   return client }

Определяем переменную для клиента redis и соответствующего посредника:

let redisClient  async function pageController(req, res, next) {   // создаем клиента `redis` при отсутствии   if (!redisClient) {     try {       redisClient = await createClient()     } catch (err) {       console.error(err)     }   }    console.log('@redis middleware', req.path)    // ключ для кеша   const cacheKey = req.path    try {     // пытаемся получить разметку из кеша     const html = await redisClient.get(cacheKey)      // если получилось     if (html) {       console.log('@from cache')        // возвращаем разметку клиенту       // на этом обработка запроса завершается,       // соответствующий обработчик запроса не вызывается       return res.send(html)     }      // расширяем объект ответа функцией для записи разметки в кеш     res.saveHtmlToCache = (html) => {       console.log('@to cache')        redisClient.set(cacheKey, html).catch(console.error)     }      // расширяем объект ответа функцией для очистки кеша     res.clearCache = () => {       console.log('@clear cache')        // в данном случае очищается весь кеш, хранящийся в `redis`       // для удаления определенного кеша по ключу используется метод `redisClient.del(cacheKey)`       redisClient.flushAll().catch(console.error)     }      // передаем управление обработчику запроса     next()   } catch (err) {     console.error(err)   } }  module.exports = pageController

Теперь рассмотрим основной файл сервера (index.js).

Импортируем библиотеки и утилиты, определяем список/массив кешируемых страниц, определяем среду разработки, создаем экземпляр сервера next и обработчик запросов:

const next = require('next') const express = require('express')  const pageController = require('./utils/pageController') const renderPage = require('./utils/renderPage')  // кешируемые страницы const CACHED_PAGES = ['/', '/catalog', '/about']  // среда разработки const dev = process.env.ENV === 'development'  // экземпляр сервера const app = next({ dev })  // обработчик запросов const handle = app.getRequestHandler()

Выполняем перехват и обработку запросов:

app.prepare().then(() => {   // создаем экземпляр приложения `express`   const server = express()    // директория со статическими файлами   server.use(express.static('static'))    // запросы на получение статики   server.get('/_next/*', handle)    server.get('/favicon.ico', handle)    // все остальные `GET-запросы`   // проходят через посредника для взаимодействия с `redis`   server.get('*', pageController, (req, res) => {     console.log('@route handler', req.path)      // если поступил запрос на очистку кеша     if (req.path === '/clear-cache') {       // проверяем, что в заголовке `x-verification-code` содержится код подтверждения `super-secret`       // предполагается, что запрос приходит откуда-то извне       // например, в одном из моих рабочих проектов такой запрос       // приходит от "полноценного" сервера, реализованного на `Python`       if (         req.headers['x-verification-code'] &&         req.headers['x-verification-code'] !== process.env.VERIFICATION_CODE       ) {         // если заголовок отсутствует или его значение не совпадает с `super-secret`         return res.sendStatus(403)       }        // очищаем кеш       res.clearCache()        return res.sendStatus(200)     }      // если запрашивается кешируемая страница     if (CACHED_PAGES.includes(req.path)) {       // вызываем нашу утилиту       return renderPage(app, req, res)     }      // остальные запросы обрабатываются по умолчанию     return handle(req, res)   })    // определяем порт   const port = process.env.PORT || 5000    // запускаем сервер   server.listen(port, (err) => {     if (err) return console.error(err)      console.log(`? Server ready on port ${port}`)   }) })

Определяем в разделе scripts файла package.json команды для запуска кастомного сервера next в режимах для разработки и продакшна:

"start:dev": "ENV=development nodemon server/index.js", "start": "ENV=production node server/index.js"

Запускаем приложение в режиме для разработки с помощью команды yarn start:dev или npm run start:dev и открываем вкладку браузера по адресу: http://localhost:5000.

Обратите внимание на сообщения в терминале: мы видим, что запрос на получение главной страницы (/) проходит сначала через посредника для работы с redis (@redis middleware /), затем через обработчик запроса (@route handler /). Также мы получили сообщение от redis о записи страницы в кеш (@to cache).

Переходим на другую страницу, например, About.

Получаем аналогичные сообщения для этой страницы.

Возвращаемся на главную.

На этот раз запрос проходит только через посредника (@redis middleware /), а страница доставляется из кеша (@from cache). Прекрасно, это как раз то, к чему мы стремились.

Вернемся к тому, почему на клиенте мы использовали тег a вместо компонента Link. Дело в том, что при использовании Link маршрутизация будет выполняться только на клиенте, без обращения к серверу, поэтому при переходе, например, с / на /about, /about не будет кешироваться (страницы будут кешироваться либо при перезагрузке вкладки браузера, либо при прямом переходе на страницу). Вы можете сами в этом убедиться, заменив a на Link в файле components/Nav.js.

Ускорение серверного рендеринга страниц

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

В одном из рабочих проектов я столкнулся с тем, что при инициализации приложение в _app.js запрашивало от сервера огромное количество данных. Ответ на этот запрос занимал до 10 (sic) секунд. Все это время пользователь, впервые пришедший на сайт, любовался белым экраном и индикатором загрузки браузера (при повторном посещении сайта и большинства страниц такой проблемы не было благодаря кешированию на next и python-серверах). Сами понимаете, что такая ситуация меня, мягко говоря, не очень устраивала. При этом я не мог ограничить размер возвращаемых данных (например, с помощью limit и offset) без существенного изменения логики приложения или избавиться от большого количества вычисляемых свойств возвращаемого объекта (чтобы ускорить работу сервера по формированию ответа).

После нескольких экспериментов я пришел к следующему:

  • запрашиваем данные при запуске сервера;
  • возвращаем эти данные клиенту без обращения к БД;
  • периодически обновляем данные (каждые 20 минут) для обеспечения их актуальности.

Реализуем это на примере категорий (categories) для страницы каталога товаров (Catalog, catalog.js). Для этого немного модифицируем кастомный сервер в файле server/index.js.

Импортируем утилиту для создания задач из библиотеки cron:

const { CronJob } = require('cron')

Определяем дефолтные категории и глобальную переменную для категорий:

const DEFAULT_CATEGORIES = [   {     id: 1,     title: 'First category',     products: []   },   {     id: 2,     title: 'Second category',     products: []   },   {     id: 3,     title: 'Third category',     products: []   } ]  let allCategories = []

Определяем функцию для получения категорий и вызываем ее:

async function updateCategories() {   try {     const categories = await Promise.resolve(DEFAULT_CATEGORIES)     // записываем категории, якобы полученные из БД, в глобальную переменную     allCategories = categories   } catch (err) {     console.error(err)   } } updateCategories()

Определяем cron-задачу для обновления категорий каждые 20 минут:

const cronJobForCategories = new CronJob(   '0/20 * * * *',   updateCategories,   null,   false,   'Europe/Moscow' )

Сигнатура конструктора Cron:

constructor(cronTime, onTick, onComplete, start, timezone)

  • cron: string — время в специальном формате (онлайн-редактор). В данном случае «работа» будет выполняться каждые 20 минут;
  • onTick: function — функция, запускаемая при выполнении «работы»;
  • onComplete: function? — функция, запускаемая после выполнения работы (символ ? означает, что параметр является опциональным);
  • start?: boolean — индикатор запуска «работы» после создания;
  • timezone?: string — временная зона и т.д. С полным списком параметров можно ознакомиться здесь.

Наконец, запускаем выполнение «работы» после запуска сервера при условии, что приложение запущено в производственном режиме:

server.listen(port, (err) => {   if (err) return console.error(err)    console.log(`? Server ready on port ${port}`) })  // ! if (!dev) {   cronJobForCategories.start() }

Отлично, данная задача нами также успешно решена.

Для полного счастья нам не хватает только «контейнеризации» Next-приложения. Давайте это исправим.

Создаем в корневой директории файл Dockerfile следующего содержания:

# дефолтная версия `Node.js` ARG NODE_VERSION=16.13.1  # образ FROM node:${NODE_VERSION}  # рабочия директория WORKDIR /app  # копируем указанные файлы в рабочую директорию COPY package.json yarn.lock ./  # устанавливаем зависимости RUN yarn  # копируем остальные файлы COPY . .  # выполняем сборку приложения RUN yarn build  # запускаем кастомный сервер в производственном режиме CMD ["yarn", "start"]

Редактируем файл docker-compose.yml:

version: '3.9' services:   next:     env_file: .env     # это важно: название хоста `redis` должно совпадать с названием соответствующего сервиса     environment:       - REDIS_HOST=redis     container_name: ${APP_NAME}_next     # контекст сборки     build: .     ports:       - 5000:5000     restart: on-failure    redis:     env_file: .env     container_name: ${APP_NAME}_redis     image: bitnami/redis:latest     volumes:       - ./data_redis:/data     ports:       - 6379:6379     restart: on-failure

Останавливаем и удаляем сервис:

docker compose stop docker compose rm

Запускаем сервис с помощью docker compose up -d.

Отправляем GET-запрос к http://localhost:5000/clear-cache с заголовком x-verification-code: super-secret для очистки кеша, например, с помощью Insomnia.

Получаем сообщение об очистке кеша от redis (@clear cache).

Проверяем работоспособность приложения.

Круто! Все работает, как ожидается.

Обратите внимание: в реальных приложениях страницы следует кешировать только в производственном режиме. Для этого можно использовать переменную const isProd = process.env.ENV === 'production', например.

Пожалуй, это все, о чем я хотел рассказать вам в этой статье.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/659511/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *