Мы продолжаем рассказ о том, как внутри Одноклассников с помощью GraalVM нам удалось подружить Java и JavaScript и начать миграцию в огромной системе с большим количеством legacy-кода.
Во второй части статьи мы подробно расскажем о запуске, сборке и интеграции приложений на новом стеке, погрузимся в специфику их работы как на клиенте, так и на сервере, а так же обсудим, возникшие на нашем пути, трудности и опишем решения, помогающие их преодолеть.
Если вы не читали первую часть, то очень рекомендую это сделать. Из неё вы узнаете об истории фронтенда в Одноклассниках и познакомитесь с его историческими особенностями, пройдете путь поиска решения проблем, которые накопились у нас за 13 лет существования проекта, а в самом конце окунетесь в технические особенности серверной реализации принятого нами решения.
Конфигурация UI-фреймворка
Для написания UI-кода мы выбрали самые современные инструменты: React вместе с MobX, CSS Modules, ESLint, TypeScript, Lerna. Всё это собирается с помощью Webpack.
Архитектура приложений
Как было написано в предыдущей части данной статьи, чтобы реализовать постепенную миграцию, мы на сайте будем вставлять новые компоненты в DOM-элементах с кастомными именами, которые будут внутри работать на новом UI стеке, при этом для остального сайта выглядеть, как DOM-элемент со своим API. Содержимое этих элементов может быть отрендерено на сервере.
Что получается? Внутри классное, модное, современное MVC-приложение, работающее на React и предоставляющее наружу стандартный DOM API: атрибуты, методы на этом DOM-элементе, и события.
Чтобы запускать такие компоненты мы разработали специальный механизм. Что он делает? Во-первых, инициализирует приложение по его описанию. Во-вторых, связывает компонент с конкретным DOM-узлом, в котором он запускается. Также есть два движка (для клиента и для сервера), которые умеют эти компоненты находить и рендерить.
Зачем это нужно? Дело в том, что, когда весь сайт делается на React, то обычно компонент сайта рендерится в корневой элемент страницы, и этому компоненту не важно, что находится вовне, а интересно только то, что находится внутри.
В нашем случае все сложнее: ряду приложений нужна возможность сказать нашей странице на сайте «я есть, и во мне что-то меняется». Например, календарику нужно бросать событие о том, что пользователь нажал на кнопку, и дата поменялась, либо снаружи нужна возможность, чтобы внутри календарика можно было сменить дату. Для этого движок приложения реализует фасады в базовом функционале приложения.
При доставке компонента на клиент необходимо, чтобы движок старого сайта умел запускать этот компонент. Для этого во время билда собирается информация необходимая для его запуска.
{
"events-calendar": {
"bundleName": "events-calendar",
"js": "events-calendar-h4h5m.js",
"css": "events-calendar-h4h5m.css"
}
}
В атрибуты тега компонента добавляются специальные маркеры, которые говорят, что это приложение нового типа, его код можно взять из определенного JS файла. При этом у него есть собственные атрибуты, которые нужны для инициализации этого компонента: из них формируется начальное состояние компонента в store.
<events-calendar data-module="react-loader"
data-bundle="events-calendar.js"
date=".."
marks="[{..}]"
…
/>
Для регидрации используется не слепок стейта приложения, а именно атрибуты, что позволяет экономить на трафике. Они приходят в нормализованном виде, и, как правило, меньше того store, который создается приложением. При этом время на воссоздание store из атрибутов на клиенте мало, поэтому им можно обычно пренебречь.
Например, для календаря в атрибутах всего лишь выделенная дата, а в store уже матрица с полной информацией за месяц. Очевидно, что ее бессмысленно передавать с сервера.
Как запускать код?
Концепция тестировалась на простых функциях, которые либо отдают строчку для сервера, либо пишут innerHTML для клиента. Но в реальном коде есть модули и TypeScript.
Для клиента существуют стандартные решения, например, собирать код с помощью Webpack, который сам все перемалывает и отдает на клиент в виде пачки бандлов. А что делать для сервера при использовании GraalVM?
Рассмотрим два варианта. Первый — транспайлить TypeScript в JavaScript, как делают для Node.js. Этот вариант, к сожалению, не работает в нашей конфигурации, когда JavaScript гостевой язык в GraalVM. В этом случае в JavaScript нет ни модульной системы, ни даже асинхронности. Потому что модульность и работу с асинхронностью предоставляет конкретный рантайм: NodeJS либо браузер. А в нашем случае на сервере есть JavaScript, который умеет только синхронно выполнять код.
Второй вариант — можно просто запустить на сервере код из тех же файлов, что были собраны для клиента. И этот вариант работает. Но есть проблема, что на сервере нужны другие реализации для ряда методов. Например, на сервере для отрисовки компонента будет вызываться функция renderToString(), а на клиенте — ReactDOM.render(). Или другой пример из предыдущей статьи: для получения текстов и настроек на сервере будет вызываться функция, которую предоставляет Java, а на клиенте это будет реализация на JS.
В качестве решения этой задачи можно использовать алиасы из Webpack. Они позволяют создать две реализации нужного нам класса: для клиента и сервера. Затем в конфигах сборки для клиента и сервера указать соответствующую реализацию.
Но два конфига — это две сборки. Каждый раз собирать все отдельно для сервера и для клиента долго и сложно в поддержке.
Нужно придумать такую конфигурацию, чтобы все собиралось за один раз.
Конфигурация webpack для запуска JS на сервере и клиенте
Чтобы найти решение этой задачи, посмотрим из каких частей состоит проект:
Во-первых, в проекте есть сторонний рантайм (vendors), одинаковый для клиента и для сервера. Он почти никогда не меняется. Рантайм можно отдать пользователю, и он закешируется на клиенте пока мы не обновим версию сторонней библиотеки.
Во-вторых, есть наш рантайм (core), который обеспечивает запуск приложения. В нем есть методы с разной реализацией для клиента и сервера. Например, получение текстов локализации, настроек и так далее. Этот рантайм также меняется нечасто.
В-третьих, есть код компонент. Он одинаков для клиента и для сервера, что позволяет отлаживать код приложения в браузере, вообще не запуская сервер. Если на клиенте что-то пошло не так, можно посмотреть ошибки в консоли браузера, довести все до ума и быть уверенным, что при запуске на сервере не будет ошибок.
Итого получаются три части, которые необходимо собирать. Мы хотим:
- Сконфигурировать отдельно сборку каждой из частей.
- Проставить зависимости между ними, чтобы в каждую часть не попадало то, что есть в другой.
- Собрать всё за один проход.
Как описать отдельно части, из которых будет состоять сборка? В webpack есть мультиконфигурация: вы просто отдаете массив экспортов модулей, входящих в каждую часть.
module.exports = [{
entry: './vendors.js',
}, {
entry: './core.js'
}, {
entry: './app.js'
}];
Все бы хорошо, но в каждой из этих частей будет дублироваться код тех модулей, от которых эта часть зависит:
К счастью, в базовом наборе плагинов webpack есть DllPlugin, который позволяет для каждой собираемой части получить список входящих в него модулей. Например, для vendor можно узнать, какие конкретно модули входят в эту часть.
При сборке другой части, например, core-библиотек, можно сказать, что они зависят от части vendor.
Тогда во время сборки webpack, DllPlugin будет видеть зависимость core от какой-то библиотеки, которая уже есть в vendor, и не будет добавлять ее внутрь core, а просто проставит на нее ссылку.
В итоге три куска собираются за один раз и зависят друг от друга. При загрузке первого приложения на клиент рантайм и core-библиотеки сохранятся в кеш браузера. А поскольку Одноклассники — сайт, вкладку с которым пользователь может открыть «навсегда», вытеснение будет происходить довольно редко. В большинстве случаев при релизах новых версий сайта будет обновляться лишь код приложения.
Доставка ресурсов
Рассмотрим проблему на примере работы с локализованными текстами, которые хранятся в отдельной базе.
Если раньше где-то на сервере нужен был текст в компоненте, можно было вызвать функцию получения текста.
const pkg = l10n('smiles');
<div>
Текст: { pkg.getText('title') }
</div>
Получение текста на сервере не представляет сложности, потому что серверное приложение может сделать быстрый запрос в базу или даже закэшировать все тексты в памяти.
Как получить тексты в компонентах на реакте, которые рендерятся на сервере в GraalVM?
Как было расмотрено в первой части статьи, в контекст JS можно в объект global добавлять методы, к которым хочется дать доступ из JavaScript. Было решено сделать класс со всеми методами, доступными для JavaScript.
public class ServerMethods {
…
/**
* Получаем текст в виде строки
*/
public String getText(String pkg, String key) {
…
}
…
}
Затем экземпляр этого класса положить в global контекста JavaScript:
// добавляем объект с методами Java в поле контекста
js.putMember("serverMethods", serverMethods);
В итоге из JavaScript в серверной реализации будем просто вызывать функцию:
function getText(pkg: string, key: string): string {
return global.serverMethods.getText(pkg, key);
}
Фактически это будет вызовом функции в Java, которая вернет запрашиваемый текст. Прямое синхронное взаимодействие и никаких HTTP-вызовов.
На клиенте, к сожалению, очень долго ходить по HTTP и получать тексты на каждый вызов функции вставки текста в компонентах. Можно предварительно скачать все тексты на клиент, но одни только тексты весят десятки мегабайт, а еще существуют и другие типы ресурсов.
Пользователь устанет ждать, пока у него перед стартом приложения все скачается. Поэтому такой способ не подходит.
Хотелось бы получать только те тексты, которые нужны в конкретном приложении. У нас тексты разбиты на пакеты. Поэтому можно собрать пакеты, нужные для приложения, и скачать их вместе с бандлом. Когда приложение запустится, все тексты уже будут в клиентском кэше.
Как узнать, какие тексты нужны приложению?
Мы ввели соглашение, что пакеты текстов в коде получаются вызовом функции l10n(), в которую передается название пакета ТОЛЬКО в виде строкового литерала:
const pkg = l10n('smiles');
<div>
{ pkg.getLMsg('title') }
</div>
Мы написали webpack-плагин, который, анализируя AST дерево кода компонент, находит все вызовы функции l10n() и из аргументов собирает названия пакетов. Аналогичным образом плагин собирает информацию о других типах ресурсов, необходимых приложению.
На выходе после сборки для каждого приложения мы получаем конфиг с его ресурсами:
{
"events-calendar": {
"pkg": [
"calendar",
"dates"
],
"cfg": [
"config1",
"config2"
],
"bundleName": "events-calendar",
"js": "events-calendar.js",
"css": "events-calendar.css",
}
}
И конечно, надо не забыть про актуализацию текстов. Потому что на сервере все тексты всегда актуальные, а для клиента нужен отдельный механизм обновления кеша, например, watcher или пуши.
Старый код в новом
При плавном переходе возникает задача переиспользования старого кода в новых компонентах, потому что существуют большие и сложные компоненты (например, видеоплеер), переписывание которых займет много времени, а использовать в новом стеке их необходимо уже сейчас.
Какие существуют проблемы?
- У старого сайта и новых приложений на React совершенно разные жизненные циклы.
- Если вставить код старого образца внутрь React-приложения, то этот код не запустится, потому что React не знает, как его активировать.
- Из-за разных жизненных циклов React и старый движок могут одновременно пытаться модифицировать содержимое старого кода, что может вызвать неприятные сайд-эффекты.
Чтобы решить эти проблемы, был выделен общий базовый класс для компонент, содержащих старый код. Класс позволяет наследникам согласовать жизненные циклы React-приложений и приложений старого образца.
export class OldCodeBase<T> extends React.Component<T> {
ref: React.RefObject<HTMLElement> = React.createRef();
componentDidMount() {
// ЗАПУСК активации при появлении компонента в DOM
this.props.activate(this.ref.current!);
}
componentWillUnmount() {
// ЗАПУСК деактивации при удалении компонента из DOM
this.props.deactivate(this.ref.current!);
}
shouldComponentUpdate() {
// React не должен модифицировать старый код,
// появившийся внутри React-приложения.
// Поэтому необходимо запретить обновление компонента.
return false;
}
render() {
return (
<div ref={this.ref}></div>
);
}
}
Класс позволяет либо создавать куски кода, которые работают по-старому, либо уничтожать, при этом одновременного взаимодействия с ними не будет.
Вставка старого кода на сервере
На практике возникает потребность в компонентах-обертках (например, попап), содержимое которых может быть любым, в том числе сделанным еще на старых технологиях. Необходимо придумать, как на сервере внутрь таких компонент вставлять любой код.
В предыдущей статье мы рассказывали, что используем атрибуты, для передачи параметров в новые компоненты на клиенте и сервере.
<cool-app users="[1,2,3]" />
А теперь мы еще хотим вставить туда кусок разметки, который по смыслу не является атрибутом. Для этого было решено использовать систему слотов.
<cool-app>
<ui:part id="old-code">
<div>old component</div>
</ui:part>
</cool-app>
Как видно на примере выше, внутри кода компонента cool-app описывается слот old-code, содержащий старые компоненты. Затем внутри react-компонент указывается место, куда нужно вставить содержимое этого слота:
render() {
return (
<div>
<UiPart id="old-code" />
</div>
);
}
Серверный движок рендерит этот react-компонент и обрамляет содержимое слота в тег <ui-part>, присваивая ему атрибут data-part-id=«old-code».
<cool-app>
<div>
<ui-part data-part-id="old-code">
old code
</ui-part>
</div>
</cool-app>
Если же серверный рендеринг JS в GraalVM не уложился в таймаут, то мы делаем fallback на клиентский рендеринг. Для этого движок на сервере отдает только слоты, обрамляя их в тег template, чтобы браузер никак не взаимодействовал их кодом.
<cool-app>
<template>
<ui-part data-part-id="old-code">
old code
</ui-part>
</template>
</cool-app>
Что происходит на клиенте? Клиентский движок просто сканирует код компонета, собирает теги <ui-part>, получает их содержимое в виде строк и передает в функцию рендеринга вместе с остальными параметрами.
var tagName = 'cool-app';
var reactComponent = components[tagName];
reactComponent.render({
tagName: tagName,
attrs: attrs,
parts: parts,
node: element
});
Код компонента, который вставляет в необходимое место слоты, выглядит следующим образом:
export class UiPart extends OldCodeBase<IProps> {
render() {
const id = this.props.id;
const parts = this.props.parts;
if (!parts.hasOwnProperty(id)) {
return null;
}
return React.createElement('ui-part', {
'data-part-id': id,
ref: this.ref,
dangerouslySetInnerHTML: { __html: parts[id] }
});
}
}
При этом он унаследован от класса OldCodeBase, решающего проблемы взаимодействия старого и нового стека.
Теперь можно написать поп-ап и наполнить его с помощью нового стека или запросить с сервера, используя старый подход. При этом компоненты будут работать корректно.
Это позволяет постепенно мигрировать компоненты сайта на новый стек.
Как раз это и было одним из основных требований к новому фронтенду.
Итоги
Всем интересно, насколько быстро работает GraalVM. Разработчики Одноклассников провели различные тесты с React-приложениями.
Простая функция, возвращающая строку, после разогрева выполняется примерно за 1 микросекунду.
Компоненты (опять же после разогрева) — от 0.5 до 6 миллисекунд в зависимости от их размеров.
GraalVM разгоняется медленнее, чем V8. Но на время его прогрева ситуация сглаживается благодаря фоллбэку на клиентский рендеринг. Поскольку пользователей очень много, виртуальная машина разогревается быстро.
Что удалось сделать
- Запустить JavaScript на сервере в Java мире Одноклассников.
- Сделать изоморфный код для UI.
- Использовать современный стек, который знают все фронтендеры.
- Создать общую платформу и единый подход для написания UI.
- Начать плавный переход, не усложнив при этом эксплуатацию и не замедлив серверный рендеринг.
Надеемся, что опыт Одноклассников и примеры окажутся для вас полезными и вы найдете им применение в своей работе.