Привет! Меня зовут Максим, я фронтенд-разработчик компании Тинькофф, лид команды фронтендов, которые пилят международные проекты. Я работал как фронтом, так и бэкером — это дало мне релевантный опыт и в микрофронтендах в том числе.

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

Зачем пилить монолит

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

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

Разберем пример. Возьмем условное приложение — любой городской портал. Там есть новости, продажа или аренда жилья и статистика по автомобилям.

Городской портал — классический монолит, SPA, который состоит из трех страниц. Каждую страницу поддерживает своя команда, каждую из этих страничек — три команды. Соответственно, тайлы у них одни
Городской портал — классический монолит, SPA, который состоит из трех страниц. Каждую страницу поддерживает своя команда, каждую из этих страничек — три команды. Соответственно, тайлы у них одни

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

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

  1. Собраться всеми командами, которые участвуют в разработке продукта.

  2. Определить, что, как и зачем будем пилить.

  3. Придумать схему нового решения.

  4. Внести правки в новое решение.

  5. Начать распил.

  6. Забыть про отказоустойчивость решения.

На выходе получится примерно такая схема: 

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

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

Можно оставить все как есть, если текущий UI всех устраивает. Главное — соблюдать основной постулат разработки: «Работает — не трогай». Но есть минус такого решения: неудобно тащить новую функциональность, разработчики будут страдать и начнется война за место в релизе. Чтобы все разрулить, появилась новая должность — Senior Conflict Manager. Человек, который будет собирать релизы.

Если текущий UI не подходит, будем распиливать его на микрофронты.

Что такое микрофронтенд

Название микрофронтенд появилось в 2016—2017 годах. Это некий постулат идей о том, как должно выглядеть приложение. Идей около 17, и на сайте Micro-Frontends.org они все расписаны.

Я выделил три важных аспекта микрофронта.

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

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

Выбор нативного API, который предоставляет фреймворк. Это могут быть нативные фишки браузера, нативные фишки фреймворка, и не нужно писать свои костыли. Если что-то не нравится, можно заявить в issue на GitHub в этот фреймворк либо попробовать закатить пул-реквест.

Я убедился опытным путем, что не стоит придумывать свои решения. Когда я придумывал свои решения, мне казалось, что они классные и отлично работают. Но мои фронтендеры потом в конце рабочего дня плакали.

Какие есть варианты распила

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

Микросервис все же не об этом. Он о том, что два приложения могут вместе работать и взаимодействовать между собой. А что, если сделать три приложения?

Источник: https://martinfowler.com/articles/micro-frontends.html 

Три приложения с базовыми пайплайнами. Каждое приложение собирается отдельно, гоняем на каждом тесты и деплоим. Процесс компоновки всего воедино уже не такой подробный: у одних это будет компоновка через Iframe, у других свои фреймворки и велосипеды или же гитовые сабмодули и сборка в единый бандл. Целостного решения нет
Источник: https://martinfowler.com/articles/micro-frontends.html Три приложения с базовыми пайплайнами. Каждое приложение собирается отдельно, гоняем на каждом тесты и деплоим. Процесс компоновки всего воедино уже не такой подробный: у одних это будет компоновка через Iframe, у других свои фреймворки и велосипеды или же гитовые сабмодули и сборка в единый бандл. Целостного решения нет

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

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

На выходе получается вроде бы монорепозиторий. Но нужен SPA. Решение нашлось в 2018 году, когда появился пятый Webpack. Помимо всяких ускорений работы он принес с собой Modul Federation.

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

plugins: [
     new ModuleFederationPlugin({
       remotes: {
         main: 'main@http://localhost:4201/remoteEntry.js',
         rent: 'rent@http://localhost:4202/remoteEntry.js',
         cars: 'cars@http://localhost:4203/remoteEntry.js',
       },
       shared: share({
         '@angular/core': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
         '@angular/common': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
         '@angular/common/http': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
         '@angular/router': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
         '@angular/forms': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
         ...sharedMappings.getDescriptors(),
       }),
     }),
     sharedMappings.getPlugin(),
   ],

Есть два приложения, в рамках MFE это называется Host и Remote. У host-приложения есть webpack-конфиг с плагином MFE, он принимает в себя список зависимых приложений и библиотеки, которые должны быть едиными для всех приложений.

Подробнее про Remotes

Блок Remotes — это webpack-конфиг хост-приложения, приложения-синхронизатора. В этом блоке определяются названия проектов, они уникальны и должны иметь свой префикс.

Указывается URL-адрес, откуда забирать. Shared-секция описывает, какие зависимости в package.json должны иметь строгую версию и должна ли эта зависимость являться single-тоном.


 
1 — название приложения, в которое надо загрузить; 2 — URL-адрес, откуда нужно загрузить; 3 — файл, который надо загрузить
1 — название приложения, в которое надо загрузить; 2 — URL-адрес, откуда нужно загрузить; 3 — файл, который надо загрузить

Блок Remotes позволяет разнести свои приложения по разным доменам. В плане при распиле монолита был пункт «забыть про отказоустойчивость». Про нее забывают всегда.

Такое нововведение, которое позволяет с хоста загружать свой файл, дает развернуть приложение на отдельном железе или отдельном деплойменте в кубе. Чтобы все приложения стали независимыми и имели разное количество памяти, ЦПУ.

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

В Angular есть файлик app.module.ts, там описаны компоненты, модули, зависимости приложения, декларируется роутинг и многое другое. В рамках remote-приложения на MFE сохраняется app.module.ts remote, но появляется новый файлик — remote-entry.module.ts. В нем описываются зависимости приложения, которые нужны в remote-режиме.

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

Но есть нюанс. Если в app-модуле нужно объявить root-зависимости, то во втором случае нужно определить child-зависимость. Они должны быть дочерними, потому что root-зависимости будут описаны в app-модуле нашего хост-приложения.


   plugins: [
       new ModuleFederationPlugin({
           name: "main",
           fileName: "remoteEntry.js",
           exposes : {
               './Module': 'apps/main/src/app/remote-entry/entry.module.ts'
           },
           shared: share({
               '@angular/core': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
               '@angular/common': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
               '@angular/common/http': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
               '@angular/router': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
               '@angular/forms': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
               ...sharedMappings.getDescriptors(),
           }),
       }),
       sharedMappings.getPlugin(),
     ],

Внутри webpack.config.js добавляем плагин MFE, в котором описаны базовые импорты, правила и многое другое.

Появилась секция name, где нужно указать название дочернего приложения, которое мы разрабатываем. А в секции exposes указываем ссылку на тот модуль, который будет упаковываться в файл remoteEntry.js.

Аналогично описывается shared-секция тех зависимостей, что должны быть синхронизированы. Мы синхронизируем Angular, можем синхронизировать любую библиотеку из NPM-скоупа и любую локальную библиотеку, если используем там монорепозиторий.

Подробнее про Host

Все описывается в app-модуле. Я декларирую, определяю рутовый роутинг, который будет базовым для всего и главным, определяю несколько путей, импортирую к себе лейзи-модуль. Этот модуль должен быть загружен не сразу, при старте приложения, а когда мы перейдем на условный путь. Для этого делаем ссылку на динамический модуль дочернего приложения.

 @NgModule({
       declarations: [AppComponent],
       imports: [
           BrowserModule,
           RouterModule.forRoot([
               {
                   path: 'main',
                   loadChildren: () => import('main/Module').then(m => m.RemoteEntryModule)
               },
               {
                   path: 'rent',
                   loadChildren: () => import('rent/Module').then(m => m.RemoteEntryModule)
               },
               {
                   path: 'cars',
                   loadChildren: () => import('cars/Module').then(m => m.RemoteEntryModule)
               }
           ])
       ],
       bootstrap: [AppComponent]
   })
   export class AppModule {}

Все, можно считаться микрофронтендером!

Дальше я напишу примерно такой UI, в котором будет хедер, а внутри хедера — ссылки, ведущие нас по приложению.

 <div>
       <h3>My city portal</h3>
       <header>
           <a [routerLink]="'/main'">Main</a>
           <a [routerLink]="'/rent'">Rent</a>
           <a [routerLink]="'/cars'">Cars</a>
       </header>
       <div>
           <router-outlet></router-outlet>
       </div>
   </div>

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

Дальше начинается магия — происходит анимация. Я хожу по нашему приложению, при нажатии на LoginPage у меня загружается файлик, который загружает в приложение LoginPage, при нажатии на Origination загружается приложение Origination. В приложении Origination есть кнопка Go To Lazy — это дочерний роутинг зависимого приложения. Ничего не перезагружается, все работает как классическое SPA, то есть фактически монолит, но им не является.

Так выглядит код в консоли
Так выглядит код в консоли

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

В браузере получаем remoteEntry.js, который начинает тащить те зависимости, что нужны ему для приложения. И если зайти на localhost.42, должно открыться приложение с уже описанным там роутингом
В браузере получаем remoteEntry.js, который начинает тащить те зависимости, что нужны ему для приложения. И если зайти на localhost.42, должно открыться приложение с уже описанным там роутингом

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

Работа из коробки с нативом и голым Webpack — там уже есть pluginFederation и можно делать микрофронты. Все популярные фреймворки имеют в комплекте MFE.

Выводы

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

Webpack выпуском MFE понизил планку входа в микрофронты. Если раньше нужно было писать загрузчик, понимать, как все работает на деле, то теперь все стало проще — плагин, документация к нему. Идешь по документации и делаешь, как там написано.

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

А в следующей статье расскажу, как мы съезжали на Modul Federation. Если есть вопросы — жду в комментариях.

Комментарии (20)


  1. andreyiq
    24.11.2022 16:55
    +1

    А как решается проблема со стилями, что будет если в разных модулях окажутся стили для одинаковых классов?


    1. FindYourDream
      24.11.2022 18:41

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


      1. andreyiq
        24.11.2022 18:47
        +1

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


        1. FindYourDream
          25.11.2022 10:03

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


          1. andreyiq
            25.11.2022 10:36

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


        1. Dozalex
          25.11.2022 10:05

          Css in js, например linaria, не используете до сих пор?


          1. FindYourDream
            25.11.2022 10:06

            Для ангуляра не видится смысла в данном действии. С появлением нормального standalone API в 15 версии, можно рассмотреть данное решение.


  1. imater
    24.11.2022 17:22

    1) А микрофронтенд может использовать другой микрофронтенд? Или всех их разруливает (роутит) ядро?
    2) Работает ли hot reload при разработке?
    3) Работает ли SSR? И видит ли node.js что микрофронтенд обновился при hot reload


    1. FindYourDream
      24.11.2022 18:48

      1. Да можно использовать, для этого внутри ремоута нужно определить роут и вызвать с помощью функции loadRemoteEntryModule().

      2. При использовании nx-workspace автоматом перезагружается хост, но появилось это относительно недавно, до этого приходилось ручками обновлять страницу в браузере. В остальном hot работает как надо

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


      1. icherniakov
        25.11.2022 13:34

        Спасибо за статью, как раз сейчас стоит задача перевести приложение с микрофронтами (angular-architects) на SSR. Отсюда просьба, дать какой-то совет или ссылку "что почитать", чтобы этот путь не был столь долог, опасен и не стал тупиковым) пожалуйста!


        1. markelov69
          25.11.2022 14:04

          puppeteer + cache, в фоне кэш страниц обновляется, а на запрос странички отдается сразу готовый HTML из кэша, работает супер быстро и никаких переделок на фронте не требует, после загрузки бандла просто данные зафечатся по новой которые при загрузки отображаюся и пользователь увидит их самыми актуальными, а для поисков пофиг что они могут быть на 5-10 минут "устаревшие", на SEO это не влияет


          1. icherniakov
            25.11.2022 14:32

            Оу, спасибо, изучу и этот вариант!


            1. mayorovp
              26.11.2022 08:19

              Осторожнее с этим вариантом.


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


              1. markelov69
                26.11.2022 11:59

                Нет. Это копейки. 1-2 экземпляра запущенных которые обновляют кэш и периодически перезапускаются дабы избежать возможных утечек памяти.
                Альтернативы 3:
                1) Нe использовать SSR.
                2) Использовать классические схемы типо PHP + jQuery и т.п.
                3) Превратить свой проект в говнокод и получить на ровном месте дополнительно связанные руки, ограничения, реальную нагрузку на сервер и использовать всяческие Next.js, Nuxt.js и прочую фигню.


  1. radtie
    24.11.2022 19:28

    Микрофронтенды решают много проблем, но и привносят достаточно новых.


    1. FindYourDream
      25.11.2022 10:04

      Как и любая другая технология в целом. Серебряных пуль не существует.


  1. Tzar4eg
    25.11.2022 10:04

    Я так понимаю используется несколько ангуляр приложений. Не возникает проблема с zone.js?


    1. FindYourDream
      25.11.2022 10:05

      Нет, проблем не возникает. На уровне конфига мы можем управлять зависимостями и синглтонить их. Поэтому все что касается ангуляра автоматом прописано как singlton: true


  1. Femistoklov
    25.11.2022 10:30

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

    Ловко. Возьму на вооружение.

    - Надо отрефакторить фронт.

    - Значит и бэк будем, а то что это он!


  1. Lonli-Lokli
    26.11.2022 22:41

    Насколько понимаю, нельзя сделать шеллом реакт приложение, а вот ангуляр с его роутингом привязывать как микрофронт? То есть решение исключительно для ангуляр команд?

    ПС почему Modul Federation? Вроде как Module Federation