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


В одной из предыдущих статей я рассказывал об оптимизации изображений с помощью 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!




Комментарии (1)


  1. vcebotari
    07.04.2022 13:03
    +1

    чем это лучше SSG от Next.js ?