В апреле этого года мы перезапустили tinkoff.ru. Банк превратился в финансовый супермакет. Теперь не только клиент банка, но и любой посетитель оплатит мобильный, проверит налоги и оформит ипотеку — всё на одной платформе. В этой статье я поделюсь опытом и технологическими решениями, к которым мы пришли за год разработки.
Наш стэк для фронтенда хорошо себя показал. Теперь мы решаем проблемы и адаптируемся к новым требованиям в рамках концепции архитектуры.
За год мы разработали 350 независимых интерфейсных блоков из 3000 React-компонентов. Оптимизировали загрузку страниц, потому что tinkoff.ru посещают 7 млн пользователей в месяц и число посетителей постоянно растет. Фронтенд разрабатывает 30 человек и мы постоянно ищем талантливых разработчиков.
Мы объединили функции предыдущих интернет-банков, портала и кошелька. Поэтому я сначала опишу исходные стэки, а потом расскажу, как мы пришли к текущему стэку.
Обзор предыдущих стэков
Портал и интернет-банк 2011
Первую версию мы разрабатывали внутренними ресурсами на основе коммерческого решения от голландской компании. Бекенд крутился на базе тяжелого Java/Spring приложения. На фронте из-за недостатка гибкости и подробной документации сформировался стэк из jQuery, Backbone, Handlebars.
Maven собирал фронт с бэком. Было катастрофически мало плагинов для фронта, так как Maven не подходил для сборки клиентских пакетов. Это привело нас в тупик. Благо нашли как отделить клиентскую сборку от серверной с помощью Grunt.
Использовать шаблоны на сервере и несвязанные шаблоны на клиенте со своей логикой и архитектурой считалось нормой. Приходилось поддерживать два UI-слоя: серверный UI и клиентский UI. Когда мы имеем крупное RIA – дублируется много логики, которая написана на разных языках программирования. Например: маршрутизация по страницам, логика получения данных или шаблоны с одинаковой разметкой.
В конце 2013 года стал развиваться изоморфный подход к созданию веб-приложений. И решение с дублированием шаблонов на сервере и на клиенте стало считаться концептуально неверным.
Кошелек 2014
Этот проект интересен по двум причинам:
- «Тинькофф Мобильный Кошелек» — первое приложение, которое использовали все, не только клиенты банка.
- Мы попробовали изоморфный подход на базе архитектуры MVC. Отправная точка — статья от Airbnb.
Стэк был схож с интернет-банком: Backbone и Handlebars, сервер на Node.js. Часть вью рендерилось на сервере. Приложение решало свои задачи и даже получился один UI-слой. Но стало ясно, что на большом приложении подобная архитектура принесет сложности. Появились проблемы с обогащением моделей данных в браузере. Приходилось писать отдельные контроллеры для серверной и клиентской стороны.
Следующий проект разработали по другой парадигме.
Интернет-банк 2015
Интернет-банк был отделен от внешнего сайта и представлял из себя Single Page Application. Для интернет-банка использовали фреймворк Angular.js. В итоге мы получили современный интерфейс интернет-банка.
В 2015 году бизнес изменил стратегию развития и предъявил новые требования к веб-приложению. Нам предстояло:
- обновить Tinkoff.ru,
- интегрировать на все страницы функциональность интернет-банка и кошелька,
- поддержать SEO и SMM для всех страниц, включая страницы с платежными провайдерами.
Новый интернет-банк имел UI-слой только на клиенте. Поисковые роботы пока не научились хорошо индексировать подобные приложения. И непонятно, когда это произойдет. Не получилось отказаться от серверного слоя. Мы видели несколько путей развития существовавших стэков:
- Объединить старый портал и новый интернет-банк. Портал выступал бы в качестве серверного UI-слоя, а Angular.js в качестве клиентского. Этот вариант не решил бы наши фундаментальные проблемы.
- Заменить Java приложение Node.js приложением. Это могло бы упростить поддержку, но оставалось два UI-слоя.
- Убрать Java, оставить только Angular.js. И рендерить SPA с помощью развернутых серверов с headless браузером Phantom.js. Такую схему сложно отлаживать, поэтому этот вариант не подходит для большого количества страниц.
Мы проанализировали пути развития существовавших стэков и поняли, что они не решают наших задач. В итоге выбрали кардинально другой подход.
Платформа 2016
То, что сейчас доступно на Tinkoff.ru, мы называем платформой.
За основу этой системы мы выбрали архитектуру Flux, которую предложили инженеры Facebook в 2014 году. Flux отличается от MV* тем, что в нем отсутствуют разнонаправленные потоки данных, что легче ложится на изоморфную парадигму и всё это помогает быстрее отлаживать приложение. Архитектура Flux взяла за основу модель работы с базой данных CQRS.
Мы реализовали идею изоморфного приложения с одним UI-слоем. В качестве шаблонизатора выбрали библиотеку React.js с поддержкой виртуального DOM. Благодаря которому шаблоны легко рендерятся на сервере.
Сложившийся стэк решает задачи SEO и SMM. Переиспользует код между браузером и сервером, за исключением специфичного для окружения кода, например работа с cookies. Позволяет решать весь спектр задач одной группой разработчиков, что приводит к ускорению работы. Не зависит от одного фреймворка/вендора. Приложение объединяется набором правил, выстраивается из небольших и независимых модулей. Модуль можно заменить, если будет более подходящая реализация.
Универсальное приложение
Fluxible
В качестве реализации Flux выбрали фреймворк Fluxible от инженеров Yahoo. Эта реализация ориентирована на изоморфный рендер. Выбранное решение полностью удовлетворяет нашим требованиям.
Мы стараемся не связывать приложение крупными зависимостями, поэтому используем только две библиотеки из набора:
- Routr – маршрутизация по страницам. Библиотека поддерживает express-подобные пути. Она изоморфная и быстрая: сейчас роутимся по 2000 страницам.
- Dispatchr – Flux диспетчер.
Распределение по слоям
Архитектура приложения
Сервисы. Доступ к браузерным или HTTP API, взаимодействие с внешними системами. Здесь содержится часть бизнес логики. Сервисы имеют несколько реализаций: изоморфные shared, server для node.js и client для браузера. Могут кэшировать результат.
Действия. Flux action creators, содержат часть UI и бизнес логики. Имеют доступ к сервисам.
Сторы. Модели данных, которые содержат UI логику.
Компоненты. Рендер данных из сторов в HTML.
Прогрессивная загрузка
Благодаря серверному рендеру мы получаем эффект прогрессивной загрузки и сокращение time to glass. Средний пользователь видит работающую страницу через 600 мс после запроса сайта. Через пару секунд инициализируется динамика и загружаются персональные данные.
Мы можем рендерить все данные на сервере, можем ренедрить часть. А можем полностью отключить серверную часть и использовать приложение как SPA. Чтобы сократить нагрузку на сервера, рендерим только общие данные для всех пользователей, а работу с персональными данными выполняем в браузере.
Пример кода
Что выполнять на сервере или что позволить запустить пользователю с определенными ролями, мы определяем на уровне функции создания действия:
import { ACCOUNT_LIST } from '../actions';
import { CLIENT } from '../roles';
accountList.isServer = true; // Флаг указывает на право запуска действия на сервере
accountList.requiredRoles = [CLIENT]; // Список указывает необходимые роли пользователя
function accountList(context) {
return context.service('account/list') // Каждая часть приложения имеет свой ограниченный контекст с набором методов из общего контекста. Таким образом вызываем сервисы.
.then(payload => context.dispatch(ACCOUNT_LIST, payload)); // Далее диспатчим действие с полезной нагрузкой
}
export default accountList;
В остальном коде приложения нет условий для определения окружения. Все решается на уровне контекста, который наследуется от базового класса контекста.
Переиспользование
Компоненты приходилось использовать с разными моделями данных. Их стало сложно поддерживать, тестировать и переиспользовать. Поэтому мы разделили компоненты на коннекторы и чистые компоненты:
Такой подход упростил переиспользование компонентов и тестирование.
Пример коннектора
import { LogoPure } from './LogoPure.jsx';
const UILogo = connect(
['config', 'brands'],
({
brands,
config: { brandsBasePath }
}) => ({
brands,
brandsBasePath
})
)(LogoPure);
export default UILogo;
Используем утилиту connect (схожа с коннектором из redux), которую можно использовать в виде декоратора. Первый аргумент – список сторов, в которых мы заинтересованы. Второй аргумент – функция маппинга, которая принимает состояние всех сторов и возвращает только нужные чистому компоненту данные.
Higher-order Components
Следующий подход, который мы изначально не использовали для наследования кода — компоненты высокого порядка. Переиспользовать код между компонентами помогали миксины, которые поддерживает React.createClass.
Миксины не гарантируют свое окружение. Если на компоненте использовать более трех миксинов и если они становятся зависимы, то поддержка такого компонента становится проблемой. Подробнее об этом в статье Mixins Are Dead. Long Live Composition.
Оптимизация
Оптимизация универсального приложения находится на двух сторонах: клиентской и серверной. Расскажу о наших подходах к разработке быстрого приложения.
Оптимизация на клиенте
Первое правило быстрых компонентов – как можно реже вызывать метод render. Для этого мы используем подход pure-render. Он вызывет render через переопределение метода shouldComponentUpdate, если только исходные данные изменились (props или state).
Иногда в компонент передается больше данных, чем ему нужно. Иногда меняются поля модели, данные не меняются, а ссылка на модель данных изменилась. В этом случае проверка pure-render не срабатывает. Чтобы определить количество вызовов render того или иного компонента, мы используем модуль react-perf. С его помощью получаем статистику в удобном виде:
Если находим компонент с неоправданно большим количеством ререндеров, то диагностируем его подробней с помощью нашей утилиты render-logger. Она позволяет увидеть измененные данные, которые повлекли вызов render:
Рендер произошел из-за изменения функции, переданной в свойство onClick. Это происходит при использовании bind или определении функции внутри render родительского компонента, когда при каждом вызове render создается новая функция. И из-за этого не срабатывает защита pure-render дочернего компонента.
Чтобы запретить bind в render, используем линтинг Eslint с плагином eslint-plugin-react и опцией jsx-no-bind.
Batched updates
React поддерживает изменение стратегии рендера в runtime. Мы используем стратегию пакетного обновления при первоначальной инициализации. Или при переходах между страницами, когда происходит повышенная активность приложения. Это уменьшает итоговое время рендера.
Визуальное ускорение
Следующий способ, который мы используем для ускорения клиентского экземпляра приложения, это визуальное ускорение. Считаю этот способ лучшим по соотношению затрат к результату.
Сейчас на главной Tinkoff.ru есть шапка с логотипами платежных провайдеров, которые появляются с анимацией.
В первых версиях логотипы появлялись только после загрузки клиентского пакета JS. На медленном мобильном интернете это приводило к неприятному эффекту долгой загрузки страницы. Пользователь ждал, когда серый блок будет наполнен.
Мы решили изменить стили и код компонента, чтобы отрисовывать логотипы на сервере. Благодаря отказу от ожидания инициализации клиентской части, уменьшилось время до появления логотипов.
Оптимизация на сервере
Серверный рендер компонентов React медленней простых строковых шаблонизаторов. Я расскажу о том, как мы ускоряем рендер на сервере.
- Используем ES6 Class, а не React.createClass (он медленней из-за autobinding) и не забываем NODE_ENV=production, который отключает код для отладки. Это приводит к 4х кратному ускорению.
- Минифицированная версия React. Код для профайлинга удален полностью. +2% (здесь и далее прирост относительно первого пункта)
- Трансформация кода на уровне Babel. Используем два плагина:
- transform-react-constant-elements поднимает инициализацию компонентов в верхний скоуп модуля
- и transform-react-inline-elements преобразует вызов createElement к оптимизированному виду. +10%
- Максимально используем stateless функции. На тестовом стенде замена всех компонентов привела к прибавке в 45%.
- React-dom-stream приводит результат функции renderToString к потоку, что дает уменьшение времени до отдачи первого байта (TTFB) на нашем стенде до 3-5мс. И к сокращению времени до передачи последнего байта (TTLB) на 55%.
На данный момент библиотека не поддерживает React 15. Возможно, функциональность библиотеки включат в ядро React: https://github.com/aickin/react-dom-stream/issues/18 Помимо этого, библиотека реализует интересную возможность кэширования компонентов по отдельности, что может ускорить серверный рендер.
На данный момент мы кэшируем всю страницу. Благодаря отсутствию персональных данных в HTML, нам это далось легко.
Кэш
Кэширование в приложении реализовано на нескольких уровнях:
- Nginx кэширует на короткий промежуток времени результат генерации всей страницы.
- Express-cache-on-deamand страховка от потока однотипных запросов.
- Lru-cache библиотеку используем для кэширования результатов сервисов.
С lru-cache мы используем нестандартную схему удаления данных из кэша. Любые данные при достижении TTL не удаляются из памяти, а запрашиваются у источника. Если источник ответил новым результатом – кладем значение в кэш. Такой подход повышает отказоустойчивость, когда внешние системы недоступны. Если мы единожды получили данные из внешнего сервиса, то уже не потеряем.
Есть библиотека, которая реализует похожую схему:
Сборка и деплой
Для сборки и деплоя используем CI Teamcity. Делаем два отдельных артефакта для клиента и сервера. На сборке клиентского пакета обычная конфигурация Webpack. Со сборкой серверного пакета интересней.
Сначала мы просто собирали пакет tar из исходников и распаковывали его на сервере. Когда модулей для компиляции Babel стало много, первые запросы на сервер стали проходить долго – Babel компилировал все при первых запросах.
Первое реализованное решение – процесс прогрева перед стартом, компиляция.
Затем настроили прекомпиляцию Babel при сборке артефакта, что сократило время прогрева до пары секунд.
Сейчас в момент прогрева происходит только наполнение кэшей данными из внешних сервисов. Такой подход экономит время на сборке с помощью переиспользования времени, затраченного на компиляцию Babel в клиентской сборке.
Второе решение, которое можно использовать – компиляция серверного пакета с помощью Webpack. Этот подход более функциональный, но влечет увелечение общего времени сборки.
В артефакты мы не записываем переменные окружения, а передаем их только при старте серверного экземпляра приложения. Значения переменных окружения записываются в стор и передаются на клиент через стандартный процесс передачи состояния приложения с сервера на клиент.
Для запуска и мониторинга приложения на сервере используем application runner PM2. PM2 имеет богатую функциональность.
- Для запуска приложения используем команду pm2 startOrGracefulReload processes.json, что позволяет перезапускать приложение с минимальным временем недоступности.
processes.json:{ "name": "portal", "script": "server/index.js", "instances": -1 }
- Если ваше приложение запускается более чем на одном сервере, то между ними есть балансировка. И в этом случае балансировка между форками с помощью Node.js кластера становится ненужной. Мы убрали балансировку балансировки и переложили эту ответственность на единую точку – Nginx. Каждый экземпляр приложения на всех серверах запускается на отдельном порту и Nginx знает о всех экземплярах. Благодаря PM2 сделать это просто. При выборе порта нужно учитывать переменную окружения NODE_APP_INSTANCE: PORT: process.env.PORT + process.env.NODE_APP_INSTANCE
Завершение
Универсальное веб-приложение решает выставляемые задачи, объединяет одну ответственность в одну кодовую базу. React.js заставил переосмыслить сложившиеся практики в разработке веб-интерфейсов. Виртуальный DOM становится стандартом де-факто для шаблонизаторов. Flux упрощает разработку и отладку сложных приложений. Node.js продолжает завоевывать свое законное место на корпоративных серверах.
Если вы не разрабатывали приложения на схожем стэке, то, надеюсь, прочтение статьи сподвигнет вас на смелые эксперименты в этом направлении. Думаю, в ближайшие несколько лет не появится кардинально новых подходов и инструментов для разработки приложений, которые бы на порядок упрощали разработку. Есть смысл вложить немного своего времени в изучение React.js, Flux и присоединиться к нашей Dream Team.
Пишите о себе на best-talents@tinkoff.ru.
Делюсь фотографиями с одного планирования и обычной пятничной тусовки:
Спасибо и до новых встреч!
PS: Если вы хотите получить навык промышленной разработки веб-приложений, а также навыки создания конечных продуктов, то добро пожаловать в нашу летнюю школу FinTech. Курсы ведут наши специалисты и по итогу вы научитесь создавать финансовые продукты.
Автор: Тинькофф Банк