Server-side rendering

Alexander Solovyov
Jul 18, 2017 · 4 min read

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

В этот раз расскажу про то, как у нас устроен серверный рендеринг: это одна из самых интересных частей архитектуры. Напомню, что серверная часть приложения у нас написана на Clojure, а в браузер мы отдаём SPA (одностраничное приложение) на ClojureScript, которое внутри использует React.

Глубокая древность

Так как у нас весь фронтенд на Реакте, одним из обещаний которого является рендеринг приложения без DOM’а — а именно в Node.js — то первая идея была, конечно, рендерить всё нодой. Но, конечно, нодой рендерить это как-то low-tech и всё такое, ведь в Java 8 появился JS-рантайм под названием Nashorn. Ну очень быстрый по обещаниям, в отличии от старого Rhino (тоже движка JS на Java). Исполнение простенького джаваскрипта дало клёвые результаты — даже быстрее, чем на Node.js.

“Супер” — подумал я и мы начали строить само приложение. Когда оно стало более-менее рабочим, пришло время проверить, как там Nashorn исполнит 2–3 тысячи строк вместо предыдущей игрушки. Оказалось, что никак — 8 гигабайт выделенной памяти ему еле хватало загрузить джаваскриптовый файл и через 20–30 секунд пыхтения моим ноутбуком сказать “вот это выражение в вашем джаваскрипте я не могу”. После исправления пары багов стало очевидно, что никаких сил не хватит так разрабатывать и наступило время Node.js.

Обычная древность

Идея была простая: упаковываем готовый к употреблению JS-файл в jar-файл вместе с серверным приложением, которое при запуске его пишет на диск и натравливает на него Node.js. С этим запущенным Node.js общение идёт по HTTP — туда запрос пользователя, обратно HTML.

Из-за того, что Node.js однопоточный, запускать один процесс бессмысленно — и у нашего бекенда отрос менеджер пула запущенных процессов ноды.

А еще Node.js очень асинхронный, а мы запрос данных на сервер делаем в момент инициализации компонента. Ну, скажем, надо отрисовать меню — и компонент, который этим занимается, шлёт запрос “дайте мне меню”. Для приложения в браузере это нормальная ситуация: покрутится спиннер, приедут данные и всё отрисуется. А вот на сервере только спиннер отрисовался — и сразу результат уехал клиенту. В результате мы получаем серверный рендеринг спиннера, довольно бесполезную вещь. Данные, конечно, потом придут, но их уже никто и не ждёт…

Поэтому сначала происходит рендеринг, чтоб запустить процесс сбора данных, потом мы ждём, пока завершатся все запросы в API, а потом рендерим еще раз, уже с данными. Самый оптимистичный результат был 300 мс на рендеринг одной из простых страниц.

А еще, конечно же, у Node.js текла память. Профайлеры мне не помогли найти проблемы и я применил критически важный паттерн, подсмотренный в uwsgi: “рестартовать после тысячи запросов”. :)

“Чего не вытерпишь ради искусства” — подумал я и отправился в теплую (по меркам киевского ноября) Филадельфию на Clojure/Conj 2015.

Просветление

Отправился я, конечно, не за просветлением, но просто за инфой по кложе. :) Нооооо! Чрезвычайно удачно я посетил выступление Аллена Ронера (хз как правильно — чувак, кстати, очень крутой, основал CircleCI) “Optimizing ClojureScript Apps For Speed”.

Идея такая: в Clojure 1.7 (вышла в июне 2015) добавили возможность в одном файле (.cljc) писать код и для Clojure (.clj), и для ClojureScript (.cljs). Скажем, вот примерно так выглядит компонент Реакта, который рисует картинку:

(defc Image [image-source]
[:img {:src image-source}]]

Тут defc — это макрос, который превращает вот этот кусочек кода в компонент на Реакте, вот в такое (примерно, частично псевдокод):

(def Image (js/React.createComponent {
:render (fn []
(js/React.createElement "img"
{:src (get js/this.args :image-source)}))}))

Но это он делает в случае компиляции этого кода в JavaScript. А если другой макрос в момент исполнения на Clojure будет тот же кусочек кода преобразовывать в функцию, которая вернет строку <img src="...">?

(defn Image [image-source]
(format "<img src=\"%s\">" image-source))

В январе 2016 я написал первую версию такого рендеринга для Rum’а, которую потом Никита довёл до ума. Получился серверный рендинг, только без рантайма Реакта (довольно большого, надо сказать), чисто на JVM, с синхронным (ура!) чтением по сети, с многопоточностью: кароче, офигеть.

Плоды

Синхронное чтение по сети круто тем, что пока не скачаются данные из компонента выше по иерархии, компоненты ниже по иерархии (рендеринг которых от этих данных зависит) сидят терпят — а когда скачаются, начинают качать свои данные.

А еще оказалось, что не обязательно по HTTP ходить, можно просто вызывать приложение, как функцию от хттп-запроса (Ring в Clojure явно вдохновлён WSGI’аем питоновским). Поэтому xhr выглядит как-то так для ClojureScript:

(defn xhr [params callback]
(let [req (js/XMLHttpRequest.)]
(set-params req params)
(set-callback req callback)))

И вот так для Clojure:

(defn xhr [params callback]
(callback (app (params->http-request params)))

Очевидно, настоящие имплементации чуть подлиннее, заголовки запроса всякие установить, распарсить JSON и т.д. С JSON’ом, кстати, тоже хорошая история: сразу после BF 2016 я рассказывал и показывал, как работает у нас серверный рендеринг и при этом в MiniProfiler’е увидел, что сериализация данных при серверном рендеринге занимает прилично времени. Очевидно, что и десериализация тоже не бесплатно.

54 строки изменений позже и мы начали передавать несереализованные данные для серверного рендеринга: и по CPU быстрее, и памяти в 3 раза (грубо: минус строка сериализации и минус новые объекты) меньше начало есть. Из приятного — медиана отрисовки HTML’я на сервере упала со 110 до 80 мс. :)

Ещё из интересного мы делаем хитрый ход: всё, что требует пользователя (в том числе определение, залогинен ли он) происходит только на клиенте. Это компромисс — на клиенте видно, что информация о пользователе появляется не сразу, но благодаря ему мы можем закэшировать HTML для всех пользователей одновременно. Это урок, который хорошо запомнился после BF 2015, где поверхностно приложение на Django так и работало, а внутри был ужас и кошмар.

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

И жили они долго и счастливо. The End.

engiKasta

modnaKasta engineering blog

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store