Всем привет! Меня зовут Евгений Мальченко, я разработчик из QIWI, занимаюсь созданием внутренних сервисов. Совсем недавно мы провели эксперимент по использованию микрофронтендов, и я хочу поделиться с вами опытом использования. Это вторая часть, а первую можно посмотреть по ссылке.

Цели

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

  1. Так как мы имеем несколько сред исполнения (testing, staging, production), то собранное приложение должно соответствовать принципу "build once - deploy everywhere". Фиксированные адреса до статики использовать нельзя, нужно более умное решение.

  2. Мы хотим инициализировать приложение как можно быстрее, если микрофронтов много, и они вложены. Здесь нужно ускорение.

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

  4. Настройка проектов должна быть унифицирована.

  5. Микрофронты должны быть наблюдаемы. Разработчик должен иметь возможность узнать состояние системы в целом: используемые версии, зависимости, а самое главное - связи между микрофронтами.

ModuleFederationManifestPlugin

Первое, что мы сделали - написали плагин, который бы позволил нам собрать информацию из билда: какие exposed, shared, remotes есть. И даже выложили в опенсорс. Плагин генерирует манифест, который содержит всю необходимую информацию о Module Federation. Пример такого манифеста ниже.

{
    name: 'container',
    publicPath: 'auto',
    exposes: {
      a: {
        name: 'custom-name',
      },
      b: {},
      c2: {},
    },
    remotes: {
      remote1: {
        modules: ['app'],
      },
      remote2: {
        modules: ['app', 'helpers'],
      },
    },
    provides: {
      react: [
        { version: '1.0.0', shareScope: 'default' },
        { version: '1.2.0', shareScope: 'default' },
      ],
    },
    consumes: {
      react: [
        { version: '^1.0.0', shareScope: 'default', singleton: false, strictVersion: true, eager: false },
        { version: '^1.2.0', shareScope: 'default', singleton: false, strictVersion: true, eager: false },
      ],
    },
  })
}

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

RuntimeConfigPlugin

Принцип конфигурации из Twelve-Factor App говорит, что нужно хранить конфигурацию в среде выполнения.

Если применить этот принцип на фронтенды, то как пример - между средами меняется URL до API. Но проблема в том, что когда мы собрали с помощью webpack приложение, мы уже не можем просто так передать туда переменные окружения, как это происходит с Docker образами. Также это противоречит build once - deploy everywhere.

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

Мы решили поступить следующим образом. В корне проекта будет лежать конфиг приложения в файле runtime.config.json как на примере ниже:

{
  "local": {
    "API_URL": "google",
  },
  "testing": {
    "API_URL": "yandex"
  },
  "production": {
    "API_URL": "yahoo"
  },
}

При этом плагин разрешает делать специальный импорт:

import config from '#config';

console.log(config.API_URL);

Сам модуль #config под капотом заменится на запрос к бэкенду, который отдаст нужный конфиг для этого приложения. Упрощенный код полученного модуля ниже (на самом деле очень просто). Здесь appName - имя текущего приложения, откуда оно берется будет описано дальше.

const config = () => {
    return fetch('https://microfront-discovery.example.com/' + appName + '/config')
}

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

MicrofrontendWebpackPlugin

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

Интерфейс плагина ниже:

{
  /** Имя приложения, уникально для нашей компании *//
  appName: string

  /** Версия. Уникально для приложения */
  appVersion: string

  /** Список shared модулей, аналогично ModuleFederationPlugin */
  shared?: ModuleFederationPluginOptions['shared']

  /** Список ремоутов массивом строк */
  remotes?: string[]

  /** exposed модули. Аналогично ModuleFederationPlugin*/
  exposes?: ModuleFederationPluginOptions['exposes']
}

Здесь обязательно нужно указать appName и appVersion. appName чаще всего строго прописывается в репозитории, а appVersion берется из CI/CD системы. У разных команд подходы отличаются, но самый базовый пример - хеш коммита, на котором собран билд.

MicrofrontendWebpackPlugin на основе эти параметров делегирует отвественность другим плагинами, в числе которых: ModuleFederationPlugin, RuntimeConfigPlugin, MicrofrontendLoadingRuntimeModule, AssetsManifestPlugin. Все это спрятано от пользователя, для него существует один конфиг, позволяющий использовать микрофронты в нашей инфраструктуре

Отличие от ModuleFederation - список remotes передается массивом строк, мы не указываем откуда мы берем и каким образом загружаем приложения. Мы просто указываем их appName.

MicrofrontendLoadingRuntimeModule

MicrofrontendWebpackPlugin принимает массив remotes. При этом из базового примера помним, что remotes в Module Fedeation - массив объектов, в котором мы указываем адреса откуда нужно загрузить энтрипоинт.

Module Federation позволяет кастомизировать загрузку ремоутов через promise based dynamic remotes. Пример из документации:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // This part depends on how you plan on hosting and versioning your federated modules
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (arg) => {
            try {
              return window.app1.init(arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // inject this script with the src set to the versioned remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};

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

Здесь есть несколько минусов:

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

  • как было показано в базовом примере, инициализация ремоутов выполняется последовательно, будет отправлено N запросов для N микрофронтов.

Чтобы решить эти две проблемы, мы написали специальный модуль - MicrofrontendLoadingRuntimeModule, который заранее собирает список ремоутов в одном хосте и загружает их одним запросом.

Здесь кроется небольшой хак. Инициализация ремоутов в сборке вебпака выглядит так:

initExternal(1);
initExternal(2);
initExternal(3);

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

Пример код загрузчика:

__webpack_require__.qw_load_mf = (remoteName) => new Promise((resolveModule, rejectModule) => {
    // Сразу резолвим модуль, если уже загрузили
    if (windowremoteName]) return resolveModule(window[getGlobalAppName(remoteName)]);
    
    // Лоадеры храним в window, чтобы если несколько микрофронтов захотели загрузить одно и то же, то запросы не дублировались
    const globalLoaders = window.loaders = window.loaders || {};
    
    // Стэк того, что мы еще не начали грузить, но собираемся
    const stack = window.stack = window.stack || [];
    stack.push(remoteName);,
        
    // Хак - переносимся на следующий тик
    // Предыдущий код был выполнен для каждого 
    Promise.resolve().then(() => {
        // Чистим стэк и запоминаем что нужно грузить
        // stack = ['microfront-1', 'microfront-2']
        const stackCopy = [...window.stack];
        window.stack = [];
        
        // Сильное упрощение
        // names=microfront-1, microfront-2
        // fetchScript загружает динамически собранный бандл из microfront-1.entry.js и microfront-2.entry.js
        fetchScript('microfront-discovery/entry?names=' + stackCopy.join(','))
            .then(() => resolveModule(entryName))
    
    })
})

Модуль объявляется точно также как runtime modules webpack.

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

Если бы использовали этот модуль вместе с голым ModuleFederation, то настройка выглядела так:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise __webpack_require__.qw_load_mf('remote')`,
      },
    }),
  ],
};

Так как ModuleFederationPlugun спрятан внутри MicrofrontendPlugin, то подключение микрофронта еще сильнее упрощается:

module.exports = {
  plugins: [
    new MicrofrontendPlugin({
      appName: 'host',
      appVersion: '1.0.0',
      remotes: ['remote'],
    }),
  ],
};

Мы получаем декларативность, разработчик не знает, как именно remote загружается. Для него достаточно знать имя микрофронта. Также этот подход позволяет легко обновляться, мы меняли код загрузчика 3 раза абсолютно безболезненно.

При сборке генерируется следующий код:

{
  9538: ((module, __unused_webpack_exports, __webpack_require__) => {
    module.exports = __webpack_require__.qw_load_mf('one');
  }),

  5195:
  ((module, __unused_webpack_exports, __webpack_require__) => {
    module.exports = __webpack_require__.qw_load_mf('two');
  }),

  428:
  ((module, __unused_webpack_exports, __webpack_require__) => {
    module.exports = __webpack_require__.qw_load_mf('three');
  }),

  7808:
  ((module, __unused_webpack_exports, __webpack_require__) => {
    module.exports = __webpack_require__.qw_load_mf('four');
  }),

  1153:
  ((module, __unused_webpack_exports, __webpack_require__) => {
    module.exports = __webpack_require__.qw_load_mf('five');
  })
}

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

Бэкенд

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

Microfront API

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

  • /build/:version - регистрация нового билда. При сборе приложения выполняется загрузка статики в S3, а также отправка манифестов по этому эндпоинту. После этого этот билд можно посмотреть в UI интерфейса, а также задеплоить его в какую-либо из сред.

  • /deploy/:appName/:version/:env - деплой определенной версии в указанную среду.

  • Несколько эндпоинтов, которые отдают манифесты и релизные версии.

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

Дашборд микрофронтов
Дашборд микрофронтов

На дашборде видны связи между микрофронтам, какие exposed модули используются, какие версии shared модулей нужны.

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

Microfront Discovery

microfront-api управляет релизным циклом и observability. При этом за непосредственно сборку бандла, который попадет на фронт при запросе отвечает microfront-discovery.

У сервиса две функции:

  • по имени микрофронта получить его энтрипоинт;

  • по имени микрофронта получить его конфиг;

  • в случае нескольких имен - смерджить результат и отдать один бандл.

Он ходит в ту же саму базу, в которую пишет microfront-api. На двух схемах ниже показано работа этих сервисов.

Процесс билда и регистрации
Процесс билда и регистрации
Процесс деплоя
Процесс деплоя

Локальная разработка

В стандартном ModuleFederation, если микрофронт А использует микрофронт Б, нам необходимо вручную менять конфиги для загрузки разных версий. Это не проблема для базового примера, но с ростом количества микрофронтов поддержка усложняется.a

Dev Manager

Для решения проблемы локальной разработке мы решили использовать кастомные middlewares. webpack-dev-server под капотом использует express и позволяет подключать свои мидлвары.

Как мы представляли такой dev-manager:

  1. разработчик должен иметь возможность запустить приложение без необходимости сборки вложенных микрофронтов. Микрофронты по умолчанию должны быть загружены из testing среды;

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

И мидлвар оказлось достаточно для такой реализации. Напомню, что у нас есть microfront-discovery сервис, который отвечает за сопоставление имени микрофронта с его энтрипоинтом. С помощью мидлвар можно перехватить эти запросы и реализовать свою логику. Мы отводим специальный URL localhost:3000/__dev-manager для отображения UI, в котором можно посмотреть какие микрофронты подключены, откуда они берутся и логи того как резолвился микрофронт.

Вдохновение черпали из исходников webpack-dev-server, который по похожей логике добавляет путь localhost:3000/webpack-dev-server для отображения своей статистики. Если захотите реалзиовать что-то похожее, то можно посмотреть исходник.

Dev Manager
Dev Manager

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

Итог

В итоге вся эта система не смотря на кажущуюся сложность на самом деле очень простая:

  1. все нужные плагины инкапсулированы в один MicrofrontendPlugin - легко обновляться и подключать;

  2. генерация манифестов - ключевой этап, который позволяет анализировать билды и выполнять релизы;

  3. два микросервиса с простой CRUD логикой достаточно для умного дискавери микрофронтов;

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

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

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


  1. dagot32167
    20.05.2023 06:37

    Спасибо за статью. У нас тоже микрофронты, но на своей реализации. Вот только недавно пришли к выводу, что service-discovery (как у вас microfront-discovery) это единая точка отказа для всей системы. У нас он statefull потому с репликацией там ну такое. Это привело к тому, что за счёт высокой производительности, простого api не требующего обслуживания и изменения, а так же за счёт кажущейся стабильности и надёжности он у нас не обновлялся. В итоге инфра при обновлении версии helm заставила нас перерелизить SD. Было очень потно. Более 50 микрофронтов ссылались на него,а в одной из прошлых версий нашего фреймворка микрофронтов прилага падала, когда теряла связь с SD. И даже заранее проверив версии было страшновато. В итоге решили, что лучше конфиги разбитые на локал, дев и прод. Т.е. микрофронты не меняют свое место публикации, убираем лишний сервис с обслуживания, освобождаем ресурсы на его работу, фреймворк микрофронтов на основе используемых паттернов api может сам сапоставить куда стучать за ремоутами, состав фронта по ремоутам не меняется внезапно, без релиза, только версионность потому возможно конфиг проще в поддержке


    1. Nijo Автор
      20.05.2023 06:37

      Да, сейчас тоже понимаем, что microfront-discovery - единая точка отказа. На данный момент не выбрали, что будем использовать в качестве альтернативы.

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

      Именно по этой причине у нас разделены microfront-api и microfront-discovery.

      Кажется, тут вариант один - каким-либо образом клиент сам должен уметь резолвить пути:

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

      2) Использовать k8s (примерно так). Все необходимые ресуры де-факто хранятся в s3 хранилище, можно навесить service (externalName) + ingress и выполнять релизы через кубер. В таком случае discovery заменяется кубером причем для каждого микрофронта отдельно. Если сюда добавить custom resource definition, чтобы скрыть эту логику с сервисом и ингрессом, то получается довольно декларативно. Похожий подход был рассказан в докладе на Holy JS 2020



  1. skeevy
    20.05.2023 06:37

    Немного не ясен момент - у вас все-таки динамическая федерация или синхронная на промисах?

    Идея с плагином для манифестов интересная, но я в своих проектах держал отдельно с каждым фронтом json, который его описывает. В последних итерациях пришли сбору всех и мержингу в одну большую (относительно, для прода минифицируется и чистится от dev переменных) структуру, которая запрашивается шеллом в рантайме + содержит переменные окружения (динамическая федерация).
    В крайнем проекте, все это упаковано в nx и генерируется его инструментами


    1. Nijo Автор
      20.05.2023 06:37

      Не понял, что значит "динамическая федерация", "синхронная на промисах". Можете раскрыть, что подразумеваете под этим?

      > В последних итерациях пришли сбору всех и мержингу в одну большую структуру

      А что именно содержится в этом JSON? Если там по аналоги с нашим манифестом инфа о shared, то как вы обеспечиваете актуальность версий, например, в package.json одно, а в yarn.lock другое?


      1. skeevy
        20.05.2023 06:37

        Можете раскрыть, что подразумеваете под этим?

        Синхронная федерация - это когда адрес прописывается в remotes. Динамическая - когда в рантайме дергается get/init вебпака (примерно, как тут)

        А что именно содержится в этом JSON?

        Переменные окружения, адрес ремоута, filename, exposes, некоторая бизнес-информация

        Если там по аналоги с нашим манифестом инфа о shared, то как вы обеспечиваете актуальность версий

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