Как мы строим веб-платформу в Авиасейлс

Последний год я почти все рабочее время трачу на создание и поддержку веб-платформы в Авиасейлс. На мой взгляд, она уже достигла зрелости и теперь будет только эволюционно развиваться. Поэтому я решил рассказать как мы ее создавали, с какими проблемами столкнулись и как их решили.

Чтобы быстро и без проблем развивать продукт нужно вкладывать силы и время в создание инструментов. Внутренние библиотеки, дизайн-система, конвенции и документация — все это снимает головную боль продуктовых разработчиков и позволяет им заниматься действительно важными вещами.

Один из самых ценных инструментов разработки — платформа, вокруг которой строится продукт. Этот термин сложно определить строго, поэтому я буду использовать своё дилетантское определение.

Платформа — это набор инструментов и ограничений, которые ускоряют разработку и диктуют правильный способ создания приложения.

Глава первая, в которой команда фронтенд-разработки замечает проблему

Чтобы в полной мере разобраться в задачах новой платформы, нужно поговорить о состоянии фронтенда Авиасейлс весной 2020 года. В этой главе мы обсудим технические особенности приложения и организационную структуру компании.

Команда

Когда я пришёл в Авиасейлс, все фронтендеры компании были разделены на три группы.

  1. Большая и цельная команда фронтендеров. Это была своего рода заказная разработка внутри компании. К этой команде приходили, когда нужно было сделать что-то с веб-интерфейсом.
  2. Отдельные фронтендеры (на самом деле, фронтендер) команды ассистед-букинга. Эта команда полностью владеет флоу покупки билета прямо в интерфейсе Авиасейлс.
  3. Фронтендеры б2б-направления. «Авиасейлс для бизнеса» — мини-стартап внутри компании, который развивает достаточно самостоятельный продукт.

Из-за закона Конвея, устройство кода полностью повторяло структуру команды. Почти весь код лежал в большом проекте Экспложен (запомните это название, я буду много о нем говорить), приложение ассистед-букинга было в том-же репозитории, но явно отделено. А приложение б2б жило в отдельном репозитории.

Возможно, эта ситуация не стала бы проблемой, но весной 2020 года внутри компании началось преобразование функциональных команд (фронтендеры/мобильщики/аналитики) в продуктовые команды. То есть, теперь у каждой команды были свои фронтендеры, свои аналитики, свои дизайнеры, свои мобильные разработчики. Плюс, мы стали активно нанимать.

После преобразования, код Экспложена стал бы принадлежать 4 или 5 разным командам. Такому количеству людей со своими задачами и идеями было бы тесно в рамках одного монолитного приложения с не самым прозрачным разделением зон отвественности.

Приложение

Фронтенд

На самом деле, Экспложен не был монолитом. Внутри него было много мини-приложений, которые мы называли виджетами. Некоторые виджеты были написаны на чистом JS, они просто оживляли элементы контентных страниц — добавляли интерактивности календарям, графикам, картам. Эти виджеты мы почти не трогали.

Пара виджетов использовала React и полностью контролировала интерфейс приложения. Это виджеты поисковой формы и выдачи и личного кабинета. 95% изменений кода приходилось на эти виджеты, в них была сконцентрирована основная работа.

Все виджеты были связаны между собой достаточно непрозрачными связями. Например, для получения текущей валюты пользователя в поисковую форму просто подключались некоторые Redux-редьюсеры из личного кабинета. В итоге независимо развивать все это было затруднительно.

Отдельно замечу, что эти приложения существовали много лет и в них использовался широкий спектр технологий. Что-то было написано на CoffeeScript, что-то — на TypeScript. В качестве стейт-менеджера везде использовался Redux, но работа с сайд-эффектами была организована где-то через кастомные мидлвары, где-то через redux-thunk, где-то через redux-saga. Стили везде были написаны на SCSS, но изоляция производилась через кастомный аналог БЭМа, что в большом приложении нередко приводило к коллизиям.

Бекенд

Все страницы, которые получает пользователи были просто статическими HTML-файлами. Они раздавались через nginx. Создавались эти файлы большим Elixir-приложением из Slim-шаблонов в фоновом режиме. Это приложение называется Гелиос, оно еще не раз встретится в нашей истории.

При этом, чтобы добавить разметку из React-приложения в конечный HTML-файл был специальный флоу: на компьютере разработчика запускалась специальная таска, которая из React-компонентов генерировала HTML-строку и добавлялся ее внутрь Slim-шаблона, чтобы потом Гелиос приготовил финальную страницу для пользователей.

У этой схемы был ряд важных преимуществ, главное из которых — надёжность. Все операции со страницами происходили вне обработки пользовательского запроса, таким образом вероятность аварий была сведена к минимуму, а высокая производительность nginx позволяла без проблем переживать любые пиковые нагрузки (например, когда приходили поисковые боты).

Гелиос тогда был не простым движком сборки страниц, он занимался еще региональной спецификой. Дело в том, что на разных рынках у нас есть разное количество данных и именно Гелиос решал как должен выглядеть интерфейс для конкретного рынка.

Почему понадобилось что-то менять

У всей этой системы было две основные проблемы.

Проведение АБ-тестов, затрагивающих интерфейс главной страницы

Все эксперименты с интерфейсом мы проводили через сервис Flagr. Пользователь заходит на сайт, приложение прямо из браузера запрашивает у Flagr флаги для этого пользователя, а клиентское приложение на их основе меняется.

Проблема в том, что если так проводить эксперимент затрагивающий главную страницу, то интерфейс будет дергаться. Ведь HTML собран заранее и одинаковый для всех пользователей, а флаги у каждого свои. Поэтому, для АБ-тестов затрагивающих главную страницу мы использовали самодельную систему — Фемиду.

Этот сервис позволял задеплоить на два разных сервера код из двух разных веток и отправлять пользователя на один из серверов в зависимости от группы эксперимента, в которую он попал.

Из-за этой схемы мы могли запускать параллельно только один такой тест. В итоге скапливалась очередь и начинал страдать продукт. Плюс, приходилось поддерживать две совершенно независимые системы проведения интерфейсных экспериментов.

Если ваша рука уже потянулась, чтобы написать в комментарии что-то типо «фича флаги нельзя использовать для АБ-тестов» — остановитесь. Нам можно.

Увеличение числа фронтендеров

Как я уже говорил, примерно в это время мы начали наращивать количество фронтендеров в продуктовых командах и столкнулись с очередями на деплой, постоянными конфликтами в репозитории и прочими прелестями общей кодовой базы для большого числа людей. В старой системе мы не смогли придумать способа дать командам возможность работать абсолютно независимо и не блокироваться друг об друга.

Другие минорные проблемы

Было ощущение, что кодовая база Экспложена, где лежало большинство кода, портится быстрее, чем улучшается. Мы связывали это с большим количеством кастомных решений, общей неоднородностью архитектуры и запутанностью связей между разными модулями приложения.

Отдельно стояла проблема повторного использования элементов интерфейса в других продуктах. Например, на маркетинговые лэндинги часто хочется добавить виджет подписки на дешевые билеты, а у Travelpayouts есть целый вайт-лейбл, в котором было бы здорово использовать выдачу Авиасейлс.

Глава вторая, в которой команда Веб-платформы придумывает решение

В итоге все эти проблемы навели нас на мысль, что нужно создать платформу, вокруг которой будут строиться все интерфейсы. Основные свойства этой системы должны быть такими:

  1. Позволяет продуктовым командам работать без взаимных блокировок.
  2. Позволяет повторно использовать элементы интерфейса в других продуктах.
  3. Поддерживает любое количество перпендикулярных АБ-тестов над любой частью интерфейса.

Останавливать разработку продукта и делать «дивный новый фронтенд» мы не могли. Мне кажется, такой подход вообще никогда не работает. Поэтому мы выбрали путь «новое писать но-новому, а старое — по-старому»:

  • Мы учим Гелиос работать в динамике — собирать страницу на каждый запрос пользователя и отдавать ее в браузер, без промежуточного складывания файлов на диск.
  • Создаём небольшой сервис на Node.js (его назвали Селена) для рендеринга маленьких React-приложений в HTML-строки, которые Гелиос сможет встраивать в страницы.

Первая версия этой схемы была супер-простой — Селена на сервере не исполняла никакой логики и не делала запросы за данными, она просто принимала данные из тела HTTP-запроса и передавала его как пропсы в React-компонент, отдавая наружу получившуюся строку. Этого было достаточно, чтобы проверить возможность постепенной миграции.

В этой версии у нас осталось несколько незакрытых вопросов, часть из которых удалось отложить, а некоторые нужно было решать.

Что делать с SPA-переходами?

На сайте Авиасейлс есть несколько SPA-переходов, но самый главных из них — переход с главной страницы на страницу поиска по нажатию на большую оранжевую кнопку.

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

Как доставить нужные ресурсы в браузер?

Каждому виджету нужны свои ресурсы (стили, скрипты, шрифты), и при этом есть некоторая общая для всех виджетов часть. Для прототипа мы решили не париться об этом — собирать все ресурсы в один большой бандл и отправлять в браузер.

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

Как позволить виджетам общаться между собой?

Внимательный читатель уже заметил, что, по сути, мы сделали систему микрофронтендов, а в любой микросервисной архитектуре вопрос общения разных сервисов (или виджетов) — ключевой.

Нужно дать разным микро-приложениям возможность общаться между собой, передавать данные и события, но при этом не скрепить их жесткими связями, которые сведут на нет все усилия по предоставлению возможности разрабатывать приложение без блокировок разными командами.

Мы провели в обсуждении несколько десятков часов, изрисовали кучу бумаги и в итоге придумали решение, которое пришлось выбросить спустя полгода 🤷‍♂️

Основная идея была простой: виджеты не знают друг о друге вообще ничего, а связывают их между собой внешние сущности — релейшны. Чтобы инициализировать несколько виджетов и релейшнов нужно определить сценарий — самую высокоуровневую сущность, которая управляла отображением всего приложения.

Все эти связи были построены вокруг событийной модели: виджет объявлял какие события он может использовать и получал через пропсы кастомный ивент-бас, через который можно было испускать и слушать объявленные события.

Интересный факт! Почти сразу после написания кастомного ивент-баса мы обнаружили, что написали свой маленький RxJS. Спустя пару месяцев кастомную имплементацию заменили на настоящий RxJS сохранив публичный интерфейс.

Релейшны в свою очередь выступали преобразователями событий одних виджетов в другие.

function seasonalCurrency(setting, seasonal) {
   settings.init$
     .pipe(map((payload) => payload.currency))
     .subscribe(seasonal.setCurrency$);

   settings.currencyChanged$
    .subscribe(seasonal.setCurrency$);
 };

Довольно быстро мы обнаружили, что существует еще некоторая логика, не относящаяся к конкретному виджету — так появились сервисы. Сервис — это виджет без отображения. Связи сервисов между собой и с виджетами должны были быть проложены так же через релейшны.

Эта концепция казалась прекрасной — связи направлены в правильные стороны, виджеты легко использовать в разных сценариях.

Как позволить виджетам общаться со старым кодом?

Экспложен и Селена жили в разных репозиториях, независимо деплоились и вообще ничего друг о друге не знали. Единственное место пересечения этих двух приложений — страница в браузере. И в этой ситуации нам нужно было обеспечить передачу событий между ними.

Тогда мне в голову пришло решение «уровня Б», которое до сих пор служит верой и правдой всему продукту. В Селене был создан сервис для общения с Экспложеном, который по сути просто подсовывал ивент-бас в глобальный объект window, а на той стороне Экспложен пытался найти его и использовать.

Это решение было сопряжено с некоторым количеством трудностей. Например, Селена работала не везде, где работал Экспложен, поэтому весь код был написан в расчете на то, что связь может быть, а может не быть.

Кстати, именно эта связь стимулировала нас перейти с кастомной имплементации ивент-баса на RxJS. Из-за того, что мы не знали какое приложение запустится раньше, все события внутри ивент-баса нужно было накапливать и всем новым подписчикам отдавать полную историю событий. В RxJS есть для этого встроенный механизм — BehaviorSubject. Им мы и воспользовались, избавившись от кастомной имплементации.

Глава третья, в котором Селена оказывается в продакшене

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

Производительность

До появления Селены в Авиасейлс не было серьезных Node.js-сервисов. А ведь Селена должна была стать ключевым сервисом, на который завязан весь веб-интерфейс. Нам нужно было удостовериться, что сервис сможет переживать пиковые нагрузки без деградации.

Так мы обвесили сервис мониторингом и начали серию нагрузочных тестов с разными профилями нагрузки. Мы рендерили большие виджеты, маленькие виджеты, сложные виджеты, простые виджеты. В итоге выяснили, что можем без проблем выдерживать нагрузку в 100 раз превышающую пиковую без увеличения аппаратных мощностей. Этот результат всех устроил и мы пошли дальше.

Надёжность

Следующий этап тестирования — АБ-тест первого виджета на настоящих пользователях. У продуктовой команды был в нем свой интерес, а у нас — свой. Мы хотели убедиться, что под настоящим трафиком вся связка сервисов работает корректно и обслуживает пользователей стабильно.

В процессе мы обнаружили несколько интересных проблем. Например, из-за разных настроек времени жизни открытого коннекта на nginx-ingress и в Node.js-приложении часть запросов обламывалась и пользователи не получали виджет. И таких мелких, но противных проблем было много.

За пару недель мы обкатали сервис и торжественно обьявили, что теперь он готов к обслуживанию пользователей.

Глава четвёртая, в которой мы «все переделали»

Первые полгода жизни Селены архитектура фронтенда почти не менялась — сервисы, виджеты, релейшны.

Мы с командой (за это время выросшей в два раза) работали над выделением логики работы с данными пользователя в отдельную сущность. Хотелось перенести эту часть кода из Экспложена в Селену, сделать все ее зависимости явными и предоставить простой и понятный интерфейс для продуктовых команд.

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

Все сервисы представляли собой наборы Rx-стримов и связей между ними. То есть по сути сервисы оперировали только событиями, но не умели работать с состояниями и сайд-эффектами. В этом и была проблема. Например, для сохранения состояния приходилось использовать стрим, который накапливал данные, а для асинхронных сайд-эффектов просто два стрима — команда на старт эффекта и сообщение об окончании эффекта.

Это отвлекало от задачи сервисы и вынуждало писать очень много вспомогательного кода. Сервисам явно стало не хватать стейт-менеджера, который представлял бы не только события, но и состояние, и сайд-эффекты. Требования к новому инструменту были простые — модульность из коробки (ведь сервисы должны быть максимально изолированы друг от друга) и выразительность API.

Финалистов было три:

  1. Redux и велосипед для нескольких независимых сторов. Получилось совсем плохо. Во-первых, концепция многих сторов явно идёт в разрез с правильным путём использования. А во-вторых, сформировать чистый публичный API сервиса с Redux было сложно, всегда оставалась вероятность неконтролируемых протечек в абстракции. Единственный плюс — знакомая технология, которую мы умели неплохо готовить.
  2. MobX. Результат был хорошим — ООП-природа библиотеки помогала строить четкие границы между сервисами, а встроенная возможность работать с мульти-сторами отлично легла на нашу схему данных. Главной проблемой для нас была поддержка SSR и размер бандла. Плюс, мне не нравилась необходимость работать с расширениями языка типо прокси-объектов и классов. Это выглядит несколько чужеродным.
  3. Effector. Тоже отлично — у сервисов получались очень JS-вей, а описывать логику можно было простыми и выразительными средствами. Приятные бонусы связанные с удобным SSR и маленьким размером окончательно склонили нас к выбору этой библиотеки. Её мы и выбрали.

Effector

Я считаю, что из-за отсутствия реактивных примитивов в JS, описание бизнес-логики не может быть оторвано от какой-нибудь библиотеки содержащей эти примитивы. Поэтому менеджер состояния для меня — ключевая технология в любом приложении.

Изначально, я был довольно скептически настроен к Эффектору. Мне нравилась идея, но пугало все остальное: незрелость экосистемы, сложность внутреннего устройства, крутая кривая обучения. Я использовал его для многих пет-проектов, но откровенно боялся затягивать библиотеку в большой продакшн.

Чтобы принять окончательное решение я сделал три довольно больше демки — переписал значительные кусочки Авиасейлс с использованием каждого из финалистов. В итоге код с Эффектором оказался выразительнее и проще, чем конкуренты. Так я стал амбассадором Эффектора.

Смерть релейшнов

Изначально казалось, что мы всего лишь добавим в сервисы состояние, но сохраним архитектуру неизменной. Но в процессе исследований оказалось, что большая часть релейшнов ничего не делает и просто перекладывает данные из одно места в другое. Это не снижало связанность модулей, а просто прятало связи под ковёр. Тогда мы сделали еще один шаг и разрешили виджетам напрямую использовать сервисы.

function travelRestrictionsModal(service, widget) {
  service.openFeedback$
    .subscribe(widget.open$);
 };

Глава пятая, в которой все наконец-то хорошо

Сейчас Селена отлично решает поставленные перед ней задачи и даже почти не бесит продуктовых разработчиков. Теперь время эволюционных улучшений. Например, прямо сейчас у нас в работе два проекта, которые должны еще больше упростить разработку виджетов и ускорить развитие фронтенд-приложения.

Изоморфные виджеты

Изначально Селена создавалась как простая рендерилка HTML. Ей давали данные, а она на их основе создавала разметку для пользователя. Но сейчас стало понятно, что в итоге нам приходится поддерживать два пути получения данных:

  1. при рендеринге на сервере виджет получает данные из пропсов;
  2. при изменении состояния в браузере, виджет должен сам сходить в интернет за новыми данными.

Чтобы избавиться от двух параллельных схем, мы начали работать над виджетами, которые будут сами ходить за данными на сервере и на клиенте. Кстати, в этом очень помогает Fork API, который я считаю самой важной частью Эффектора.

UI для сервисов

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

Послесловие

Продукты — это сложно. Нельзя недооценивать важность платформенной разработки, которая закладывает основу для быстрой и надежной продуктовой разработки.

Наш путь не подойдёт другим командам, он слишком специфичен. Но некоторые принципы универсальны:

  1. Платформа должна ускорять разработку. Решать типичные техническое проблемы и избавлять продуктовых разработчиков от головой боли.
  2. Платформа должна ограничивать разработчиков. В кураже производства фич легко не заметить увеличение хрупкости приложения, и испортить жизнь себе из будущего.

Пишите вопросы в телеграм-канал или в твиттер 🤗


Записывайтесь на консультации 🤓