Многие слышали название GraalVM, но опробовать эту технологию в продакшене пока довелось не всем. Для Однокласснииков эта технология уже стала «священным Граалем», меняющим фронтенд.
В этой статье я хочу рассказать о том, как нам удалось подружить Java и JavaScript, и начать миграцию в огромной системе с большим количеством legacy-кода, а так же как на этом пути помогает GraalVM.
Во время написания статьи оказалось, что весь объём материала не влезает в традиционный для ХАБРа размер и если выложить публикацию целиком, то на её прочтение уйдет несколько часов. Поэтому мы решили разделить статью на 2 части.
Из первой части вы узнаете об истории фронтенда в Одноклассниках и познакомитесь с его историческими особенностями, пройдете путь поиска решения проблем, которые накопились у нас за 11 лет существования проекта, а в самом конце окунетесь в технические особенности серверной реализации принятого нами решения.
Предыстория
Первая версия Одноклассников появилась 13 лет назад, в 2006 году. Сайт был сделан на .NET, никакого JavaScript тогда не существовало, всё было на серверном рендеринге.
Через год у Одноклассников было свыше одного миллиона пользователей. В 2007 году это были невероятные цифры, и сайт, не выдержав нагрузки, начал падать. Разработчики решили проблему с помощью проекта One.lv, созданного латвийской компанией Forticom, у которой основные компетенции были в Java-разработке. Поэтому Одноклассники решено было переписать с .NET на Java.
Со временем проект развивался, и возникла необходимость в новых клиентских решениях. Например, чтобы при навигации сайт обновлялся не полностью, а только определенные части. Или чтобы при сабмите обновлялась только форма, а не вся страница. При этом сайт работал только на Java.
Для этого придумали систему, в которой сайт размечался именованными блоками. При переходе по навигации производился запрос на сервер, который принимал решение, о том что нужно поменять по этой ссылке, и клиенту отдавались нужные куски. Клиентский движок просто заменял нужные части, и так реализовывалась клиентская динамика. Очень удобно, потому что вся бизнес-логика находится на сервере. Небольшой движок позволил компании по-прежнему писать Java-код, управляющий клиентом.
Конечно, без минимального JavaScript было не обойтись. Чтобы сделать pop-up, нужны манипуляции: например, по наведению курсора на div вешался display:block или он скрывался с помощью display:none.
Но при этом содержимое поп-апа запрашивалось с сервера, вся бизнес-логика находилась там и была на Java.
2018
Спустя 12 лет Одноклассники превратились в гигантский сервис с более 70 миллионами пользователей. У нас больше 7 000 машин в 4 дата-центрах, и только на фронтенд OK.RU приходит 600 тысяч запросов в секунду.
Фронт-сервер Одноклассников продолжает работать на Java, а кодовая база одних только фронтов превышает два миллиона строк.
Технологии, реализуемые на клиентской стороне, тоже не стояли на месте: появилось много решений с использованием разных библиотек: GWT, jQuery, DotJs, RequireJS и многих других.
В тот период не были распространены стандарты вроде React, Angular и Vue, каждый разработчик пытался найти оптимальное решение, используя все доступные средства.
Стало понятно, что жить с этим очень трудно, потому что накопилось огромное количество проблем:
- Много старых библиотек
- Нет единого фреймворка
- Нет изоморфности (поскольку бэкенд на Java, клиент на JS)
- Нет единого структурированного приложения на клиенте
- Плохая отзывчивость
- Недостаточный инструментарий
- Высокий порог входа
В мире шел уже 2018-й год и необходимо было меняться.
Применив всю силу технической мысли, мы продумали и сформулировали четыре основных требования к решению проблем:
- У Одноклассников должен быть изоморфный код для UI. Потому что невозможно постоянно писать сервер на Java, а потом, если необходимо добавить какую-то динамику, воспроизводить то же самое на клиенте.
- Необходим плавный переход. Потому что быстро сделать вторую версию Одноклассников и переключиться невозможно
- Обязательно нужен серверный рендеринг (Об этом ниже)
- Новое решение, работая на том же количестве железа, не должно ухудшать производительность и отказоустойчивость при наших нагрузках
Почему серверный рендеринг?
У Одноклассников много пользователей, которые живут далеко от Москвы и у них не всегда хороший интернет.
Серверный рендеринг поможет таким пользователям быстрее получать контент. Пока картинки будут грузиться, они смогут начать что-то читать:
Мы провели ряд экспериментов, пытаясь понять что произойдёт, если какие-то данные (например, ленту) доставлять уже на клиенте, с ожиданием. В результате оказалось, что это негативно сказывалось на пользовательской активности.
Как работает сервер сейчас
Браузер делает запрос на сайт ОК, и попадает на приложение OK-WEB, которое целиком написано на Java. Приложение идет за данными в API. Между WEB и API реализован быстрый бинарный транспорт one-nio, разработанный в Одноклассниках. Запросы осуществляются менее чем за одну миллисекунду. Можете посмотреть, что это такое отдельно. One-nio позволяет дешево делать много запросов, не беспокоясь о задержках.
API достает данные, отдает вебу. Веб генерирует HTML-страницы движком на Java и отдает браузеру.
Все это занимает сейчас меньше 200 мс.
Поиск решения
Сперва была выработана концепция миграции, основанная на виджетах.
Приложения будут доставляться на сайт маленькими кусочками. Внутри они будут написаны на новом стеке. А для остального сайта это будет просто DOM-элемент с каким-то кастомным поведением.
Это будет похоже на тег <video>: кастомный DOM-элемент с атрибутами, методами и событиями. В результате снаружи находится DOM API, а внутри реализуется функциональность виджета на новом стеке.
Какой стек выбрать?
Теперь концепцию необходимо было реализовать, стали перебирать варианты.
Kotlin
Первый прототип сделали на Kotlin. Идея заключалась в следующем: для новых компонентов писать логику на Kotlin, а разметку компонента описывать в XML. На сервере все можно запускать в JVM, используя существующий шаблонизатор, а для клиента транспайлить в JavaScript.
Помимо привнесения нового языка, с высоким порогом входа, у Kotlin оказался не достаточно развитый инструментарий работы с JavaScript, и пришлось бы многое дорабатывать самостоятельно.
Поэтому, к сожалению, от этой концепции пришлось отказаться.
Node.js
Другой вариант — поставить Node.js или другой рантайм, например, Dart. Но что получится?
Использовать Node.js можно двумя способами.
Первый способ заключается в том, что бы делегировать отрисовку компонента серверу на Node.js запущенному на том же сервере, где и Java приложение. Таким образом мы сохраняем приложение на Java и просто в процессе отрисовки HTML делаем вызов к сервису локально запущенному на Node.js.
Однако, с этим подходом есть несколько проблем:
- Удалённый вызов Node.js предполагает сериализацию/десериализацию входных данных. Эти данные могут быть весьма объёмными, например в случае, когда новый компонент на JS является обёрткой вокруг старого компонента, реализованного на Java.
- Удалённый вызов, даже на локальной машине, является далеко не бесплатным, а также вносит дополнительную задержку. Если на странице будут десятки или сотни таких компонент, пусть даже очень простых, мы существенно увеличим накладные расходы и задержку на обработку запроса пользователя.
- Кроме того существенно усложняется эксплуатации подобной системы, так как вместо одного процесса нам надо было бы иметь процесс на Java и несколько процессов на Node.js. Соотвественно все операции становятся намного сложнее, например: развёртывание, сбор операционных показателей, анализ логов, мониторинг ошибок и т.д.
Второй способ использования Node.js заключается в том, чтобы поставить его перед веб сервером на Java и использовать для пост-обработки HTML. Другими словами, это прокси, который разбирает HTML, находит компоненты на JS, отрисовывает их и возвращает пользователю готовый HTML. Вариант интересный, вроде бы универсальный и вполне рабочий. Недостатки такого подхода заключаются в том, что он требует основательного изменения всей инфраструктуры, существенно увеличивает накладные расходы и несёт в себе серьёзные риски – любой запрос должен проходить через Node.js, то есть мы начинам полностью от него зависеть. Это выглядит слишком дорогим решением для того, что бы решить нашу задачу.
Получается, Node.js нельзя использовать по следующим причинам:
- Сериализация/десериализация — это дополнительная нагрузка и задержки
- Node.js это еще один компонент в огромной распределенной системе Одноклассников
У нас уже работает много специалистов, знающих, как «готовить» Java, а теперь придется нанять штат сотрудников, которые будут эксплуатировать Node.js и в дополнение к существующей создать ещё одну инфраструктуру.
JavaScript в JVM
А что если попробовать запустить JavaScript внутри JVM? Получится, что код на Java и JavaScript будет исполняться в одном процессе и взаимодействовать с минимумом накладных расходов.
Это позволит плавно заменять Java-куски на JavaScript внутри текущего WEB’а.
JS-компоненты будут получать данные из Java и формировать HTML. Они смогут изоморфно работать как на клиенте, так и на сервере.
Но как запустить JS в JVM?
Можно использовать V8 по примеру Cloudflare. Но это — бинарный код, сторонний по отношению к Java. Поэтому в JVM невозможно будет отловить ошибки внутри V8. Любой креш V8 приведет к разрушению всего процесса. В результате использование V8 повысит риски эксплуатации, а этого допускать нельзя.
Для JVM существует несколько JS-рантаймов: два «носорога», Nashorn и Rhino (один от Oracle, другой от Mozilla) и свежий GraalVM.
Преимущества JS-рантаймов для JVM:
- Все работает в JVM, а у нас в этом большая экспертиза
- Бесплатное взаимодействие Java и JavaScript
- Безопасный рантайм
- Компилятор на Java в случае GraalVM
Дальше достаточно было сравнить по скорости эти рантаймы. Оказалось, что GraalVM всех опережает с большим отрывом:
Что такое GraalVM?
GraalVM это рантайм высокой производительности, который поддерживает программы на разных языках. В нем есть фреймворк для написания компиляторов языков для JVM. Благодаря этому поддерживается выполнение программ на Java, Kotlin, JS, Python и других языках внутри одной JVM.
Подробнее о возможностях GraalVM можно узнать из доклада Олега Шелаева, который работает в Oracle Labs, где разрабатывают GraalVM. Рекомендовано к посмотру бэкендерам и фронтендерам.
GraalVM позволяет нам запустить JS для рендеринга UI на сервере. В качестве библиотеки мы используем React.
Преимущества такой связки:
- Не добавляется новых языков: по-прежнему Java и JavaScript
- Большое сообщество: все знают React
- Низкий порог входа
- Легко искать коллег в команду
- Эксплуатация не усложнилась
Запуск React в GraalVM
Внутри GraalVM можно создать Context — изолированный контейнер, в котором будет выполняться программа на гостевом языке. В нашем случае гостевым языком является JS:
Context context = Context.create("js");
// получаем global данного контекста
Value js = context.getBindings("js");
Для взаимодействия с контекстом используется его объект global:
// можно записать в global
js.putMember("serverProxy", serverProxy);
// можно читать из global
Value app = js.getMember("app");
В контекст можно загрузить код модуля:
// получаем метод загрузки кода
Value load = js.getMember("load");
// загружаем модуль в контекст
load.execute(pathToModule);
Или «за-eval-ить» там любой код:
context.eval("js", someCode);
Серверный рендеринг JS: концепт
Создаем в JVM контекст JavaScript и загружаем в него код модуля приложения на React. Прокидываем из Java в JS нужные функции и методы. Затем из этого контекста извлекаем ссылку на JS функцию render() данного модуля, чтобы потом вызывать её из Java.
Когда пользователь запрашивает страницу, запускается серверный шаблонизатор, он вызывает функцию render() нужных компонент с необходимыми данными, получает из них HTML-код и отдает его вместе с HTML всей страницы пользователю.
Серверный рендеринг JS: реализация
В серверном шаблонизаторе Одноклассников верстка написана в виде HTML-разметки. Для того чтобы отличить приложения на JS от обычной разметки мы используем кастомные теги.
Когда шаблонизатор встречает кастомный тег, то создается задача на рендеринг соответствующего модуля. Она отправляется в пул потоков, каждый из которых имеет свой JS-контекст, исполняется на свободном потоке, рендерит в нём компонент и отдает его клиенту.
Зачем нужен пул контекстов
Рендеринг компонента происходит синхронно в одном потоке. На это время JS-контекст рендеринга занят. Поэтому, создав несколько независимых контекстов, можно распараллелить рендеринг компонентов, используя возможности многопоточности Java.
Java-функции получения данных передаются по ссылке в каждый контекст. В результате получается классный многопоточный JavaScript внутри одного процесса.
Как на этой концепции построена реализация клиентской части нового фронтенда, мы расскажем в следующей статье.
To be continued
Автор: Олег Коровин