Всем привет! На протяжении года мы переходим на React и задумались о том, как бы сделать так, чтобы наши пользователи не ждали клиентской шаблонизации, а видели страницу как можно быстрее. С этой целью решили делать серверный рендеринг (SSR — Server Side Rendering) и оптимизировать SEO, ведь не все поисковые движки умеют исполнять JS, а те, которые умеют, тратят время на исполнение, а время краулинга каждого сайта ограничено.



Напомню, что серверный рендеринг — это выполнение JavaScript-кода на стороне сервера, чтобы отдать клиенту уже готовый HTML. Это влияет на воспринимаемую пользователем производительность, особенно на слабых машинах и при медленном интернете. Нет необходимости дожидаться пока скачается, распарсится и выполнится JS. Браузеру остается только отрисовать HTML сразу, не дожидаясь JSa, пользователь уже может читать контент.
Таким образом сокращается фаза пассивного ожидания. Браузеру после рендера останется пройтись по готовому DOM, проверить, что он совпадает с тем, что отрендерилось
на клиенте, и добавить слушателей событий (event listeners). Такой процесс называется гидрацией. Если в процессе гидрации произойдёт расхождение контента от сервера и сгенерированного браузером, получим предупреждение в консоли и лишний ререндер на клиенте. Такого быть не должно, надо следить за тем, чтобы результат работы серверного и клиентского рендеринга совпадали. Если они расходятся, то к этому следует отнестись как багу, так как это сводит на нет преимущества серверного рендеринга. В случае если какой-то элемент должен расходиться, надо добавить ему suppressHydrationWarning={true}.


Помимо этого есть один нюанс: на сервере нет window. Код, который обращается к нему, должен выполняться в lifecycle методах, не вызываемых на стороне сервера. То есть, нельзя использовать window в UNSAFE_componentWillMount() или, в случае с хуками, uselayouteffect.


По сути, процесс серверного рендеринга сводится к тому, чтобы получить initialState с бэкенда, прогнать его через renderToString(), забрать на выходе готовый initialState и HTML, и отдать на клиент.


В hh.ru походы из клиентского JS разрешены только в api gateway на питоне. Это нужно для безопасности и балансировки нагрузки. Питон уже ходит в нужные бэкенды за данными, подготавливает их и отдает браузеру. Node.js используем только для серверного рендеринга. Соответственно, после подготовки данных питону необходим дополнительный поход в node, ожидание результата и передача ответа клиенту.


Для начала нужно было выбрать сервер для работы с HTTP. Остановились на koa. Понравился современный синтаксис с await. Модульность — это легкие middleware, которые при необходимости ставятся отдельно или легко пишутся самостоятельно. Сам по себе сервер легкий и быстрый. Да и написан koa той же командой разработчиков, что пишут express, подкупает их опыт.


После того как научились раскатывать наш сервис, написали простейший код на KOA, который умел отдавать 200, и залили это на прод. Выглядело это так:


const Koa = require('koa');

const app = new Koa();

const SERVER_PORT = 9400;

app.use(async (ctx) => {
    ctx.body = 'Hello World';
});

app.listen(SERVER_PORT);

В hh.ru все сервисы живут в docker контейнерах. Перед первым релизом необходимо написать ansible плейбуки, с помощью которых сервис раскатывается в продакшен окружении и на тестовых стендах. У каждого разработчика и тестировщика свое тестовое окружение, максимально похожее на прод. На написание плейбуков мы потратили больше всего времени и сил. Так получилось из-за того, что делали это два фронтендера, и это первый сервис на ноде в hh.ru. Нам пришлось разбираться с тем, как переключать сервис в режим разработки, делать это параллельно с сервисом, для которого происходит рендеринг. Поставлять файлы в контейнер. Запускать голый сервер, чтобы докер контейнер стартовал, не дожидаясь сборки. Собирать и пересобирать сервер вместе с использующим его сервисом. Определить, сколько нам нужно оперативки.


В режиме разработки предусмотрели возможность автоматической пересборки и последующего рестарта сервиса при изменении файлов, входящих в итоговый билд. Ноде нужно перезапуститься, чтобы подгрузить исполняемый код. За изменениями и сборкой следит webpack. Webpack нужен для конвертации ESM в common CommonJS. Для рестарта взяли nodemon, который смотрит за собранными файлами.


Далее научили сервер маршрутизации. Для корректной балансировки необходимо знать, какие инстансы сервера живы. Чтобы это проверить, эксплуатационный heart beat раз в несколько секунд ходит на /status и ожидает получить 200 в ответ. В случае если сервер не отвечает более заданного в конфиге количества раз, он удаляется из балансировки. Это оказалось простой задачей, пара строк и готов роутинг:


export default async function(ctx, next) {
    if (routeMap[ctx.request.path]) {
        routeMap[ctx.request.path](ctx);
    } else {
        ctx.throw(NOT_FOUND, getStatusText(NOT_FOUND));
    }
    next();
}

И отвечаем 200 на нужном урле:


export default (ctx) => {
    ctx.status = 200;
    ctx.body = '200';
};

После этого сделали примитивный сервер, который отдавал state в <script> и готовый HTML.


Понадобилось контролировать, как работает сервер. Для этого нужно прикрутить логирование и мониторинг. Логи пишутся не в JSON, а чтобы поддержать формат логов остальных наших сервисов, преимущественно Java. По бенчмаркам был выбран log4js — он быстрый, легко настраивается и пишет в нужном нам формате. Общий формат логов необходим для упрощения поддержки мониторинга — не надо писать лишние регулярки для разбора логов. Помимо логов мы еще пишем ошибки в sentry. Код логеров приводить не буду, он очень простой, в основном, там настройки.


Потом необходимо было предусмотреть graceful shutdown — когда серверу становится плохо, или когда катится релиз, сервер не принимает больше входящих подключений, но выполняет все висящие на нем запросы. Для ноды есть множество готовых решений. Взяли http-graceful-shutdown, все, что нужно было сделать — это обернуть вызов gracefulShutdown(app.listen(SERVER_PORT))


На этом моменте получили production-ready решение. Чтобы проверить, как он работает, включили серверный рендеринг для 5% пользователей на одной странице. Посмотрели метрики, поняли, что ощутимо улучшили FMP для мобильных телефонов, для десктопов значение практически не изменилось. Начали тестировать производительность, выяснили, что один сервер держит ~20 RPS (джавистов очень развеселил этот факт). Выяснили причины, почему это так:


  • Одна из основных проблем оказалась в том, что собирали без NODE_ENV=production (выставляли нужный нам ENV для серверного билда). В этом случае реакт отдает не продакшен сборку, которая работает примерно на 30% медленнее.


  • Подняли версию ноды с 8 до 10, получили еще порядка 20-25% производительности.


  • То, что мы оставляли напоследок — запуск ноды на нескольких ядрах. Мы подозревали, что это очень сложно, но тут тоже все оказалось весьма прозаично. В ноде есть встроенный механизм — cluster. Он позволяет запустить необходимое количество независимых процессов, в том числе мастер-процесс, который раскидывает им задачи.



if (cluster.isMaster) {
    cluster.on('exit', (worker, exitCode) => {
        if (exitCode !== SUCCESS) {
            cluster.fork();
        }
    });
    for (let i = 0; i < serverConfig.cpuCores; i++) {
        cluster.fork();
    }
} else {
    runApp();
}

В этом коде запускается мастер-процесс, стартуют процессы по количеству выделенных под сервер CPU. Если один из чайлд процессов падает — код выхода не равен 0 (мы сами выключили сервер), мастер-процесс его перезапускает.
И производительность возрастает примерно на количество выделенных под сервер CPU.


Как я писал выше, больше всего времени потратили на написание изначальных плейбуков — порядка 3 недель. На написание всего SSR ушло еще порядка 2 недель, и еще около месяца мы потихоньку доводили его до ума. Все это делалось силами 2-х фронтов, без энтерпрайз опыта node js. Не бойтесь делать SSR, главное — не забудьте указать NODE_ENV=production, ничего сложного в этом нет. Пользователи и SEO скажут вам спасибо.

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


  1. androidovshchik
    28.03.2019 19:25

    Кажется, уже недостаточно просто оптимизировать сайт, чтобы весил меньше и быстрее работал. Теперь и сервер должен участвовать в работе js. В итоге что: пользователю с течением времени будет и этого мало, пожалуется, почему медленно грузится сайт(… Вообще, по-моему, будущее за такими проектами как Google Stadia, и пользователю будет достаточно грубо говоря одного экрана (притом еще желательно гибкого)


    1. EaGames
      29.03.2019 15:57

      Минус не от меня конечно, но причем тут вообще Google Stadia?
      Да и SSR делается больше для поисковиков нежеле для пользователей.


  1. cyber_ua
    28.03.2019 20:16
    +1

    Полезной информации в статье минимум, по сути описание как сделать hello world, коих много.
    Добавлю от себя вещи с которыми сталкивался когда делал SSR:
    — нужно встроить код реакта внутрь html
    пробовал взять шаблонизатор типа ejs и вставлять строку которую вернет renderToString внутрь, но это оказалось достаточно медленным при большой нагрузке, самый быстрый и простой вариант, что то типо

    ...
    <body>
    {replaceMe}
    </body>
    ...
    

    потом разрезаем это regexp на 2 части и храним в памяти и просто конкатим 2 с результатом renderToString
    — что бы получить хороший прирост лучше юзать stream рендеринг
    вариант выше работаеть и со стримами, работало примерно так
    github.com/arshtepe/koa-render-view/blob/master/src/factory/PageStreamFactory.js

    я юзал свой middleware для коа, но в итоге забросил это, когда сменил работу, а на новой уже мы юзаем www.gatsbyjs.org который оказался по удобнее велосипедостроения :)
    P.s у нас недавно был митап на эту тему как мы юзаем gatsbyjs кому интересно могу скинуть ссылку в личку (не даю тут что бы не сказали что реклама :) )


  1. hazratgs
    28.03.2019 21:36
    +1

    Вы конечно молодцы, но какую пользу представляет ваша статья? Она не раскрывает вопрос рендера на стороне сервера


    1. tapo4ki Автор
      29.03.2019 10:48

      Рассказываем о том где сами набили шишки. Весь рендеринг по сути сводится к `renderToString` про это 100500 раз писали.


      1. hazratgs
        29.03.2019 23:53

        Серьезно? Кинь ссылку туториал на русском где освещена эта тема подобно


  1. megahertz
    28.03.2019 21:56

    Зачем нужен запуск на нескольких ядрах, если все хозяйство в докере крутится? Логичнее было бы количество реплик увеличить.


    1. tapo4ki Автор
      29.03.2019 10:47

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


      1. megahertz
        29.03.2019 11:00
        +1

        Несколько процессов в одном контейнере — это антипаттерн. Системы оркестрации для того и придуманы (помимо прочего), чтобы горизонтально масштабироваться максимально просто, гибко и эффективно. Форкаться имеет смысл только если у вас не задействованы контейнеры.


  1. Razoomnick
    28.03.2019 22:14
    +1

    Когда читаю такие статьи, появляется чувство, что куда-то не туда пошла индустрия.
    Вот у истоков были HTTP, HTML и простая понятная модель: клиент отправил запрос, сервер его обработал и отдал HTML. Клиент его отрендерил.
    Потом понадобилось запускать снежинки на странице — окей, вот вам javascript. Понадобилось какие-то операции делать без перезагрузки страницы — получите XMLHttpRequest, а потом и библиотеки, которые сглаживают разницу в реализации в разных браузерах.
    А потом зачем-то понадобилось все возвести в абсолют. И в итоге на клиенте появились фреймворки, которые устаревают быстрее, чем появляются, шаблонизация, слои абстракций. Стало принято кросс-компилировать код на одном языке в код на интерпретируемом(!) языке. Слой с магией переехал еще дальше от процессора — теперь это даже не браузер, а фреймворк.
    И вот новая история. А давайте будем делать шаблонизацию на сервере, только не как раньше, а интерпретируемым языком, в который мы кросс-компилировали код на другом языке. И будем обрабатывать 20 запросов в секунду (40 с оптимизацией) вместо хотя бы пары тысяч. Но и на клиенте все оставим. А все ради того, чтобы показать пользователю 140 символов.
    P.S. Я знаю, что под реакт на js пишут. Я больше про общее положение дел. И да, я не знаю, как ситуацию исправить.


    1. JustDont
      28.03.2019 22:54
      +1

      А потом зачем-то понадобилось все возвести в абсолют.

      Так ну нет же. Просто «снежинки» стали сначала неизбежными (элементы разложить с правильными размерами — это тоже JS, особенно во времена, когда CSS объективно не мог разруливать комплексные случаи. Флексбокс не всегда с нами был), а потом и абсолютно неизбежными (из-за скорости разработки, да и «снежинки» всё так же никуда не деваются).

      Потом подъехал пакетный менеджер со всеми своими свойствами пакетного менеджера (возьмите только самое-пресамое нужное, и всё равно получите зависимостей на несколько мегабайт), потом пошла борьба за 1st meaningful render, и тому подобное.

      Хотите это всё выкинуть? Да пожалуйста, сайт времен web1.0 можно поднять за смешное количество времени (сильно быстрее, чем во времена web1.0). Только вот кому он будет нужен. А дальше — куда б вы не пошли, вас будет ждать либо «долго и дорого» (медленная разработка силами только тех людей, которые понимают, как без этого вашего реакта всё работает), либо же мегабайты js и борьба за первый значимый рендер. Ну и разрабатывая что-то достаточно сложное, вы неизбежно напишете свой собственный реакт или что-то подобное. С азартными играми и женщинами легкого поведения.

      ЗЫ: Заметьте, кстати, что сейчас вот человек пишет про SSR, а в комментах ему тут же пишут «фу, надо было <мою любимую PWA за которую я везде топлю> брать» — это вот как раз то же самое, как если б кто-то написал про разработку без фреймворков, а ему стали бы предлагать реакт ;)


      1. Razoomnick
        29.03.2019 02:16

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

        Я считаю, что веб мог развиваться в другом направлении. В ответ на запрос клиента сервер отдает HTML вполне функциональной страницы, которая уже содержит максимум информации, ведь большиинство страниц в вебе несут информацию, а не требуют действия от пользователя. Для действий спецификация HTML тоже содержит элементы и возможность отправить форму. А скрипты, которые загружаются на странице, постепенно улучшают её. Отправку формы могут заменить на ajax запрос и частичное обновление страницы и так далее.

        И в данном случае все равно, с помощью фреймворка это сделано или без помощи. Даже лучше, если с помощью, будет и переиспользование накопленного опыта, и, возможно «дешево и быстро». А такие проблемы, как борьба за первый значимый рендер или индексация поисковиками решаются сами собой.


        1. JustDont
          29.03.2019 03:33

          В ответ на запрос клиента сервер отдает HTML вполне функциональной страницы, которая уже содержит максимум информации, ведь большиинство страниц в вебе несут информацию, а не требуют действия от пользователя.

          С любым комплексным отображением такой подход, возведенный в абсолют, немедленно превращается в тыкву. Надо график нарисовать? Ой, а давайте мы у клиента будем запрашивать размеры вьюпорта и прочие всякие dpi, а потом рисовать на сервере нечто под эти параметры (если кому-то стало смешно — то я такое монструозие в реальности видел, и да, работало оно очень грустно).

          Вы никуда не денетесь от того, что существует масса моментов, когда легче рендерить, непосредственно располагая информацией о том, куда и как мы собственно рендерим.

          И да, server-side rendering — это вот именно то, что вы и описали. Отдали HTML, подцепили к нему потом скрипты. То, что это «эмуляция клиента на сервере» (на самом деле не совсем) — избавляет от необходимости дважды писать код страницы, один раз для чисто серверной выдачи, второй раз для всяких «частичных обновлений» и т.п. И синхронизировать между собой.


          1. Razoomnick
            29.03.2019 05:40

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

            Но в текущей ситуации я вижу две проблемы. Хорошим тоном для разработчика всегда было немного разбираться в том, что происходит на уровень ниже, чем тот уровень, с которым работаешь. Но слоев абстракции стало так много, что этот второй сверху уровень уже даже не javascript-движок, а фреймворки. В итоге получаем такие вопросы на стековерфлоу: Calculating sum of repeated elements in AngularJS ng-repeat. То есть, вопрос даже не в том, как посчитать сумму на javascript, а в том, как это сделать в ангуляре.

            И вторая проблема, наверное, как следствие первой. Гмэил показывает список писем за 7 секунд, и это с ресурсами гугла. То ли на оптимизацию забили, то ли не осилили. Да, скорость разработки важнее скорости работы, но это стало нормой даже для успешных продуктов, а рендеринг на клиенте поощряет такой подход. И мне кажется, что перенос рендеринга на сервер так, как это описано в статье — это такой последний отчаянный шаг ускорить приложение не меняя концепцию.


            1. JustDont
              29.03.2019 09:12

              То ли на оптимизацию забили, то ли не осилили.

              С гуглом — чисто первое.


    1. DarthVictor
      29.03.2019 12:00

      Понадобилось какие-то операции делать без перезагрузки страницы — получите XMLHttpRequest, а потом и библиотеки, которые сглаживают разницу в реализации в разных браузерах.

      Примерно в этот момент появилась необходимость дублировать серверный шаблонизатор клиентским. Причем тестировать нужно оба. Причем у них сильно разный синтаксис и это создает лишнюю ментальную нагрузку на разработчика. Потом появилась необходимость поддержки API для мобильных устройств и отдача JS/XML вместо отрендеренного HTML. И код приходилось писать уже местами не дважды, а трижды.
      Возможно так будет понятнее откуда
      потом зачем-то понадобилось все возвести в абсолют

      и выделить в системе слои по функциональному принципу, а не по принципу «надо пользователю отдать js-файлик, значит будем писать в js-файлике».


  1. Klenov_s
    28.03.2019 22:33
    +2

    Какая у людей интересная жизнь!!!
    Простой html отдавать не круто, давайте накрутим кучу js. Js на клиенте тормозит, давайте будем выполнять js на сервере, а клиенту отдавать html. )))
    Какой следующий шаг?


    1. 4tlen
      29.03.2019 16:04

      Кэшировать результаты SSR конечно же.


  1. xState_level80
    28.03.2019 22:57

    Чем вам не подошёл f/w Next.js с готовыми решениями?

    Не могли бы поделиться вашим бойлерплейтом? Находил на гитхабе, но там староваты с устаревшими модулями и костылями


    1. Peregrinus
      29.03.2019 10:08

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


  1. hazratgs
    28.03.2019 23:32

    Посмотрите этот, на нем несколько проектов написал github.com/hazratgs/react-app-private


  1. kashey
    29.03.2019 09:55

    PHP — php тормозит, давайте ему APC, eAcceleator и opCode под капот, и заодно давайте все повторяемые блоки в memcached засунем.
    JavaScript — какой такой APC?
    React — какой такой memcached?


    1. zede
      29.03.2019 18:09
      +1

      А зачем JS-у акселераторы, если эти решения нужны только из-за специфичности PHP. Как с memcahed связан с React?


      1. kashey
        30.03.2019 13:37

        PHP — всеми третируемый язык, который не прячет свои недостатки, и позволяет их решать дедовскими методами.
        JS — заместо байткода нам выдали wasm. Спасибо конечно, но современную проблему JavaScripта, в виде бандла на 20 мегабайт, это не решает.
        React — особенно хорош на сервер сайде. На клиенте он то хорош, но для серверного окружения, где надо что-то рендерить для 100 клиентов одновременно… вот как раз кеширования и не хватает. А оно примерно не возможно с текущей моделью работы Реакта. Точнее — очень даже возможно(rapscallion, hypernova), но почему-то «настоящий реакт» этого никаким образом сделать не помогает.


        1. hazratgs
          30.03.2019 14:24

          Для react server side render мы настроили CloudFront, проблем с производительностью не наблюдаем


          1. kashey
            31.03.2019 01:19

            Да, но можно еще быстрее, а, главное, сильно дешевле.


  1. Peregrinus
    29.03.2019 10:01
    +1

    Тему совсе не раскрыли, поэтому у меня несколько вопросов :)

    1) Зачем вообще использовать node.js фреймворки? Там серверного кода то один модуль, который просто в ответ на запрос отдаёт renderToString со страничкой.
    2) С многопоточностью тоже непонятно. У вас же докер, можно настроить репликацию.
    3) LazyLoad использовали или у вас всё в одном модуле?
    4) Пробовали использовать renderToNodeStream?
    5) Как добавляете мета-теги на серверной стороне (если добавляете конечно)? Helmet или вручную.


    1. tapo4ki Автор
      29.03.2019 10:59

      1) не мало кода вокруг, перформанс нода против коа почти не страдает
      2) мы так и сделали, не что при этом не мешает спавнить потоки, странная история пладить тонны инстансов с 1 поток
      3) использовали
      4) нет, клиенту отвечает пайтон
      5) обвязка пока еще старая, в ней все добавляется


  1. Eirik
    29.03.2019 10:55

    У меня есть вопрос. Что нас должно впечатлить в этой статье? Серверный рендеринг очень древняя технология. PHP, C#, Pyton, Java, Node.js. Все давно используют серверный рендеринг. В чем технический шаг вперед при изпользовании React? Как по мне, дополнительная реализация серверного рендеринга в для фронтенд библитеки это признак тупика в ее развитии.


    1. Peregrinus
      29.03.2019 13:39

      Прелесть изоморфного реакта в том, что один и тот же код работает на клиенте и сервере и можно безболезненно SPA на реакте сделать доступной для поисковых роботов.


      1. noodles
        31.03.2019 00:07

        Зачем SPA делать доступными для поисковых роботов? Это же application… приложение, т.е. то что на других платформах скачивается и устанавливается.
        Для seo и роботов делать и оптимизировать нужно страницу входа в это приложение и/или лендинг — который рекламирует это приложение и рассказывает про него.


  1. bespechnost
    29.03.2019 16:56

    А зачем промежуточное звено, которое ходит за данными, отправляет их на отрисовку и отдает клиенту? Мне кажется было бы проще сразу писать на node.js фронт сервер с SSR, который умеет ходить за данными в API.


    1. tapo4ki Автор
      29.03.2019 16:58

      Было бы, никто не спорит. Проекту 18 лет переписывать все что написано на питоне на ноду займет кучу времени. Пока живем так.


      1. bespechnost
        29.03.2019 17:05

        То есть у вас SSR не всех страниц, а фрагментов на react?


  1. tapo4ki Автор
    29.03.2019 17:25

    Все страницы на реакте с SSR-ом. Но обвзяка не реактовая (хедер, футер)