Долгое предисловие о том где и как используется

Читая такие статьи как про Капибару, мне хочется упомянуть один свой старый/новый проект, в изначальном виде я затеял как проект реставрации старого форума сети Минска (uruchie.org) из далеких 2006-2012 годов, который хранился у меня в замороженном виде последние 10 лет. Не так давно я решил его расконсервировать и заняться реставрацией в свободное время, того, фактически, что осталось еще со времен локальных сетей. 

Я сразу отбросил почти все что было, это старый движок vBulletin - на тот момент крайне перспективный и развивающийся движок форума на PHP, и убрав почти все, оставив только базу данных из 250 000 сообщений и 5000 пользователей начал реализовывать новые концепции которые хотелось видеть. Если кому-то интересно то, кстати, одна из причин гибели такого старого и долгого проекта был именно vBulletin и безопасность.

Ссылка на рабочую версия нового ресурса: https://talkvio.com (заходим, регистрируемся, пишем, предлагаем идеи)

За основу взял что душе угодно для таких целей:

Backend: NodeJS + MySQL + Redis + Manticore (у меня с ним был крайне приятный опыт на других своих старых проектах) + Bash + отдельные модули на Python + Nginx

UI: React

Talkvio
Talkvio

За последние пол года я уже многое восстановил так и реализовал того что не было (может у меня и нет команды как у капибары, тем не менее что уже есть):

  • Посты, разделы (форумы), темы

  • Редактирование, удаление, создание постов

  • Специальный блочный редактор с черновиками. Где можно комбинировать блоки и элементы.

  • Мое / авторский контент (пометки и категории)

  • В основе взаимодействия с функциями ресурса является карма, а не рейтинг. Карма была внедрена в форум еще в 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;
 }

Оба эти ограничения сыграют важную роль в стабильности работы системы при любой нагрузке.

На этом все, спасибо за прочтение :)

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


  1. LeshaRB
    27.11.2023 08:34

    А как восстановить пароль, если не помнишь email (все-таки мое последнее сообщение 11 лет назад)
    И уверен на 100% там ..@maill.by я не восстановлю уже


    1. DEgITx Автор
      27.11.2023 08:34

      Большинство почт было на tut.by мертвый. Я был очень удивлен узнать, но оказывается хоть в рб и прикрыли этот домен но mx запись жива, и почта на эти адреса всё ещё доходит редиректя на яндекс. Я делал небольшие группы рассылок, конечно те кто в 2010 юзали gmail восстанавливали пароль чаще. Но тем не менее были и такие. Моя самая большая ошибка как раз в другом, до 2010 кажется учитывая что сеть была локальная на форуме не было верификации почты, и в итоге помимо устаревшей почты есть очень много нереальных адресов.


    1. DEgITx Автор
      27.11.2023 08:34

      Еще есть очень интересный пост, интересно было узнать как поменялась жизнь людей за эти 10 лет. https://talkvio.com/threads/24680-chto-sluchilos-s-vami-za-poslednie-10-let можете ознакомиться ).


  1. DarthVictor
    27.11.2023 08:34

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

    Не лишним будет сказать, что Next.js накладывает такие ограничения, потому что он именно что фреймворк. ReactJS с первых версий вполне самостоятельно умеет рендерится на NodeJS в строку.


  1. xTiM4x
    27.11.2023 08:34
    +1

    У меня на одном из проектов Vue и для решения проблемы с рендерингом для всяких честных краулеров (гугл, яндекс, фб, линкедин и т. д.) используется похожее решение, только мы опирались на prerender и работает наше решение на aws lambda@edge + для некоторых случаев на nginx. И хочу сказать, что это еще одна головная боль, конечно. Нужно держать руку на пульсе и обновлять список хороших/плохих краулеров, для кого нужно рендерить, для кого не нужно. А самый новый хром для лямбд - 96 версия или около того (3-4 года ей уже). Короче, пришли к выводу, что проще переходить на server rendering, чем пилить костыли для того чтобы всё работало как должно


    1. DEgITx Автор
      27.11.2023 08:34

      Спасибо, интересно было узнать что там прям так грустно по версиям. Ну конечно не phantomjs который там на каких-то 40-60 версиях основан, но все равно выглядит странно, учитывая что у нас на работе на android железе заставили пересобирать chromium с 116 на 118 версию тупо из-за уязвимости в декодере недавней).
      У меня у друга проект тесно повязан на лямбды, у него там какая-то другая головная боль - их цикл жизни и перезапуск который по итогу его мучает и он с ним сражается.


  1. Brucey
    27.11.2023 08:34

    В качестве баловства - забавно, конечно, но египетские боги - в какой же бред превратился современный фронтенд. Как можно читать эту статью не хватаясь за голову (не пытаюсь обидеть автора)?

    И как на SEO повлияет то, что бот будет получать сплошные ошибки при превышении определённой нагрузки, думаю тоже очевидно.


    1. DEgITx Автор
      27.11.2023 08:34

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

      Тут все зависит от цели. Если бы приоритет был в сторону SEO и поиск и все выстраивается от него, я бы рекомендовал смотреть в сторону Next.js, или даже в сторону серверного рендеринга вплоть до связки с PHP или статических сайтов - там не ошибешься. Данное же решение хорошо когда индексация опциональна и она просто должна быть, и вам не хочется выстраивать проект исключительно под нее. В моем случае там около 400 000 страниц, мне просто выгодно чтобы они индексировать постепенно и точно никак не влияя на сам проект и его технологии.


    1. DEgITx Автор
      27.11.2023 08:34

      А насчет современного фронтенда я согласен

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


      1. Brucey
        27.11.2023 08:34
        +1

        Согласен со всем, что вы написали, чисто технически.

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

        То что Next решает этот вопрос - это, конечно, хорошо, но если проект изначально был написан на чём-то другом, то помимо фронта получается нужно переписывать ещё и весь бэк.

        Делали JS-фреймворки для улучшения "дев-экспириенса", а в результате скатились в кромешный ад.