Микрофронтенды. Стабильная интеграция нескольких SPA-приложений. Часть 2

от автора

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

Делимся веб-компонентами

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

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

  2. Сделать статический JSON-файл, аналогичный нашему метафайлу. Клиент будет получать этот JSON и информацию о веб-элементах приложения. Минус — это статический файл, и если нам понадобится его модифицировать в рантайме, с этим могут возникнуть проблемы. 

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

Таким образом мы формируем библиотеку widget registry, в которую разные микрофронтенды запишут информацию о себе, а затем наша платформа или кто-то иной смогут прочитать эту конфигурацию и получить нужные данные. Это помогает нам ощутимо снижать связанность — у нас появляется единая точка взаимодействия между приложениями, и если, скажем, завтра один из продуктов решит удалить какой-то виджет или свой веб-элемент, мы просто получим знание об этом через обновлённый реестр. В итоге повышается общая стабильность.

Lazy Load и чем он хорош

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

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

А вот уже большой и тяжёлый контейнер с приложением мы будем догружать по требованию, то есть только тогда, когда пользователь заходит на страницу. Все инструменты для этого у нас уже есть — router, element container, load remote. 

Мы формируем ещё одну синглтон-библиотеку, menu registry, аналогичную нашим реестрам. 

Различные микрофронтенды запишут туда информацию о себе — нам особенны URL-сегмент и веб-компонент, который они будут обслуживать. Платформа прочитает данные, сформирует динамический роутер, что произойдёт дальше?

Пользователь перейдёт в раздел «Мониторинг», мы проверим, есть у нас в реестре кто-то, кто регистрировался на этот сегмент, проверим тег, затем отрендерим пользователю элемент контейнера и передадим ему всю нужную информацию. А далее по привычной логике:

  • выполнится функция load remote и загрузит контейнер;

  • контейнер инициализирует и запустит веб-приложение;

  • веб-приложение дефайнит и определяет для нас веб-элемент;

  • этот веб-элемент можно смело монтировать на страницу.

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

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

В свою очередь приложение мониторинга выполняет свою собственную навигацию или пишет какие-то URL-параметры, и всё это происходит внутри роутера приложения «Мониторинг». Как только мы уходим в какой-либо другой раздел, мы теряем эту информацию, и у нас разрушится состояние (браузерная навигация «Вперёд–назад» тоже ничего не знает). Поэтому микрофронтенды при каждом изменении своего роутера запускают синк — говорят друг другу, мол, ребята, мы я тут поменял кое-чего. Приложения вокруг просто слушают эти события, URL выступает как источник истины, чтение которого и позволяет им обновить состояние своего роутера.

Такие отношения мы можем настроить через классические dispatchEvent-ы. Я писал выше, что мы можем описывать, например, события жизненного цикла у веб-компонента. Мы же движемся в сторону синглтон-библиотек, там есть хорошие точка для расширения (и sync, и mount, можно добавлять новые ручки, например, navigate для перехода из одного микрофронтенда в другой). 

— Оптимизируй это

Мы обсудили веб-компоненты и приложения. Осталось поговорить о шаринге и о том, как всё это дело теперь оптимизировать.

У нас есть библиотеки widget и menu, они выступают в нашем случае в роли реестров. Нам нужна лишь одна копия, так что принудительно помечаем их как singletone:true.

new ModuleFederationPlugin({shared: {@platform/widget-registry: { requiredVersion: '>=1.0.0', version: '1.0.0',singleton: true },@platform/menu-registry: { requiredVersion: '>=1.0.0', version: "1.0.0', singleton: true },@angular/*: { requiredVersion: "19.2.0', version: "19.2.0' }@platform/buttons: { requiredVersion: '2.0.0', version: '2.0.0' } - ???}

Что по фреймворкам, в частности, Angular. Само собой, жёстко навязать кому-то что-то мы не можем, зато можем объявить, что «Если ваши версии Angular строго совпадают, то одно и то же приложение может переиспользовать Angular без проблем». Однако отдельно внимания тут требуют библиотеки, написанные поверх наших фреймворков. Например, библиотека platform/buttons, одна из библиотек нашего UI-кита, написанных поверх Angular. Может показаться, что раз у неё версия 2.0 совпадает с приложением, то мы можем её пошарить.

Нет, не можем. Внимание на картинку.

Разные версии Angular = разные компиляторы. Если мы возьмём одну и ту же версию кнопок, то разные приложения её скомпилируют по-разному, на выходе получатся разные артефакты. И раздавать такую библиотеку нельзя. Но если очень хочется, есть решение.

Мы знаем, что главное, что нужно библиотеке buttons для работы — это версия Angular (её основной peerDeps), значит, наше приложение «Мониторинг» и есть тот поставщик фреймворка, который скомпилирует и принесёт эту библиотеку. 

Можем также написать вспомогательную утилитку — озвучим ей наши хотелки:

  • мы хотим пошарить библиотеку platform/buttons;

  • её peerDeps — это Angular;

  • так что надо сходить в JSON приложения «Мониторинг»;

  • найти там версию библиотеку Angular;

  • на её основе создать синтетическую версию для библиотеки.

И мы на выходе получим не просо библиотеку 2.0, а ещё и упоминание с постфиксом, что это библиотека 2.0, которая идёт с Angular 19.2.0.

То есть теперь мы можем взять и пошарить библиотеку кнопок, но лишь при условии, что у этих библиотек совпадает версия Angular (читай — что их скомпилировал один и тот же компилятор). Этот нюанс наиболее чувствителен с приложениями, которые как-то зашивают внутренние инструкции. Но технически можно поймать проблемы и с обычными библиотеками — если мы возьмём одну и ту же библиотеку, и разные приложения будут приносить какие-то разные версии ключевых peerDeps, то это может создавать конфликты. 

Так что помните главное — если у библиотеки есть определённый важный для её работы peerDeps, то и он должен совпадать и шариться между приложениями.

Изоляция библиотек

Отмечу ещё библиотеки, которые модифицируют Window (когда мы расширяем наши глобальные объекты, а разные библиотеки расширяют сигнатуры по-разному). 

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

Когда речь заходит про такой архитектурный подход, в котором нам нужны мультиверсии, очень часто можно встретить своеобразных антипаттерн: «Упоминания — это dependency hell, никогда так не делайте, работать невозможно». И в целом-то да, если у вас огромное количество библиотек, то они будут конфликтовать. Представьте — зоопарк библиотек, вы уже сами не совсем понимаете, как (и почему) оно вообще всё работает, да, это dependency hell и вообще мало приятного. А вот если у вас есть хорошее понимание того, как всё работает — проблем не будет.

Немного выводов про синглтон-библиотеки.

  1. Пишите их как фреймворк-агностики (или чистый TypeScript, или чистый JS). 

  2. Помните, что нам нельзя раскрывать реализацию. Например, когда мы пишем widget registry, мы не можем взять какой-то публичный для всех метод. Решите в будущем его обновить — сигнатура поменяется, приложения в проде об этом знать не будут, всё сломается. API должен быть стабильным: вы открыты для расширений, но закрыты для модификаций.

Дизайн-система и консистентность UI

С одной стороны, наши приложения независимы и продукты вольны писать свои собственные кастомные компоненты. С другой — нам необходимо создать единый пользовательский опыт. Мы движемся в сторону единой дизайн-системы, сама платформа несёт глобальный набор CSS-переменных, а все приложениями ими пользуются. Но тут, как и с синглтон-библиотеками, надо помнить про обратную совместимость и тщательно контролировать любые расхождения. Ведь если просто так начать редактировать или удалять эти CSS, в проде всегда найдётся продукт, который их прямо сейчас использует. И который не будет рад вашим действиям.

Немного рекомендаций

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

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

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

Сложности. Описанное в статье разнообразие микрофронтендов увеличит сложность отладки. К вам придёт разработчик и скажет — клёво, великолепные интерфейсы, отличная документация, только там не работает ничего. И ему нельзя будет просто ответить в формате «У нас там 8 таких продуктов, и у всех всё работает, а у тебя нет», надо будет пойти и посмотреть его код. А там, например, не ваш стек. И всё. Причём ладно бы только стек — проблемы вызовут даже расхождения по табуляции и линтерам.

Интеграционные баги — самый неочевидный подводный камень. Вроде бы приложения независимые, живут себе и развиваются, но нам тут интересен класс ошибок, при котором мы всё соединяем в единую архитектуру. Такие ошибки мы называем «протечками» — протекает реализация, у кого-то протек Angular, протекли какие-то библиотеки и подобное.

Расходы на загрузку. Не очень умелыми руками можно собрать очень неповоротливый и большой бандл. Да, мы будем грузить несколько фреймворков и несколько версий Angular, но наша цель — не в том, чтобы держать 10 приложения, у каждого из которых свой Angular, а в том, чтобы не блокировать поставки функционала нашим клиентам.  Мы говорим всем — устанавливайте, обновляйте, несите ваши фичи, а с техдолгом разберётесь, когда будет удобно и когда на это будет время 

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

Что в итоге

Такой архитектурный подход даёт нам на самом деле независимые релизы — команды, продукты, платформа почти не зависят друг от друга. Есть единые точки с общими контрактами, через которые система масштабируется. 

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

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

И мы готовы к новым.

ссылка на оригинал статьи https://habr.com/ru/articles/1053978/