Микрофронтенды на Module Federation — наш опыт. Часть 1

Всем привет! Меня зовут Евгений Мальченко, я разработчик из QIWI, занимаюсь созданием внутренних сервисов. Совсем недавно мы провели эксперимент по использованию микрофронтендов, и я хочу поделиться с вами опытом использования. В качестве основы для построения системы мы выбрали фичу Webpack — Module Federation, рассмотрение преимуществ и недостатков других подходов (SingleSPA, iframe, etc) останется за рамками этой статьи.

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

Предыстория

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

Ничего не напоминает? Микросервисы! Когда речь заходит о микросервисах сразу, выделяют несколько плюсов их использования (минусы опустим):

  • разграничение зон отвественности;

  • свобода выбора технологий, подходов к разработке;

  • независимый релизный цикл.

В свою очередь, микрофронтенды — это попытка перенести микросервисный подход на фронтенд.

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

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

Если пойти дальше и расширить эти виджеты до полноценных SPA приложений, то виджетом уже не назовешь, а вот микрофронтенд — самое подходящее название.

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

При этом, так как это фронтенд, тут есть свои особенности:

  • все запускается на одной странице: единый DOM, event loop, CSSOM, нет настоящей изоляции;

  • время загрузки страницы критично — дублирование ресурсов недопустимо.

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

Webpack Module Federation

Module Federation — решение в виде плагина, представленное в Webpack 5, которое по сути является оркестратором нескольких сборок и позволяет в рантайме их соединять.

Основные плюсы:

  • все тот же вебпак, без дополнительных пакетов;

  • не зависит от используемых библиотек и фреймворков;

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

Минусы:

  • добавляет сложность, так как не каждый разработчик знаком с тем как он работает;

  • несмотря на возможность переиспользования модулей, все равно проявляется дублирование ресурсов.

Терминология

Module Federation вводит несколько терминов, для понимания достаточно знать о нескольких из них:

  • host — сборка, которая будет подгружать другие;

  • remote — сборка, которая будет загружена в host;

  • exposed modules — модули, которые экспортируются из remote. Именно эти модули могут быть импортированы в host;

  • shared modules — модули, которые могут быть предоставлены/переиспользованы.

В простом случае мы имеем один host, который подключает в себя remote, но при этом Module Federation позволяет реализовать более сложные сценарии. Мы можем получить дерево микрофронтов, когда один микрофронт вложен в другой. Если у нас получается дерево, то удобно корневой хост называть shell — он является входной точкой, именно с него начнется загрузка всего остального.

Sheel, Host, Remote

Sheel, Host, Remote

Host

// webpack.config.js
new ModuleFederationPlugin({
    // Указывем имя этого модуля, должно быть уникально
    name: 'host',
    // Список подключенных ремоутов
    remotes: {
      // Указываем, что загружать remote нужно из localhost:3002/remoteEntry.js
      remote: 'remote@//localhost:3002/remoteEntry.js',
    },
    // Список зависимостей, которые предоставит хост
    shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

Синтаксис key: 'remote@//localhost:3002/remoteEntry.js' говорит:

  • key — через import('key') можно импортировать ремоут;

  • remote — ожидается, что в будет глобальная переменная remote, где будет храниться этот ремоут;

  • //localhost:3002/remoteEntry.js — с этого адреса будет выполнена загрузка.

Также важно изменить способ инициализации приложение. Если раньше во входной точке index.js у нас был примерно следующий код.

/// index.js
import React from 'react';
import App from './App'

ReactDOM.render(, document.getElementById('root'));

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

/// index.js
import('./bootstrap.js')
/// index.js
import React from 'react';
import App from './App'

// Импортируем модуль Button из ремоута remote
const RemoteButton = React.lazy(() => import('remote/Button'));

// Для удобства отдельная функция
const AppWithRemote = () => (
  <>
    
    
      
    
  
);

ReactDOM.render(, document.getElementById('root'));

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

Другое решение состоит в том, чтобы указать опцию eager: true в shared, тогда реакт всегда будет присутствовать во входной точке.

{
  shared: { 
    react: { singleton: true, eager: true }, 
    "react-dom": { singleton: true, eager: true } 
   }
}

Динамический импорт приводит к тому, что в бандле создается дополнительный чанк и время загрузки приложения немного увеличивается, так как в изначальном чанке почти ничего не содержится и нужен еще один раунд-трип за статикой. При этом это не означает, что в index.js ничего нельзя делать. Здесь же сразу можно начать загрузку ресурсов: css, шрифтов, инициализация каких-то метрик. Главное не использовать шаренные модули, и нужно быть осторожным, так как они могут потребоваться одному импортированных модулей.

// index.js

// Так можно
import initMetrics from 'super-tiny-metrics';
import './reset.css';
import 'my-font-package'

initMetrics().then(() => import('bootstrap'));

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

Remote

Для ремоута аналогично нужно настроить плагин.

new ModuleFederationPlugin({
  name: 'remote',
  library: { type: 'var', name: 'remote' },
  filename: 'remoteEntry.js',
  exposes: {
    // указываем экспорты
    // Здесь модуль ./src/button будет доступен для остальных как app2/Button
    './Button': './src/Button',
  },
  // Также указываем список модулей, которые будут пошарены, вебпак сам в рантайме выберет подходящую доступную версии
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

Здесь есть два важных параметра:

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

library — в каком виде будет представлены данные этого ремоута (помним про единый DOM и глобальное пространство). Здесь мы указываем, что микрофронт будет записан в глобальную переменную remote. Ниже представлен код, который сгенерировал вебпак (детали опущены).

/******/ 	// module cache are used so entry inlining is disabled
/******/ 	// startup
/******/ 	// Load entry module and return exports
/******/ 	var __webpack_exports__ = __webpack_require__(677);
/******/ 	var remote = __webpack_exports__;

Смотрим, что собрал вебпаке

Если хотите, сами попробовать различные примеры, то рекомендую посмотреть репозиторий. Далее я буду ссылаться на basic-host-remote с отключенной минификацией.

Host

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

└── src/
    └── dist/
        ├── main.js
        ├── index.html
        ├── 378.js
        ├── 542.js
        ├── 575.js
        └── 744.js

main.js, несколько чанков — вроде бы всего как обычно.

Начнем с main.js. В нем содержится webpack runtime, так как мы не указывали, что его нужно выносить отдельно, а также загрузка ремоута. Вынести рантайм в отдельный чанк с помощью optimization.runtimeChunk не получится — issue висит с сентября 2022 года без активности, это был первый баг/фича на который мы наткнулись.

// main.js
/***/ 413:
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {

"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
	if(typeof app2 !== "undefined") return resolve();
	__webpack_require__.l("//localhost:3002/remoteEntry.js", (event) => {
		if(typeof app2 !== "undefined") return resolve();
		var errorType = event && (event.type === 'load' ? 'missing' : event.type);
		var realSrc = event && event.target && event.target.src;
		__webpack_error__.message = 'Loading script failed.\n(' + errorType + ': ' + realSrc + ')';
		__webpack_error__.name = 'ScriptExternalLoadError';
		__webpack_error__.type = errorType;
		__webpack_error__.request = realSrc;
		reject(__webpack_error__);
	}, "app2");
}).then(() => (app2));

Выше приведен код, который сгенерировался для загрузки нашего ремоута. Здесь можно отметить несколько деталей:

_webpack_require.l — функция для загрузки JS через тег script

  • app2 не загружается повторно, если он уже есть в глобальном пространстве;

  • загрузка выполняется через тег script.

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

Идем дальше и попробуем найти где используется модуль 413.

/* webpack/runtime/sharing */
(() => {
	__webpack_require__.S = {};
	__webpack_require__.I = (name, initScope) => {
		var initExternal = (id) => {}
		var promises = [];
		switch(name) {
			case "default": {
				register("react-dom", "16.14.0", () => (Promise.all([__webpack_require__.e(542), __webpack_require__.e(444)]).then(() => (() => (__webpack_require__(542))))));
				register("react", "16.14.0", () => (__webpack_require__.e(378).then(() => (() => (__webpack_require__(378))))));
				initExternal(413);
			}
			break;
		}
		if(!promises.length) return initPromises[name] = 1;
		return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
	};
})();

Я убрал лишние детали, чтобы они не отвлекали. Самое важное, что у нас есть функция _webpack_require_.I, которая делает две вещи:

  • регистрация списка шаренных модулей;

  • инициализация externals (которым по сути является наш ремоут под капотом)

ShareRuntime функция является IIFE, что означает независимо от того подгружаем ли мы микрофронт или нет, его entry (remoteEntry.js) загружается всегда, что откладывает загрузку остальных частей приложения. Это проблема, так как если по какой-то причине загрузка зависает, то приложение зависает тоже.

При этом, при регистрации react-dom, react, если соотнести ID модулей и чанки на файловой системе, то видно, что shared modules вынесены отдельно. Из-за этого нет возможности для tree-shaking, что логично, так как мы не знаем кому и какие части модуля будут нужны.

Remote

Также начнем с полученных файлов.

└── src/
    └── dist/
        ├── main.js
        ├── index.html
        ├── 378.js
        ├── 542.js
        ├── 744.js
        ├── 752.js
        ├── 940.js
        └── remoteEntry.js

Первое, что хочется отметить — совпадения в именах. 378.js, 542.js представлены в обоих сборках. Это не имеет отношения к Module Federation, это совсем другая фича Webpack 5 — determenitic ids. Зато сразу можно сказать, что shared модули выносятся в отдельные чанки без tree shaking.

Начнем с main.js. Я даже не буду приводить код, так как это обычный энтрипоинт билда. Здесь нет абсолютно никаких упоминаний Module Federation. Вебпак собирает exposed модули как отдельные энтрипоинты приложения, поэтому наш src/index.js генерирует main.js, который мы подключаем как обычное приложение, а src/Button создает новый независимый энтрипоинт. Можно убедиться в исходниках.

Самое интересное — remoteEntry.js. Это энтрипоинт в наш микрофронт, здесь содержится только вебпак рантайм, аналогичных main.js из хоста. Здесь есть:

  • инициализация списка shared зависимостей;

  • инициализация ремоутов этой сборки (вебпак позволяет делать вложенные микрофронты).

Приведу лишь часть кода:

var moduleMap = {
	"./Button": () => {
		return Promise.all([__webpack_require__.e(369), __webpack_require__.e(940)]).then(() => (() => ((__webpack_require__(940)))));
	}
};
var get = (module, getScope) => {
	__webpack_require__.R = getScope;
	getScope = (
		__webpack_require__.o(moduleMap, module)
			? moduleMap[module]()
			: Promise.resolve().then(() => {
				throw new Error('Module "' + module + '" does not exist in container.');
			})
	);
	__webpack_require__.R = undefined;
	return getScope;
};
var init = (shareScope, initScope) => {
	if (!__webpack_require__.S) return;
	var name = "default"
	var oldScope = __webpack_require__.S[name];
	if(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");
	__webpack_require__.S[name] = shareScope;
	return __webpack_require__.I(name, initScope);
};

// This exports getters to disallow modifications
__webpack_require__.d(exports, {
	get: () => (get),
	init: () => (init)
});

Здесь мы видим что создается объект с методами get, init и маппинг между именем модуля (который мы указали в exposed опциях плагина). Это упоминается в документации. На самом деле наш ремоут — это просто объект с двумя методами:

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

  • init — инициализация модуля. Здесь могут быть предоставлены shared modules.

Итоги примера

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

Подведем итог, что удалось узнать по исходникам, билду и базовому примеру:

  • инициализация приложения задерживается на время загрузки всех ремоутов. Если имеем дело с вложенными микрофронтами, это приводит к N последовательным запросами;

  • по умолчанию загрузка выполняется через тег script c фиксированным адресом. Нет возможности загружать по условию определенные версии (promise based не учитываем, о нем будет речь далее).

  • настройка на большом количестве проектов всех адресов, имен займет много времени и неудобна;

  • под капотом обычные модули вебпака, при желании через try/catch, а в случае реакта ErrorBoundary можно обрабатывать ошибки загрузки;

  • Async Entrypoint незначительно увеличвает время загрузки основного кода;

  • нет никаких возможностей узнать заработает ли это до момента деплоя/ручного запуска, принцип «собралось значит работает» не действует;

  • встроенных инструментов мониторинга, статистики нет.

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

Схема загрузки приложения

Схема загрузки приложения

Что хотим получить

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

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

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

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

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

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

А что дальше?

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

© Habrahabr.ru