Думаю многие сталкивались со словом оптимизация в контексте работы с сайтами. Эта оптимизация может быть на многих уровнях, но всегда говорят об оптимизации сайтов и веб приложений затрагивают три основных аспекта:
Скорость ответа сервера
Вес ответа сервера
Затрачиваемые ресурсы на ответ
И если вес исправляется как правило эффективным сжатием, выбрасыванием не нужного, то в этой статье мы рассмотрим как можно оптимизировать скорость ответа и затрачиваемые ресурсы.
Кеш, я кеш
Скорость ответа как правило зависит не столь от больших и важных бизнес задач, или от количества ваших клиентов в данную секунду. Высокая скорость ответа сервера как правило зависит от того, как хорошо и быстро отвечает ваша база данный, и как скоро она отдаёт вам вашу информацию. Будь то простая статья вроде этой, или интернет магазин, а может расчёт стоимости поездки в соседнюю страну по горячему туру. И как правило для того что бы ускорить работу нашего сайта мы оптимизируем первым делом базу данных. Оптимизация индексов, партицирование таблиц, оптимизация самих запросов выступают своего рода первым уровнем, тем с чего всё начинается - оптимизация программ. Однако после первого уровня появляется второй - кеширование, ведь самый быстрый код / запрос тот, что не исполняется. И с Этого момента открывается кроличья нора. Ибо кеш может перекрывать самые тяжёлые и важные операции, или сохранят самые простые, но частые запросы, а может и вовсе быть все объемлющим. Мы можем кешировать работу целых отдельных сервисов, можем полностью подменять их вызов на получение заранее подготовленного кеша, и ничего не терять в актуальности данных долгое время (прим.: api дистанций меж городов). Но если мы можем подменять отдельные обращения к базе данных, и даже результаты работы каких-то сервисов, то можем ли мы подменить работу целого приложения заранее заготовленным кешом?
Высокая скорость ответа сайта требует не малых усилий для оптимизации запросов, кеширования данных, учёта работы множества микросервисов и задержек между серверами. Однако ответ пользователю очень часто повторяющийся для всех пользователей html файл. Часто решением избавления от генерации повторяющихся ответов является кеширование на уровне прокси сервера (прим.: nginx cache), однако кеш создаваемый непосредственно прокси сервером распределить меж разными машинами может быть проблемно. Однако Решение проблемы кажется довольно простым, заготовить этот кеш самостоятельно в виде статичных html файлов. Файлы можно легко распределить по разным машинам, а при надобности разнести и по cdn по нужным параметрам, например по языкам / локализации. Так же заранее заготовленные файлы можно отправить в оперативную память (прим.: memcached) и ускорить доступ к html. Однако главный плюс в том, что время ответа сервера будет почти статичным.
Таким образом мы оптимизируем скорость ответа, а не обращаясь к веренице сервисов и миркросервисов, и так же мы сокращаем бюджеты на ресурсы.
Перед тем как рассматривать примеры кода и конфигурации сервера для генерации стоит. рассмотреть
Описание проекта ssg-example
Данный пример призван показать и объяснить использование ssg на примере nuxt3. А так же подсветить плюсы и минусы использования такого подхода. В примере используется google books api, go как proxy backend, nuxt 3, kafka, и прочие технологии.
Идея
Как было сказано ранее мы можем сгенерировать весь нужный нам контент (страницы) заранее, при определённых условиях. С уже подготовленным контентом мы можем поступать различными способами. Отправлять на cdn, использовать в memcached подключённом к nginx, или иным способом использовать для скорейшей доставки пользователю. Идея же данного проекта сгенерировать этот контент используя стандартный ssr nuxt проект с максимально малыми изменениями.
Масштабирование
При работе с масштабирование статичной генерации страниц я столкнулся с несколькими проблемами. Какие-то из были весьма очевидны, вроде надобности в нескольких запущенных версиях frontend - Так сказать - Па-ра-лле-ли-за-ция сынок. Или отстранения от боевого backend для того что бы не пытаться положить его длительными нагрузками в момент генерации. Или важность потоковой записи на диск, ибо забить io парой тысяч отдельных записей в секунду совершенно не проблема, и нужно писать потоками, в архивы.
Параллелизация. И это весьма обширная тема, её я затрону только на личном примере, что бы подсветить основные моменты, потому что структура данных и подход к получению данных может отличаться от проекта к проекту.
Я выбираю весьма отстранённый подход для параллелизации этого процесса. Иначе говоря я стараюсь поставить между backend и frontend прослойку которая будет брать все возможные данные из backend, или базы данных сразу же бить на группы и передавать, используя брокер сообщений, в запущенные frontend контейнеры. Как итог мы получаем простую, обходную систему которая может быть изолированна от реального, боевого контура и выносить всю дополнительную нагрузку на отдельные машины / сервера. Либо, как в примере можно использовать обратный подход, когда на стороне backend есть процесс, задача на заполнение / изменение данных для генерации, что может предоставить весьма не плохие возможности в будущем для поставки процесса пере-генерации страниц в случае их изменения (прим.: изменения наличия товаров, цены).
Io и архивация. Создание сотен и тысяч отдельных потоков записи на диск занимает дополнительное время, и когда мы говорим о сотнях тысяч и миллионах таких записей в сумме это отнимает непозволительно много времени. Проблема решается в существе своём множеством интересных способов, мы можем отправить готовые страницы сразу туда, где мы хотели бы их видеть, и конкретный канал передачи сейчас не столь важен. Но так же можем и предварительно записать на диск, как в примере в архивы с некоторым количеством страниц на штуку, что бы в случае ошибки, или иной причины аварийного завершения нам не пришлось проделывать всю работу вновь.
Разархивация множества архивов может показаться на первый взгляд проблемой, но что бы развеять ваши сомнения предлагаю простой однострочный пример используемый в данной реализации.
for file in `find ./**/*.tar`; do tar -s ',\\,/,g' -xf "${file}" -C ./; done
Реализация frontend
В данном примере я решил использовать nuxt 3, из за того что писать статью начал ещё до выхода nuxt 4, и за удобство nuxt, и за привычку его использовать.
Nuxt использует H3Server, и позволяет получить текущий H3Event в контексте компонента по средствам вызова useRequestEvent, далее event. event предполагает что в нём есть context с которым мы можем передать payload.
Если мы хотим использовать ssr, а с ним ssg, то как ни крутись нам нужно получать данные от источника (api). Мы можем модифицировать наш вызов апи с подстановкой данных из event.
event?.context?.payload
Пример можно посмотреть тут frontend/components/book-list.vue
Я хотел бы сказать что nuxt generation достаточно для генерации страниц любого объёма и количества, однако когда речь пошла о десятках и сотнях тысяч это оказалось не так. Пришлось немного закапаться в код, но как и в nuxt2 в nuxt3 получилось найти корень отвечающий за генерацию страниц ssr.
Вариант для nuxt 2:
import config from '../nuxt.config';
const Core = require('@nuxt/core');
const utils = require('@nuxt/utils');
// ...
const nuxt = new Core.Nuxt(config);
await nuxt.ready();
// ...
const payload = {
payload: typeof item === 'object' ? item.payload : {},
};
const result = await nuxt.server.renderRoute(route, payload, { loadingTimeout: 60000 });
results.push({
name: fileName,
data: Buffer.from(result.html),
staticAssets: (payload as any).staticAssets as any,
})
И asyncData в компоненте вида
async asyncData({payload}){}
Вариант nuxt 3:
import { loadNuxt } from "@nuxt/kit";
// starting server
const _ = await import('../.output/server/index.mjs')
const nitro = await import('../.output/server/chunks/nitro/nitro.mjs')
const renderer = await import('../.output/server/chunks/routes/renderer.mjs')
const nuxt = await loadNuxt({
config: {
ssr: true,
},
})
await nuxt.ready()
// ...
const result = await renderer.r.default({
path: url + '/' + (Number(index) + 1),
context: {
nuxt,
nitro: nitro.f(),
payload: { books: payload, totalItems }
},
node: {
res: new http.ServerResponse(new http.IncomingMessage(new net.Socket()))
}
})
results.push({
name: path.join(url + '/' + (Number(index) + 1), 'index.html'),
data: result,
})
Пример можно посмотреть тут frontend/generation/generation.ts
На самом деле реализация с использованием nuxt / h3server не имеет принципиальной важности, а скорее призвана отобразить основной принцип и идею. Каждая страница, каждый вызов api, который несёт данные для ssr, при генерации должен продублирован свойством, методом, декоратором, или иным способом подстановки заранее подготовленных данных.
Реализация backend
Конкретная реализация backend как и конкретная, приведённая тут реализация на nuxt не несёт в себе ключевого значения. Однако стоит подчеркнуть важность пары моментов: Идемпотентность поведения. Все запросы которые применяются для генерации, в независимости от обстоятельств при одних и тех же вводных должны обещать одно и тоже поведение. Отсутствие посторонних эффектов. Запросы которые предназначены для чтения не должны иметь значимых эффектов, они должны только отдавать нужную информацию, что бы отсутствие этих запросов со стороны реального клиента не влияло на работу приложения в глобальном плане (прим.: продление авторизации).
Реализация nginx
Реализация на стороне proxy сервера наверное самая простая сторона этого вопроса, на мой взгляд. Потому что всё что нас интересует, это как доставить до сервера нужные данные - файлы. Может использоваться как типичный sftp, так и иные варианты передачи, хоть тот же s3, или если вам кажется это разумным git. Но что куда важнее это варианты настройки. Мы можем, в случае с nginx использовать 3 основных варианта. Статичные файлы или вот такие статичные файлы, файловый кеш, memcached.
И если в своей сути первые два варианта это чтение с диска, просто в удобной вам форме и директории, то memcached, лично мне, кажется куда более правильным вариантом в плане быстродействия. try_files и предоставляет удобный интерфейс обработки на случай если нужный файл не найден как в примере. Это то что позволяет строить более гибкую логику, и некоторые страницы оставить за ssr / spa (прим.: страницу по пользователе), хоть и добавляет некоторый overhead. Однако конечный выбор всегда должен быть обоснован спецификой конкретно вашего решения / продукта.
Итоги
Мы рассмотрели довольно много разных подходов к отображению пользовательского ui на странице в браузере, spa, ssr, island. Конечно мы не трогали радикальные методы вроде all in canvas. Но у всех из них много своих плюсов и минусов. И коротко о плюсах и минусах ssg:
Плюсы
Однако стоит выделить главные плюсы нашего подхода. SSG позволяет сократить нагрузку на сервера, ускорить доставку контента пользователю, использовать стандартные ssr решения с малой модификацией, и что не сразу очевидно проверить Все страницы на неожиданные ошибки. Так же такой подход позволяет не переписывать весь legacy проект уже использующий тот или иной вариант ssr, можно просто модифицировать его с ответа конкретному пользователю, на генерацию страниц и получить максимум за минимум усилий.
Минусы
При всех своих плюсах такой подход весьма требователен к пониманию проекта на котором он будет реализовываться. Вы должны понимать какие данные, где, почему и зачем используются. Всё от локальных данных страницы (прим.: заголовок, текст статьи), до глобальных данных используемых по всему проекту (прим.: меню) должны быть учтены. Помимо этого подход с генерацией не позволяет подходить персонифицировано к генерации страницы, все пользовательские, часто меняемые данные должны быть загружен уже после загрузки страницы.
Альтернативные подходы
Существует уже не мало подходов к подготовке html файлов для пользователя.
SPA и MPA
SSR - в этот же пункт запишем любую полную генерацию страницы на лету, по запросу пользователя.
island И ниже я хотел бы кратко рассмотреть плюсы и минусы этих подходов
SPA и MPA
Приложение и фактический рендер на стороне клиента. У нас нет реальных страниц, нет реальной маршрутизации запросов на стороне сервера. Это очень частый подход для написания бизнес приложений. Таблицы информации, дашборды с графиками, множество форм. Это очень простой в поддержке и исполнении вариант для написания приложений и сайтов. Минусы подхода большая нагрузка на браузер клиента, исторически считается что ужасная SEO оптимизация, а так же большой итоговый вес сборки для пользователя. Иначе говоря это не то, что вы хотели бы видеть в качестве основы вашего магазина товаров или услуг.
SSR - генерация на лету
На самом деле классический вариант это генерация страницы на лету. Будь то старый добрый index.php, или всем уже привычный ssr с использование nodejs, это тот самый классический в наше время вариант когда на запрос пользователя каждый раз в итоге для ответа создаётся html страница. Этот формат имеет, как считается, лучшую SEO оптимизацию чем SPA / MPA вариант, он позволяет на один и тот же запрос отвечать по разному, и конечно же даёт возможность на каждое обращение пользователя выполнять какую бы то ни было бизнес логику на стороне сервера. Минусы - самый первый, и наверное самый важный минус заключается в том, в случае современных web приложений, что мы ждём, скажем так, дважды. Пока страница будет подготовлена на стороне сервера, и когда страница отобразится, и запустит свою внутреннюю javascript / wasm логику. Ожидание ответа от сервера и является во многом тем самым ключевым моментом для оптимизации, и написания этой статьи. Так же существенным минусом является выделение серверных мощностей на пускай и чаще всего быстрые, но не очень сильно необходимые повторяющиеся из раза в раз действия - рендер html, и прочих частей сайта.
Island и Astro
В эпоху cms многие пытались обойти повторный рендер внедрением компонентной структуры, что бы точно знать что можно переиспользовать, умным кешем который сам отслеживал не изменяемые блоки и код который был за них ответственен. Иначе говоря всеми способами пытались зонировать код на активный и не активный... В этот момент я думал вспомнить bitrix, и пол часа просидел в флешбеках... Люблю запах кеша по утру.
Лучше упомянуть современные решения island, astro. Они развивают идею зонирования или, иначе говоря разбивания на острова / блоки. И island и astro являются своего рода фреймворками над фреймворками. Они позволяют использовать большой список ui фреймворков, разграничивать ответственность между ssr и клиентским рендером. Из минусов то что это требует дополнительной работы с определением островов / блоков, но во многом позволяет лучше контролировать работу страницы.