Всем привет! Меня зовут Евгений Мальченко, я разработчик из QIWI, занимаюсь созданием внутренних сервисов. Совсем недавно мы провели эксперимент по использованию микрофронтендов, и я хочу поделиться с вами опытом использования. Это вторая часть, а первую можно посмотреть по ссылке.
Цели
В прошлой статье я рассказал о том, как работает Module Federation, и что нас не устроило. Исходя из этого мы поставили себе следующие цели:
Так как мы имеем несколько сред исполнения (testing, staging, production), то собранное приложение должно соответствовать принципу "build once - deploy everywhere". Фиксированные адреса до статики использовать нельзя, нужно более умное решение.
Мы хотим инициализировать приложение как можно быстрее, если микрофронтов много, и они вложены. Здесь нужно ускорение.
При локальной разработке должна быть возможность поднять приложение, использующее микрофронты без их запуска. Разработчик не должен знать, как нужно собирать другие микрофронты для работы.
Настройка проектов должна быть унифицирована.
Микрофронты должны быть наблюдаемы. Разработчик должен иметь возможность узнать состояние системы в целом: используемые версии, зависимости, а самое главное - связи между микрофронтами.
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:
разработчик должен иметь возможность запустить приложение без необходимости сборки вложенных микрофронтов. Микрофронты по умолчанию должны быть загружены из testing среды;
должна быть возможность изменить используемую версию любого микрофронта. Например, взять версию и прода или запущенную локально.
И мидлвар оказлось достаточно для такой реализации. Напомню, что у нас есть microfront-discovery
сервис, который отвечает за сопоставление имени микрофронта с его энтрипоинтом. С помощью мидлвар можно перехватить эти запросы и реализовать свою логику. Мы отводим специальный URL localhost:3000/__dev-manager
для отображения UI, в котором можно посмотреть какие микрофронты подключены, откуда они берутся и логи того как резолвился микрофронт.
Вдохновение черпали из исходников webpack-dev-server, который по похожей логике добавляет путь localhost:3000/webpack-dev-server
для отображения своей статистики. Если захотите реалзиовать что-то похожее, то можно посмотреть исходник.
Теоретически существует возможность даже уже в задеплоенном приложении через расширение хрома менять версии, чтобы подтянуть свою локальную версию прямо в прод/тестинг. Мы проверили этот способ, он работает за счет того что микрофронты хранятся в window
и можно их подменить до начала работы кода загрузчика, но у нас не было особой потребности использовать это, поэтому дальше идеи не пошло.
Итог
В итоге вся эта система не смотря на кажущуюся сложность на самом деле очень простая:
все нужные плагины инкапсулированы в один
MicrofrontendPlugin
- легко обновляться и подключать;генерация манифестов - ключевой этап, который позволяет анализировать билды и выполнять релизы;
два микросервиса с простой CRUD логикой достаточно для умного дискавери микрофронтов;
локальная разработка такая же простая, как и в SPA. Все автоматически собирается без танцев, а если нужна гибкость, то мидлвары позволяют сделать что угодно.
Но существует гораздо более серьезная проблема микрофронтов, о которой я не упоминал, но она на поверхности - как заставить несколько полноценных SPA приложений работать в едином DOM'е и при этом не мешать друг друга? Как обеспечить коммуникацию между ними? О нашим подходах в решении этих проблему расскажу уже в третьей, финальной статье.