Как мы распилили монолит. Часть 1

Привет, меня зовут Ваня. Я решаю архитектурные задачи на фронтенде в Тинькофф Бизнесе и сейчас расскажу вам про одну из них.

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

5f315b49275ad544d452fe0509bdd9ba.png

Синхронизация

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

Как было

Долгое время в компании был репозиторий с проектом РКО (расчетно-кассовое обслуживание), в котором находилась почти вся функциональность данной аббревиатуры. На картинке показано примерное количество разных блоков, которые пытались жить своими релизными циклами и фича-командами.

Примерная схема монолитного фронтенд-приложенияПримерная схема монолитного фронтенд-приложения

Одна из мыслей, которая приходит при просмотре картинки: «А давайте-ка мы все это распилим на независимые части!». На эту тему я предлагаю порефлексировать.

Нужно ли?

Первый вопрос, конечно же, о целесообразности потратить множество человеко-часов на распил проекта на несколько поменьше. Рассмотрим на нашем примере все за и против по каждому из следующих утверждений:

Тезис

Почему это хорошо

Почему это плохо

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

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

Этот пункт можно относительно спокойно решить с помощью TBD и фича-флагов

Уменьшение связности кода

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

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

Выделение логических частей продукта в проекты с целью повышения ответственности (ownership) за кодовую базу. Этот пункт актуален для больших команд, которые работают в одном репозитории. Часто в мастер-ветке бывает 5, 10, а то и больше пулл-реквестов (ПР), которые могут конфликтовать друг с другом

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

В монолите можно организовать код по библиотекам или разделам, на которых также появятся ответственные. Дополнительно можно создать и соблюдать определенные договоренности по работе с git flow

Возможность менять стек отдельной части

Команда как владелец приложения определяет свой стек — например, может поменять state-менеджер, обновить библиотеку до последней версии или, наоборот, зафиксировать ее. Отмазки «так исторически сложилось» со временем пропадут

Возможность ротации специалистов между командами усложняется с увеличением разницы в стеке технологий между проектами

Возможность заморозить часть приложения

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

— Кто сломал такую-то функциональность на этом релизе?

— Это соседняя команда. Они сказали, что ревью прошло, все протестировано и доехало до прода.

Правда знакомо? :)

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

Рефакторинг маленьких приложений происходит быстрее

У вас есть некоторое ломающее изменение, и нужно мигрировать со старой версии на новую. Например, повысить версию Ангуляра (хотя со схематиками это одна радость). У вас варианты:  

  1. Засесть на большой срок за миграцию одного монолита, по дороге что-то потерять или сломать, потому что ПР будет очень большим

  2. Мигрировать по проекту: ПРы и вероятность что-то потерять ниже — за счет меньшей кодовой базы

Рефакторинг монолита можно проводить частями, для больших миграций могут помочь скрипты, codemod и схематики. В таком случае весь проект будет 100% переведен и технического долга не останется

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

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

Все же такие тест-раннеры существуют и в монолите можно распараллелить выполнение задач

После прочтения пунктов у вас наверняка загорелись глаза, потому что пунктов «За» больше, и вы бежите распиливать проект. Но нужно учесть, что, если ваша команда небольшая, например из двух-трех человек, такой «распил» вам скорее навредит, чем поможет.

Что дальше?

Если вы все-таки решили распиливать монолит, теперь у вас на одну проблему больше:-) Обсудим первоочередные затруднения, с которыми мы столкнулись:

  • Каким образом будет происходить оркестрация приложениями. Теперь вам понадобится дополнительная обвязка-приложение, которое будет определять активное приложение на экране пользователя. У нас это Frame Manager, познакомиться с которым можно в докладе или в следующей статье.

  • Глобальные стили и зависимости больше не могут быть глобальными в абсолюте. Вам придется думать, как правильно развести глобальное состояние приложений, будь то window.myVar или глобальный стиль .my-bar. Теперь вам необходимо учитывать, что соседнее приложение может обратиться к вашему состоянию и произойдет что-то неожиданное. Данный пункт применим только к случаям, когда на странице более одного микрофронтенда.

  • Зависимости приложения теперь дублируются. Поясняю: все пакеты ваших приложений находятся в каждом из ваших приложений. Если раньше пользователь загружал vendor.js один раз и пользовался монолитом, то теперь это будут vendor1.js, vendor2.js и т. д. Если сойдутся звезды, то у вас будет полное совпадение всех версий и хэш в названии файла будет одинаковым — тогда браузер достанет файл из кеша. Однако такое совпадение полностью убивает преимущество № 4, да и, скорее всего, эти приложения будут находиться по разным url’ам и файл все равно будет загружен.

Разделение

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

И одного универсального ответа здесь не будет. Предлагаю рассмотреть несколько вариантов, из которых вы сможете выбрать подходящий вам:

  1. На каждую из логических частей создаем новое полноценное приложение. Если встречаются переиспользуемые сущности, то копируем их в новый проект. Звучит абсурдно, но никто не будет спорить, что это самый быстрый вариант. Однако копировать общий код из репозитория в репозиторий совсем не круто, поэтому чуть прокачаем метод.

  2. На каждую из логических частей создаем новое полноценное приложение и отдельно выносим общую кодовую базу в некий foundation. Это может быть отдельный (моно)репозиторий или директория libs у вас в проекте. И вот здесь возникает еще один нюанс: вынести-то мы вынесли, а как теперь обратно в проект подключить?

    • Самый простой вариант — положить библиотеки в репозиторий вашего проекта. Способ применим, если вы распиливали монолит в одну монорепу. Например, по такому алгоритму работает Nx Workspace. Таким образом вы можете работать со своими библиотеками, не переключаясь с проекта. Однако минусом будет тот факт, что конкретно в этот момент вы можете использовать только текущую версию библиотеки. Вы не можете ее зафиксировать, повысить или понизить, а любое изменение библиотеки соседними разработчиками автоматически применится на ваш проект.

    • Чуть посложнее: на каждый релиз вашего приложения можно создавать отдельную ветку в foundation и указывать ее название в package.json. В принципе готово. Теперь после установки зависимостей у вас будет загружена именно та версия общего кода, которую вы указали, а все дополнительные настройки можно сделать через tsconfig.json.

    • git submodule / subtree — вариант чуть посложнее, так как, кроме простого указания ссылки на репозиторий, придется еще чуть-чуть отладить его работу. У этого варианта есть огромный плюс перед предыдущим: можно прямо в своем проекте изменять исходники библиотек и пушить их в репозиторий. Еще плюс по сравнению с первым пунктом — всегда можно зафиксироваться на определенном коммите и изменение общего кода не затронет ваш проект.

    • Сложный вариант — настроить публикацию вашего общего кода как npm-библиотек, при этом заранее разбив его на пакеты. Декомпозиция нужна, чтобы изменение функциональности проходило дозированно. Вот что я имею в виду: если разработчик меняет работу с авторизацией обратно несовместимо в пакете auth и повышает версию с 1.0.0 до 2.0.0, а второй разработчик хочет поменять название в баннере для платежной системы, который каким-то чудом оказался в этом же пакете, то он выпустит уже 2.0.1, но применить его не сможет, пока не обновит в своем проекте авторизацию, которая ему в общем-то и не нужна. Здесь же можно придумать различные релиз-кандидаты, альфа-/бетаверсии и т. д. Если вас это заинтересовало — рекомендую посмотреть Lerna.

  3. Заново построить SPA. Если у вас ранее был SPA, вы, скорее всего, хотите его вернуть, чтобы у пользователя не было перезагрузки страницы, одно состояние и тому подобные плюсы одностраничного приложения. Вам придется разработать обертку над своими приложениями, которая будет всем этим заниматься. Напомню, что у нас этим занимается Frame Manager, о котором в скором времени тоже расскажем. Если очень упрощать, то вам понадобится функциональность, которая будет сопоставлять массив продуктов с массивом путей и изменять состояние страницы в зависимости от пути. В качестве шины для передачи состояния можно придумать массу решений: window.myVar, localStorage, sessionStorage, CustomEvent, postMessage и так далее.

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

Тинькофф Бизнес

Мы в Тинькофф выбрали несколько вариантов.

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

Дальше были другие проекты, процесс оттачивался и ускорялся, но в какой-то момент мы поняли, что даже приложения, которые уже были вынесены, стали слишком большими и неплохо было бы их еще раз декомпозировать. Перед нами встал вопрос: «Делать это таким же образом, как раньше, или пересмотреть подход?» Почему напрашивался пересмотр:

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

  • У продуктовых команд выработалась ответственность за собственный код и им было привычнее оставаться в рамках этого репозитория.

  • Для продуктов уже был настроен ci pipeline, а заведение нового продукта требовало дополнительных манипуляций.

В итоге подход был пересмотрен на пилотном проекте: мы стали использовать Nx. Теперь части приложения выносились в apps/, а их пересекающийся код обобщался в libs/. Это позволило решить первые две потребности команд, но только усилило третью. В итоге ci pipeline был полностью переписан и теперь добавление к новому продукту pipeline занимает считаные минуты, так как мы придерживаемся подхода IaC.

tl; dr

От монолита в одном репозитории мы смасштабировались до десятков монорепозиториев с микрофронтендом, которым для клиента управляет Frame Manager.

Схема взаимодействия клиента с микрофронтендомСхема взаимодействия клиента с микрофронтендом

Советы

Вот мы и прошли весь тернистый путь от принятия решения до его реализации. Мы приняли недостатки каждого метода и получили преимущества от реализации, описанные выше. Пробежимся быстренько по tips&tricks, которые помогли нам в работе:

  1. Подумайте несколько раз, действительно ли вам будет полезен микрофронтенд? Пока мы общаемся с вами в рамках этой статьи, вы, скорее всего, еще не начали распил, а значит, есть время одуматься :)

  2. Если это ваш первый «распил», будьте готовы просидеть за его выполнением долгое время. Это может показаться сверхкапитанским советом, но лучше взять времени с запасом, чтобы не доставлять неудобства соседним командам, которые, скорее всего, тоже поменяют свои рабочие процессы на это время, чтобы при переносе ничего из нового не потерялось.

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

  4. Если ваша цель — декомпозировать проект более одного раза, то задокументируйте все процессы настройки, чтобы другая команда пошла уже по проторенной дороге, а не набивала шишки заново. В худшем случае вы можете получить несколько решений микрофронтенда в одной компании/продукте.

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

P. S.

Делитесь своими вариантами реализации микрофронтенда, подводными камнями, на которые наступили, плюсами, которые получили при реализации, или почему передумали и остались в монолите.

© Habrahabr.ru