«Микрофронтенды в компании, которая доставляет пиццу? Серьёзно? Зачем? Да и куда? У вас же всего лишь приложенька с каталогом и заказом товара. Какие ещё микрофронтенды?»

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

На самом деле не всё так просто

Вся работа компании построена на огромной системе Dodo IS. Подробнее можно про неё прочитать в статье нашего СТО Паши Притчина. Если вкратце, то это монолит, который состоит из большого количества сервисов. Но командам разработчиков, как правило, удобнее работать с микросервисами, чем с большим монолитом. Отсюда и решение отпиливать их.

Процесс пошёл, сервисы начали отпиливаться, но появился другой вопрос: а что делать с UI?

Практически у каждого сервиса есть своя админка. Админки для нескольких сервисов часто лежат рядом, поэтому поначалу весь UI оставляли в самом монолите. Но такой подход терял все профиты от использования микросервисов, так как приходилось менять код сначала в микросервисе, а потом ещё и в монолите. Работа увеличилась бы вдвое. Плюс этот сервис размазывался, не становился таким самостоятельным и зависел бы от кода извне. Ну и писать end-to-end гораздо удобнее, когда фронт и бэк находятся рядом.

Монолитная архитектура несёт в себе некоторые особенности и есть нюансы с доставкой кода до продакшена. Наши релизы на данный момент могут проходить за пару часов, а иногда затягиваются до двух дней или даже больше. И мне, как фронтендеру, совершенно неприемлемо столько ждать, чтобы задеплоить таску, в которой я полгода красил кнопку. Естественно, я могу накатить хотфикс, но такая постоянная практика выглядит не очень православно и добавляет беспокойства релизменам. Да и работать с репозиторием в Х тысяч строк кода и разбираться со всем не самое приятное занятие.

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

Короче говоря, мы хотели:

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

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

  • иметь возможность писать на различных фреймворках, использовать любые сборщики и т.д.

Мы поискали варианты решений наших потребностей и пришли к микрофронтендам.

Нельзя просто взять и сделать микрофронтенды

Ну для начала, конечно же, мы посмотрели на Iframe. Самый простой для реализации вариант: вставляем в него наше приложение и всё.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <iframe src="./mySharedApp/index.html"></iframe>
</body>
</html>

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

На тот момент уже много кто использовал подход с Single-spa. Это удобный фреймворк для создания приложения, которое может объединять в себе дочерние фронтенд-приложения (appshell далее). Выглядит базовый конфиг совсем несложным:

import { registerApplication, start } from 'single-spa'

registerApplication(
    'sharedApp',
    () => import('./sharedApp.js'),
    location => location.pathname.startsWith('/sharedApp'),
)

start()

Регистрируем приложение sharedApp, импортируем и показываем его по маршруту /sharedApp.

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

Но у нас же ещё есть сами микрофронтенды, и их тоже нужно как-то готовить. Экосистема Single-spa позволяет нам сделать и это. У неё есть библиотеки для создания микрофронтендов на разных фреймворках. Например, single-spa-react.

import singleSpaReact from 'single-spa-react';

const App = () => <div>SharedApp</div>

export const { bootstrap, mount, unmount } = singleSpaReact({
	React,
	ReactDOMClient,
	rootComponent: App,
	errorBoundary: err => {
		return <div>I Knew You Were Trouble</div>
	},
})

Оборачиваем приложение в метод singleSpaReact и экспортируем методы жизненного цикла, необходимые Single-spa для корректной работы.

Соберём наш микрофронтенд каким-либо сборщиком, подсунем его в appshell и всё, живём счастливо? (Нет)

Наши микрофронтенды деплоятся и достаются из blob-storage. Соответственно, импортировать их через относительные пути (с текущего хоста) не получится. Нужно запрашивать их динамически и лениво.

Мы не знаем заранее, где будут храниться бандлы, но хотим их импортировать не по путям, а просто по названиям. С этим может помочь Importmap. Это скрипт с картами импортов, у которых ключи в роли идентификатора (название приложения) и значениями в роли относительных или абсолютных путей на его физическое расположение (какой-нибудь blob-storage, например, Azure).

	<script type="importmap">
		{
			"imports": {
			   "mySharedApp": "https://storage/mySharedApp.432dfgh45.js",
			   "mySharedApp2": "https://storage/mySharedApp2.123fsd4g.js",
			}
		}
	</script>

C ними, к сожалению, тогда и до сих пор сохраняются некоторые проблемы. Их поддержку всё ещё не полностью реализовали во всех современных браузерах (например, Safari на iOS). Множественные importmap не поддерживаются в современных браузерах (например, в Chrome). Также есть проблема с external importmap (когда importmap подтягиваются через src тега script).

<script type="importmap" src="http://localhost:4000/importmap.json">
</script>

И что же делать в таком случае?

На помощь пришёл SystemJS. Он позволяет использовать вышеупомянутые importmap, только в формате systemjs-importmap, который поддерживают большинство браузеров.

<script type="systemjs-importmap">
	{
	  "imports": {
	    "mySharedApp": "https://storage/mySharedApp.432dfgh45.js",
	    "mySharedApp2": "https://storage/mySharedApp2.123fsd4g.js",
	  }
	}
</script>

Получается, appshell будет выглядеть таким образом:

import { registerApplication, start } from 'single-spa'

registerApplication(
    'sharedApp',
    () => import('mySharedApp'),
    location => location.pathname.startsWith('/sharedApp'),
)

start()

Кажется, теперь всё хорошо. (Нет)

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

Перепишем импорт микрофронтенда через SystemJS, и наш appshell будет выглядеть так:

import { registerApplication, start } from 'single-spa'

registerApplication(
    'sharedApp',
    () => System.import("mySharedApp"),
    location => location.pathname.startsWith('/sharedApp'),
)

start()

И вот тут пазл сошёлся. ????????

Вот такой вышел лунапарк

Мы подумали, обсудили и, чтобы не разводить зоопарк технологий на фронте, выбрали основой нашего UI стека React и всё, что вокруг его основной экосистемы.

Вот что у нас получилось:

  • single-spa-react

  • single-spa

  • SystemJS

  • systemjs-importmap.

Код для оборачивания дочерних приложений у нас стандартный, его можно глянуть чуть выше. А вот с appshell решили ещё чуть-чуть поколдовать.

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

{
	"sharedApp": "/sharedApp",
	"sharedApp2": "/sharedApp2"
}

Используем его в appshell.

import 'systemjs'
import 'import-map-overrides'

import { registerApplication, start } from 'single-spa'

import rules from './rules.json'

async function init() {
	for (const [name, activeWhenUrl] of Object.entries(rules)) {
		registerApplication(
			name,
			() => System.import(name),
			() => window.location.pathname.startsWith(activeWhenUrl),
		)
	}

	start()
}

init()

Тут мы бегаем по нашему JSON. Скармливаем Single-spa название приложения, имя импорта, предварительно подсунув скрипт с systemjs-importmap и локейшен, по которому он будет показываться.

Бонусом прицепили библиотечку import-map-overrides. Она позволяет динамически изменять url для JS-модулей и сохранять эти переопределения в Local storage. Это очень удобно, когда нужно потыкаться своей сборкой на конкретном окружении, например на проде.

При локальной сборке ещё существует проблема получения реальных данных. Для этого можно использовать проксирование. Но если стенд с авторизацией, то CORS не позволит нам запрашивать данные. Import-map-overrides позволяет решить эту проблему, так как авторизационная cookie все равно проставляется по нужному хосту, а мы только переопределяем путь до нашего JS-бандла, что позволяет нам работать локальной сборкой на нужном нам окружении.

Perfecto!????????

Но я уже ощущаю осуждающие комменты, почему мы не посмотрели в сторону Webpack module federation.

Вкратце расскажу, что за лев этот тигр.

Module federation — плагин webpack, работающий как на стороне родительского приложения, так и дочернего.

Рассмотрим конфиг дочернего:

new ModuleFederationPlugin({
      name: 'sharedApp',
      filename: 'shared.js',
      exposes: {
        './App': './src/App',
      },
      shared: {
        react: { singleton: true, requiredVersion: dependencies.react },
        'react-dom': {
          singleton: true,
          requiredVersion: dependencies['react-dom'],
        },
      },
    })

В name указываем название приложения. В filename — имя файла, в которое оно собирается. В exposes ключами являются наши будущие импорты, а значениями —физические расположения файлов. В shared можем указать зависимости, которые не требуется подгружать (например, если родительское приложение грузит React, тогда дочернему не нужно ещё раз его выгружать).

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

Точка входа — index.jsx

import('./bootstrap');

bootstrap.jsx

import { createRoot } from 'react-dom/client';
import React from 'react';

import { App } from '@shared/App';

const container = document.querySelector(`#app`);

if (!container) {
  throw new Error('container is not defined');
}

const root = createRoot(container);

root.render(<App />);

Ну и в App лежит основной контент:

const App = () => <div>Hello from shared app</div>;

export default App;

Глянем на родителя:

new ModuleFederationPlugin({
	remotes: {
		sharedApp: "shared@http://localhost:666/shared.js",
	},
	shared: {
		react: { singleton: true, requiredVersion: 17 },
		'react-dom': {
			singleton: true,
			requiredVersion: 17,
		},
	},
})

В remotes перечисляем дочерние приложения, где ключами являются названия, а в значениях указываем scope(shared из дочернего), путь до приложения и сам файл.

Точка входа — index.js

import('./bootstrap');

bootstrap.js

import { createRoot } from 'react-dom/client';
import React from 'react';

import { App } from './App';

const container = document.querySelector('#root');
const root = createRoot(container);
root.render(<App />);

В App лежат

import { lazy, Suspense } from 'react';

const SharedApp = lazy(() => import('sharedApp');

export const App: FC = () => (
    <Suspense fallback='loading...'>
      <SharedApp />
    </Suspense>
);

Выглядит привлекательно: не надо кучи библиотек тянуть, какие-то сингл-спа и импортмапы. Почему нет?

Наш старт с микрофронтендами начался ещё в 2019 году, к тому времени Webpack module federation был только в стадии зарождения. Соответственно, этого варианта у нас по умолчанию не было.

Но справедливости ради замечу, когда мы ресёрчили актуализацию подходов к построению наших микрофронтендов, каких-то киллер-профитов по сравнению с вариантом ниже не обнаружили.

Также есть нюанс, что module federation привязывает к экосистеме webpack. Получается, что appshell и все shared-приложения должны собираться им и только им. Это ограничивало бы нам свободу выбора сборщиков, что определённо является одним из нюансов, которые нужно иметь ввиду. У нас на данный момент есть приложения, которые собирает vite, и кто знает, какие ещё быстрые сборщики появятся в будущем.

Ну и всё, мы молодцы? (Нет)

Микрофронтенды есть, а как их их деплоить, как хранить importmap и обновлять?

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

Порядок такой:

  1. Разработчик коммитит свой код в основную ветку репозитория, в котором лежит микрофронтенд.

  2. В GitHub Actions происходит автоматический билд фронта.

  3. Сбилженный артефакт выкладываем в приватный blob storage.

Для доставки до прода у нас есть собственный инструмент (script) — microfrontend-gitops. Он занимается публикацией микрофронтендов в Azure и обновлением importmap.

Скрипт принимает 3 параметра:

  • Environment. Стенд, которым пользуются команды или продакшн стенд;

  • Frontend. Тут указывается название appshell, в который мы деплом наш микрофронтенд;

  • Bundle. Урл до файла с микрофронтендом, который лежит в Azure.

Для деплоя на продакшен:

  1. Дёргаем GitHub Actions на выбранный Environment(см. выше).

  2. GHA скачивает и запускает скрипт microfrontend-gitops.

  3. Скрипт делает коммит в репозиторий, в котором прописаны физические расположения файлов importmap.

  4. Скачивается бандл.

  5. Обновляются importmap и выкладывается JSON importmap в публичный blob storage.

Схематично весь процесс отображён на скрине ниже.

Плюсы и минусы такого решения

У нас появился полноценный подход к разработке микрофронтендов, который поддерживается во всех современных браузерах с минимумом бойлерплейта для их организации. Любой может быть написан хоть на React, хоть на Vue — да на всём, чём угодно, встраиваться в любой appshell и использоваться там, где захотим, без особых проблем. Каждый из микрофронтендов минимально завязан на окружение, в котором он будет находиться, и максимально сцеплен на себе. Привет, low coupling high cohesion!

Монолит распиливается. Мы можем экспериментировать с любыми сборщиками, UI-библиотеками, фреймворками и пробовать новые штуки, которые в JS-мире появляются каждый час.

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

Помимо плюсов есть и минусы, о которых стоит упомянуть.

Так как микрофронтенд должен быть полноценным приложением, мы не делаем код-сплитинг для них. Соответственно, бандлы иногда получаются не самые маленькие, что стоит учитывать для определённых сегментов бизнеса, которым важны метрики first contentful paint, large contentful paint и time to interactive.

Ну и несмотря на удобный CD, всё ещё остаётся проблема с мониторингом микрофронтендов. Какая версия на каком стенде, насколько она отличается от продакшена, на каких стендах микрофронтенд вообще раскатан. Всё это не решённые пока проблемы.

P.S. Мы уже работаем над инструментом по мониторингу и, возможно, о нём ещё расскажем.

Так мы и жили

Пока в новом проекте к нам одним прекрасным утром не пришел наш тех-лид и такой: «Чуваки, а давайте затащим…»

Продолжение следует…

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


  1. c_kotik
    03.02.2023 11:38
    +2

    Есть готовый продукт, а статьи описывают абстрактные app1 и app2...

    Можете на пальцах скриншотах показать где микрофорнтенд1 и микрофронтэенд2 на Вашем продукте?


    1. pahan-fe Автор
      03.02.2023 16:43
      +2

      Привет)

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


  1. kmk
    03.02.2023 13:47

    Рассматриваете миграцию в Azure DevOps в будущем? Некоторые ваши шаги CI\CD там реализовать было бы проще.


    1. pahan-fe Автор
      03.02.2023 16:44

      Привет)

      У нас на самом деле лежит задача по оптимизации деплоя. Возможно мы посмотрим в эту сторону.


  1. imater
    04.02.2023 21:44

    Серверный рендеринг (SSR) не поддерживаете?


    1. pahan-fe Автор
      05.02.2023 20:32

      SSR есть, но только на сайте.

      А все микрофронтенды у нас это обычные SPA