Server-side rendering

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

В этот раз расскажу про то, как у нас устроен серверный рендеринг: это одна из самых интересных частей архитектуры. Напомню, что серверная часть приложения у нас написана на 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.