Долгое предисловие о том где и как используется
Читая такие статьи как про Капибару, мне хочется упомянуть один свой старый/новый проект, в изначальном виде я затеял как проект реставрации старого форума сети Минска (uruchie.org) из далеких 2006-2012 годов, который хранился у меня в замороженном виде последние 10 лет. Не так давно я решил его расконсервировать и заняться реставрацией в свободное время, того, фактически, что осталось еще со времен локальных сетей.
Я сразу отбросил почти все что было, это старый движок vBulletin - на тот момент крайне перспективный и развивающийся движок форума на PHP, и убрав почти все, оставив только базу данных из 250 000 сообщений и 5000 пользователей начал реализовывать новые концепции которые хотелось видеть. Если кому-то интересно то, кстати, одна из причин гибели такого старого и долгого проекта был именно vBulletin и безопасность.
Ссылка на рабочую версия нового ресурса: https://talkvio.com (заходим, регистрируемся, пишем, предлагаем идеи)
За основу взял что душе угодно для таких целей:
Backend: NodeJS + MySQL + Redis + Manticore (у меня с ним был крайне приятный опыт на других своих старых проектах) + Bash + отдельные модули на Python + Nginx
UI: React
За последние пол года я уже многое восстановил так и реализовал того что не было (может у меня и нет команды как у капибары, тем не менее что уже есть):
Посты, разделы (форумы), темы
Редактирование, удаление, создание постов
Специальный блочный редактор с черновиками. Где можно комбинировать блоки и элементы.
Мое / авторский контент (пометки и категории)
В основе взаимодействия с функциями ресурса является карма, а не рейтинг. Карма была внедрена в форум еще в 2008 под влиянием хабра, так там и осталась. Что может греть душу хабровчан. Всегда можно контролировать и ограничивать пользователя вне зависимости от того сколько рейтинга он набрал если его поведение начнет портиться.
Черновики для постов
Черновики для комментариев - сохраняет даже недописанные ответы
Спойлеры, и 18+ контент
Актуальное, Топ, Самое комментируемое, Подписки и другие разделы и фильтрации
Оформление своей страницы
Настройки
Увеличение картинок
Оповещения (например при цитированиях вашего текста и обращениях к вам можно включить оповещения)
Реверсные и прямые отображения постов
Темная тема
Автоматический постинг контента в vk, телегу, discord
Отложенные посты (публикация по расписания)
Подъем поста - особая фича когда свои старые посты, не получившие достаточно рейтинга можно попробовать в зависимости от кармы и очков поднять повторно
Сайт доступен на двух языках: английском и русском на данный момент. Есть система локализаций.
Ну и многое другое, я уже сам все не вспомню :) Загляните в тему ниже:
Вообще все изменения и предложения реализовываю в этой теме
В этой же статье пойдет речь про один из интересных кусков которых пришлось реализовать - серверный рендеринг для модуля индексации.
О том зачем нужен серверный рендеринг
Т.к. в основе frontend составляющей на Talkvio является React, основной рендеринг происходит не на стороне сервера а на стороне клиента. Это хорошо себя показывает в браузерах, но совершенно не подходит для поисков типа яндекса или гугла. Но т.к. индексация для таких проектов как у меня является важной частью, эту проблему нужно тоже решать. При этом хочется все оставить как есть - клиент должен по прежнему рендериться на стороне браузера клиента - это эффективно (например в случае обратных коммуникаций), а поисковикам должен отдаваться сгенерированный html на стороне сервера. При этом сам ресурс должен определять момент когда он считает что поисковик может брать весь загруженный контент, учитывая при этом специфику разнообразной подгрузки контента по вебсокетами ajax в двух направлениях.
Для этих целей можно использовать и Next.js являющийся по сути отдельным фреймворком для схожих целей, но одновременно налагающий определенные ограничения на принцип формирования страниц и структуры проекта. Фактически в данной статье я рассмотрю альтернативу, которая не требуют каких-либо изменений проекта, за исключением экспортирования флага, который будет сигнализировать серверный рендеринг о том, что ваша страница является загруженной и будет являться универсальным для любого из фреймворков.
История. Вернемся в далекий 2015. Как было раньше
Учитывая что большинству поисковиков рендерить нагрузочный JS было лень, на помощь приходит волшебный параметр ?_escaped_fragment_=. Его поисковые боты обычно передают серверу в надежде что сервер сам рендерить html и отдаст сгенерированный html заботясь о целостности информации. По крайне мере так это было еще недавно, но время течет, все меняется, и вот уже он deprecated в 2015 году, https://developers.google.com/search/blog/2015/10/deprecating-our-ajax-crawling-scheme
. Хоть в статье и утверждается что google теперь прекрасно понимает “ваш сайт” и все будет хорошо и с JS, на деле ничего он не понимает ничего. Когда на сайте все рендериться кусочно, либо вебсокетами, где коммуникация в обе стороны, и другим вуду - в поисковике мы все равно получим пустые страницы. Тем не менее, пока держим этот старый deprecated параметр в голове, реализовать его, в связи с поддержкой других поисковиков, тоже можно.
Другим хорошим примером инструмента канувшего в историю https://phantomjs.org/ был phantomjs, фактически порт хрома для командной строки, но к сожалению устарел настолько что ваше современное js приложении уже вряд ли потянет. Поддержки его больше нет. Хоть по меркам запуска хрома и выполнения js вполне справлялся с задачами рендеринга в былые времена.
Что можно взять сейчас?
Что же можно взять теперь. Мой взгляд пал на проект Puppeteer - очень неплохой проект по управлению хромом из nodejs. Так же как selenium задумывался в основном для тестинга страниц. Устанавливаем его.
npm install puppeteer
В целом создатель уже позаботился о том чтобы в комплекте был нужный chromium под нужную систему. Так что подбирать версию в самой системе надобности нет.
Думаю, читатель уже догадался к чему все идет и как же все будет реализовано. Приоткрываем завесу тайны - на сервере (можно отдельном) будет крутиться chrome который фактически будет заниматься рендерингом страниц исключительно для поисковиков.
У такого подхода конечно же есть и плюсы и минусы. Из минусов сразу же хочется отметить это конечно вытекающие проблемы хрома: в частности использование cpu и памяти, быстродействие браузера - это уже будет теперь проблема вашего сервера/серверов. Но плюсы тоже есть: рендеринг html будет в соответствии с современными тенденциями и стандартами js, никаких модификаций проекта не потребуется как и использования сторонних фреймворков. В комплекте Puppeteer идет последня версия chromium, да и в целом учитывая что мы имеем дело с поисковиками - отдавать контент совсем уж бесперебойно нужды нет. Итак, поехали.
Настройка nginx
Для начала разбираемся с конфигурацией Nginx.
Сервер будет крутиться на 5300 порте и доступен по адресу /snapshot. Фактически именно туда мы будет адресовать все запросы.
location /snapshot {
proxy_pass http://127.0.0.1:5300;
proxy_http_version 1.1;
}
location / {
set $prerender 0;
if ($args ~* "_escaped_fragment_=") {
set $prerender 1;
}
if ($http_user_agent ~* "Google-InspectionTool|Googlebot|Googlebot-Image|Google-Site-Verification|Google\ Web\ Preview|googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
set $prerender 1;
}
if ($http_user_agent ~ "Prerender") {
set $prerender 0;
}
if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
set $prerender 0;
}
if ($prerender = 1) {
rewrite ^ /snapshot$uri last;
}
try_files $uri /index.html;
}
Флаг prerender как раз и определяет разделяя обычный клиент от поисковиков.
if ($args ~* "_escaped_fragment_=") {
set $prerender 1;
}
Устанавливаем prerender в случае ?_escaped_fragment_=
Так же берем огромный список юзер-агентов поисковиков и во всех случаях устанавливаем prerender в 1.
Для картинок и файлов - можно обращаться к серверу напрямую без пререндеринга, так быстрее.
Теперь начинаем реализовывать сам сервер рендеринга. И перед тем как начать нужно решить одну из фундаментальных проблем такой реализации. Хотим ли мы запускать браузер на каждый запрос, или хотим чтобы запросы открывались каждый в своей вкладке? Ответ с точки быстродействия и эффективности очевиден - лучше просто во вкладках. Но это в определенной мере идет в разрез с тем как по офф. документации работает Puppeteer, ему больше нравиться все открывать заново.
За то что мы будем открывать вкладки мы еще поплатимся, но об этом ниже. Если кратко, то чем нам это аукнется - это необходимость большего контроля памяти рендерещего сервера.
Реализация
Реализация index.js для nodejs.
Для начала инициализируем браузер
let browser;
async function init() {
browser = await puppeteer.launch({headless: 'old', protocolTimeout: 0});
server.listen(5300);
}
init();
Тут вроде все понятно - стартуем и запускаем express на 5300 порте. Он будет принимать запросы от Nginx.
Отдельно стоит подметить headless: 'old' параметр запуска - по моему опыту ‘new’ версия вела себя нестабильно. protocolTimeout Отключаем.
Как уже было сказано выше, теперь у нас одна сессия хрома на все. А хром это дело тонкое, наверное многие замечали как утекает у него память после часа работы. Поэтому лучше перезапустить его где-то через час, освободив память и запустив сессию с нуля.
let browserReopen = false;
setInterval(async () => {
browserReopen = true;
await browser.close();
browser = await puppeteer.launch({headless: 'old', protocolTimeout: 0});
browserReopen = false;
}, (60 * 60) * 1000)
browserReopen флаг будет контролировать статус перезапуска, в момент перезапуска будет отвечать всем ошибкой 503.
if (browserReopen) {
res.status(503);
res.send();
return;
}
Реализовываем теперь сами ответы на запросы к серверу.
const page = await browser.newPage();
let link = req.originalUrl;
тем самым открываем новую вкладку и получаем ссылку запроса
if (link.includes('/snapshot')) {
link = link.replace('/snapshot', '');
}
if (link.includes('_escaped_fragment_')) {
link = link.replace(/\?_escaped_fragment_\=?(.*)/, '');
}
убираем все что мы накуролесили с location на стороне nginx, в том числе потенциальный _escaped_fragment_
try {
await page.goto('https://talkvio.com' + link, {waitUntil: 'domcontentloaded'});
await page.waitForFunction('window.didFinish === 0');
} catch(err) {
res.status(503);
res.send();
await page.close();
return;
}
открываем страницу на новой кладке и ждем пока страница загрузится, и пока отработает сигнал о состоянии загруженной страницы window.didFinish. Напомню: реализация этого сигнала/флага уже должна находиться на стороне клиента, в данном случае проекта Talkvio. В вашем случае вы конечно же должны сами определить когда считаете что на странице есть все что нужно чтобы отдать html. Ну и конечно если что-то не так дропаем в ошибку 503 чтобы попробовали попозже.
let content = await page.content();
content = content.replace('<meta name="fragment" content="!" />', '');
Убираем meta тег фрагмент если такой есть. Это говорит поисковику о том, что генерация страницы уже была произведена и поисковику не нужно тратить время на это.
res.send(content);
await page.close();
Ну и наконец отдаем страницу и закрываем вкладку.
На этом все. Можно еще покрыть все блоком try catch на случай срабатывания таймаутов и ошибок и перезапустить браузер в случае чего.
Собираем реализацию воедино
const puppeteer = require('puppeteer');
const express = require('express');
const app = express();
const server = require('http').Server(app);
let browser;
async function init() {
browser = await puppeteer.launch({headless: 'old', protocolTimeout: 0});
server.listen(5300);
}
init();
let browserReopen = false;
setInterval(async () => {
browserReopen = true;
await browser.close();
browser = await puppeteer.launch({headless: 'old', protocolTimeout: 0});
browserReopen = false;
}, (60 * 60) * 1000)
app.get('*', async function (req, res) {
try
{
if (!req.originalUrl.includes('.')) {
if (browserReopen) {
res.status(503);
res.send();
return;
}
const page = await browser.newPage();
let link = req.originalUrl;
if (link.includes('/snapshot')) {
link = link.replace('/snapshot', '');
}
if (link.includes('_escaped_fragment_')) {
link = link.replace(/\?_escaped_fragment_\=?(.*)/, '');
}
try {
await page.goto('https://talkvio.com' + link, {waitUntil: 'domcontentloaded'});
await page.waitForFunction('window.didFinish === 0');
} catch(err) {
res.status(503);
res.send();
await page.close();
return;
}
let content = await page.content();
content = content.replace('<meta name="fragment" content="!" />', '');
content = content.replace('<meta name="fragment" content="!">', '');
res.send(content);
await page.close();
} else {
res.send();
}
} catch(err) {
await browser.close();
browser = await puppeteer.launch({headless: 'old', protocolTimeout: 0});
res.status(503);
res.send();
return;
}
});
Память и еще раз о ней. Нагрузочное тестирование
Не забыли о чем я говорил ранее? Мы конечно получили довольно быстрый и эффективный рендеринг, но поплатились тем что теперь у нас одна сессия хрома которую нужно контролировать. И прежде всего это сулит проблемы с памятью в высокой нагрузке. Например в 7 часов утра Google с яндексом могут штудировать ваши страницы как не в себя, и пока вы тихо уткнулись носом в подушку и пускаете слюни, ваш сервер дымит как паровоз и уже вылазить в свап от 200 вкладок в chrome. Этот случай надо предусмотреть, и ввести ограничения как по памяти, так и по объему вкладок. Лучше выдать лишний раз ошибку чем получить мертвый перегруженный сервер.
В обработку добавляем лимит по вкладкам
const numberOfOpenPages = (await browser.pages()).length;
if (numberOfOpenPages > PAGES_LIMIT) {
res.status(503);
res.send();
return;
}
На практике я подобрал себе ограничение в 4 вкладки, как мне кажется если браузер не справляется все рендерить на таком ограничении, то это уже явная перегрузка.
Можно еще ограничить потребление по памяти в 70% от сервера или как вам удобнее
if (currentMemoryUsage > MEMORY_LOW_BORDER_LIMIT) {
res.status(503);
res.send();
return;
}
Оба эти ограничения сыграют важную роль в стабильности работы системы при любой нагрузке.
На этом все, спасибо за прочтение :)