Как стать автором
Обновить
456.17
Сбер
Технологии, меняющие мир

От Lerna до ModuleFederation

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров357

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

Рассказ разделён на две части. В первой рассмотрим путь проекта и проблемы, с которыми сталкивались, а во второй разберём, как мы решали часть этих проблем.

Словарь терминов

1Продукт — совокупность функциональности, объединённой определённой спецификой, например: автомобили и мотоциклы.

2Приложение — дочерние микрофронтенд приложения содержащие в себе определённую функциональность, как правило, соответствующую конкретному продукту1.

Ядро — основное микрофронтенд-приложение, которое загружает в себя другие приложения2.

Стенд — собранное и развёрнутое микрофронтенд-приложение, либо через webpack DevServer, либо через nginx.

Часть 1. Начало и развитие проекта

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

Для хранения кода выбрали стратегию монорепозетория с использованием пакета Lerna. Сначала это решение не создавало никаких проблем и позволяло максимально легко делиться общими техническими решениями, быстро вносить изменения в разные продукты и в целом вести разработку.

По мере роста и развития проекта функциональность расширялась, стали появляться новые разделы, код становился сложнее. Это привело к неизбежному росту количества NPM-зависимостей и усложнению конфигурации сборщика.

Какие сложности возникли со временем

  1. Сильная связанность релизного цикла продуктов: если возникала проблема в одном из продуктов, она могла заблокировать релиз новой функциональности другого продукта.

  2. Большая и сложная кодовая база, обусловленная хранением нескольких продуктов в одном репозитории. Добавление новых продуктов ещё больше осложняло восприятие всей кодовой базы в целом.

  3. Проблемы при настройках Lerna: иногда переставала работать типизация, иногда возникали проблемы с линтерами и различные ошибки сборки.

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

Для упрощения параллельной разработки решили внедрить микрофронтенд-архитектуры через Wepback ModuleFederation, что, в перспективе, должно было ускорить разработку новой функциональности.

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

Как изменилась ситуация

  1. Всё ещё сильная связанность релизного цикла продуктов: теперь, если возникала проблема ядра, то она как и раньше блокировала релиз функциональности приложений. При этом проблемы приложений больше не блокировали релизы ядра.

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

  3. Сложность настройки осталась, проблемы Lerna перетекли в проблемы ModuleFederation: отсутствие передачи типизации между приложениями, проблемы с передачей данных, часть NPM-пакетов работает некорректно в определённых сценариях.

Проблемы, которые принёс ModuleFederation

  1. В базовом сценарии сборка ядра требовала наличия стенда приложения, на которое ссылается ядро. При этом сборка приложения требовала стенда ядра, на которое ссылается приложение.

  2. Стенды разных типов сборки не совместимы, например, development-стенд приложения не будет корректно работать с production-стендом ядра.

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

  4. Некоторые NPM-пакеты, использующие глобальные переменные, работают некорректно. В нашем случае были различные проблемы с DayJS, React.

  5. Повторная загрузка одних и тех же чанков стилей, что приводило к искажению отображения интерфейса и при загрузке приложений.

  6. Ошибки CORS при загрузке различных ресурсов из приложений, в том числе и Web Workers, что заблокировало возможность работы с ними в таком варианте.

  7. Дублирование одинаковой функциональности от приложения к приложению.

  8. Не работает hot reload для webpack devServer, стенд не обновляется динамически при внесении изменений в код.

  9. Отсутствие передачи типизации между проектами, любой компонент ядра, который хочет использовать приложение, придётся описать внутри проекта приложения вручную.

Часть 2. Проработка слабых сторон

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

Загрузка точек входа

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

Динамическая загрузка точек входа

Создали файл getRemotes.js в котором описали загрузку файлов remoteEntry.js:

const getRemoteEntryPromise = (tag, url, build) => {
  return `promise new Promise((resolve) => {
    const script = document.createElement('script');

    const customUrl = '${build}' !== 'prod' ? localStorage.getItem('${tag}') : undefined;
    const url = '${url}'.startsWith('http') ? '${url}' : window.location.origin + '/' + '${url}';

    script.src = customUrl ? customUrl : url;
    script.onload = () => {
      const proxy = {
        get: (request) => {
          try {
            return window['${tag}'].get(request);
          } catch(error) {
            console.error('Ошибка получения элемента MF: ${tag}', { error });
          }
        },
        init: (arg) => {
          try {
            return window['${tag}'].init(arg);
          } catch(error) {
            console.error('Ошибка инициализации MF: ${tag}', { error });
          }
        },
      };
      resolve(proxy);
    };
    script.onerror = (error) => {
      const proxy = {
        get: () => {
          return Promise.reject('Не удалось получить элемент из пространства: ${tag}@' + targetUrl);
        },
        init: () => {
          return;
        },
      };
      resolve(proxy);
    };
    document.head.appendChild(script);
  })`
};

const getRemotes = (config, build) => 
  config.reduce((remotes, { tag, url }) => {
    remotes[tag] = getRemoteEntryPromise(tag, url, build);

    return remotes;
  }, {});

Далее собрали список точек входа, в зависимости от уровня стендов, которые надо подключать, и записали в файл remotes.json:

{
    "local": [
        {
            "tag": "app1",
            "url": "http://localhost:3001/remoteEntry.js"
        },
        {
            "tag": "app2",
            "url": "http://localhost:3002/remoteEntry.js"
        }
    ],
    "dev": [
        {
            "tag": "app1",
            "url": "http://app1.dev.ru/remoteEntry.js"
        },
        {
            "tag": "app2",
            "url": "http://app2.dev.ru/remoteEntry.js"
        }
    ],
    "prod": [
        {
            "tag": "app1",
            "url": "mf/app1/remoteEntry.js"
        },
        {
            "tag": "app2",
            "url": "mf/app2/remoteEntry.js"
        }
    ]
}

В завершение вызвали новую функцию внутри webpack.config.js с передачей списка точек входа в зависимости от среды разработки:

const getRemotes = require("./getRemotes");
const REMOTES = require("./remotes");


module.exports = (env) => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "core",
        remotes: getRemotes(REMOTES[env.build], env.build),
      }),
    ],
  };
};

Передача общих состояний

Следующим шагом решали проблему общего состояния. Для этого в ядре создали отдельный экземпляр ModuleFederationPlugin, который должен был отвечать только за передачу объектов, в которых хранились общие состояния:

Передача общих состояний

В изначальном варианте мы пробовали выставлять общие состояния из точки входа в ядро. Для этого создали файл fileWithState.ts, в котором объявили объект RxJs, который содержал бы общие для всех приложений значения:

import { BehaviorSubject } from 'rxjs';

const state = new BehaviorSubject<Record<string, unknown>>({});

export default state;

Затем выставили общее состояние через exposes:

module.exports = (env) => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "core",
        filename: "remoteEntry.js",
        exposes: {
          "./fileWithState": "./src/exposes/fileWithState",
        },
        shared: {
          ...deps,
          react: {
            eager: true,
            singleton: true,
            requiredVersion: "17.0.2",
          },
          "react-dom": {
            eager: true,
            singleton: true,
            requiredVersion: "17.0.2",
          },
          "react-router-dom": {
            eager: true,
            singleton: true,
            requiredVersion: "6.14.2",
          },
        },
      }),
    ],
  };
};

Импорт из приложений работал корректно:

// Импортируем состояние внутри app1
import state from 'core/fileWithState'; // Работает корректно

Но если мы делали импорт из ядра, то начинались бесконечные перезагрузки страницы:

// Импортируем состояние внутри core
import state from 'core/fileWithState'; // Бесконечные перезагрузки

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

module.exports = (env) => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "core",
        filename: "remoteEntry.js",
        shared: {
          ...deps,
          react: {
            eager: true,
            singleton: true,
            requiredVersion: "17.0.2",
          },
          "react-dom": {
            eager: true,
            singleton: true,
            requiredVersion: "17.0.2",
          },
          "react-router-dom": {
            eager: true,
            singleton: true,
            requiredVersion: "6.14.2",
          },
        },
      }),
      new ModuleFederationPlugin({
        name: "store",
        filename: "storeEntry.js",
        exposes: {
          "./fileWithState": "./src/exposes/fileWithState",
        },
      }),
    ],
  };
};

Теперь осталось только импортировать общее состояние из store:

// Импортируем состояние внутри app1
import state from 'store/fileWithState'; // Работает корректно

И аналогично для ядра:

// Импортируем состояние внутри core
import state from 'store/fileWithState'; // Работает корректно

Ошибки CORS при загрузке данных

Для решения ошибок, возникающих при загрузке различных данных между приложениями, расположенными в разных доменах, нужно было добавлять заголовок Access-Control-Allow-Origin, с указанием небольшого регулярного выражения, которое будет соответствовать повторяющейся части адреса, до всех стендов приложений:

Ошибки CORS при загрузке данных

В файл конфигурации nginx добавляем map, который позволит по частичному совпадению адреса подставлять полный адрес до стенда:

map $http_origin $allow_origin {
    ~^.(partofurl\.ru|localhost). $http_origin;
    default "";
}

После этого определяем новый location до требуемых ресурсов, в который добавляем заголовок Access-Control-Allow-Origin:

location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|otf)$ {
    add_header Access-Control-Allow-Origin $allow_origin;
    expires 1y;
    access_log off;
    add_header Cache-Control "public";
}

Связанные релизы

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

Чтобы понять, для каких сущностей в первую очередь прорабатывать API мы посмотрели на основные сущности, которые чаще всего требовали изменений со стороны ядра. Ими оказались:

  1. страницы — их загрузка и добавление в маршруты;

  2. меню — как боковое, так и его мобильный вариант в виде гамбургера;

  3. модальные окна — монтаж и открытие.

Страницы

Для добавления страниц хотелось получить максимально удобное решение, которое бы просто поддерживалось и не требовало изменений на стороне ядра. Для этого определили контракт, по которому приложение предоставляет в ядро функцию, возвращающую данные в формате двух списков, содержащих исходные компоненты react‑router‑dom. Один список для неавторизованной зоны, другой для авторизованной. Пример такой функции:

Функция для передачи страниц от приложения к ядру

На стороне приложения создаётся файл для объявления функции получения страниц, например, routes.tsx:

import { Route } from "react-router-dom";

const getRoutes = (config: { cabinetPrefix: string }) => {
  console.log("Получение страниц", config);

  const publicRoutes = [
    <Route
      path="app1-public-route"
      element={<div>Роут доступный всем пользователям</div>}
    />,
  ];

  const privateRoutes = [
    <Route
      path="app1-private-route"
      element={<div>Роут доступный только авторизованным пользователям</div>}
    />,
  ];

  return { publicRoutes, privateRoutes };
};

export default getRoutes;

Далее этот файл добавляется в параметр exposes для приложения ModuleFederationPlugin, с названием routes:

module.exports = () => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "app1",
        exposes: {
          "./routes": "./src/routes.tsx",
        },
      }),
    ],
  };
};

Теперь ядро может использовать эту функцию на своей стороне для построения страниц всей системы:

export const getRoutes = async () => {
  const module = await window["app1"].get("./routes");
  const { publicRoutes, privateRoutes } = await module().default({
    cabinetPrefix: "client",
  });

  return {
    publicRoutes: <Route path="app1/*">{publicRoutes}</Route>,
    privateRoutes: <Route path="app1/*">{privateRoutes}</Route>,
  };
};

Такое решение оставляет всю функциональность пакета react‑router‑dom и при этом позволяет ядру встраивать страницы в нужные зоны.

Меню

В отличие от страниц, для меню мы не могли использовать контракт текущей библиотеки UI-компонентов, по двум причинам: во‑первых, планировали перейти на корпоративную библиотеку UI-компонентов, во‑вторых, требовалось два разных отображения для меню — боковой вариант и мобильный гамбургер. Учитывая эти ограничения, определили контракт, схожий с контрактом для страниц, но в этот раз функция должна возвращать массив объектов с полями:

Функция для передачи меню от приложения к ядру

На стороне приложения создаём файл для объявления функции получения меню, например, menu.tsx:

import MockIcon from './MockIcon';

const getMenu = (config: { cabinetPrefix: string }) => {
  console.log("Получение меню", config);

  const menu = [
    {
      name: "Секция меню из app1",
      type: "Section",
      items: [
        {
          name: "Пункт меню из app1",
          path: `${config.cabinetPrefix}/path1`,
          icon: <MockIcon />,
        },
      ],
    },
    {
      attachTo: "core1",
      name: 'Группа из app1 внутри "Пункт core 1"',
      type: "Group",
      items: [
        {
          name: "Второй пункт из app1",
          path: `${config.cabinetPrefix}/path2`,
          icon: <MockIcon />,
        },
      ],
    },
  ];

  return menu;
};

export default getMenu;

Далее этот файл добавляем в параметр exposes для приложения ModuleFederationPlugin, с названием menu:

module.exports = () => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "app1",
        exposes: {
          "./menu": "./src/menu.tsx",
        },
      }),
    ],
  };
};

Теперь ядро может использовать эту функцию на своей стороне для построения общего меню:

// Функция для добавления префикса адресам меню из приложений 
const getMenuPathWithPrefix = (menuConfig: any[], prefix: string) =>
  menuConfig.reduce<any[]>((acc, menuItem) => {
    if (typeof menuItem?.items === "undefined") {
      acc.push({ 
        ...menuItem, 
        path: `${prefix}/${menuItem?.path}` 
      });
      return acc;
    }

    acc.push({
      ...menuItem, 
      items: getMenuPathWithPrefix(menuItem?.items, prefix),
    });
    return acc;
  }, []);

export const getMenu = async () => {
  const module = await window["app1"].get("./menu");
  const menu = await module().default({ cabinetPrefix: "client" });

  return getMenuPathWithPrefix(menu, "app1");
};

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

Модальные окна

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

Функция для передачи модальных окон от приложения к ядру

На стороне приложения создаём файл для объявления функции получения модальных окон, например, modals.tsx:

import { Modal } from "./Modal";

const getModals = () => {
  console.log("Получение модальных окон");

  return {
    "app1-modal-code": <Modal />,
  };
};

export default getModals;

Далее этот файл добавляем в параметр exposes для приложения ModuleFederationPlugin, с названием modals:

module.exports = () => {
  return {
    plugins: [
      new ModuleFederationPlugin({
        name: "app1",
        exposes: {
          "./menu": "./src/menu.tsx",
        },
      }),
    ],
  };
};

Теперь ядро может использовать эту функцию на своей стороне для построения общего меню:

export const getModals = async () => {
    const module = await window['app1'].get('./modals');
    const modals = await module().default();

    return Object.keys(modals).reduce<Record<string, React.FC>>(
        (modalsWithTag, key) => {
            modalsWithTag[`app1-${key}`] = modals[key];

            return modalsWithTag;
        },
        {}
    );
}

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

Как достигается независимость разных приложений?

Закономерный вопрос, который может возникнуть:«Каким образом решена проблема с пересечением одинаковых путей страниц или кодов модальных окон?»
Мы использовали самый простой вариант: добавление префикса для каждого приложения, соответствующего тегу приложения. Например, приложение передало адрес /some-url, мы добавляем к нему приставку /app1, и полный путь будет выглядеть так: /app1/some-url

Документация

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

Для этого в Сonfluence создали раздел, посвящённый работе с ModuleFederation в рамках разработки ядра и приложений. Для желающих подключить новое приложение к ядру создали страницу «Быстрый старт», по аналогии с такими же страницами для React, React-query и т.д., описывающую минимальный набор шагов для внедрения.

Не решённые проблемы

Передача типизации

На данный момент типизация всё ещё передаётся вручную. Есть вспомогательный раздел в Confluence, но этого мало. При изучении вариантов для передачи типизации нашли несколько потенциально подходящих. Во-первых, это переход на ModuleFederation 2.0, в котором под капотом есть передача типизации, во-вторых, подключение к проекту отдельного решения вроде пакета module‑federation‑types‑plugin или любых других схожих.

Дублирование данных

Проблема, которая даёт о себе знать раз в какое-то время и требует продуманного решения. Этим решением, на наш взгляд, может быть реализация небольшого NPM-пакета, который включал бы в себя повторяющуюся функциональность, в том числе какие-то общие UI-компоненты, утилиты и прочее.

Несовместимость стендов

Скорее, просто банальное неудобство при тестировании некоторой функциональности, чем серьёзная техническая проблема. Тем не менее, на текущий момент решение выработать не удалось.

Итоги

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

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

Кроме сложности настройки при работе с ModuleFederation хочется также отметить ещё два момента: процесс CI/CD, который требует достаточно глубокой подготовки под новую архитектуру, а также поддержку и развитие инфраструктуры для развёртывания приложений.

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

Спасибо за внимание! Будет интересно прочитать про ваш опыт перехода к микрофронтенд-архитектуре и про те практики, которые вы используете у себя.

Благодарности

Спасибо коллегам, которые помогли в написании этой статьи: моему руководителю Комягину Денису Михайловичу, а также Дулову Александру Владимировичу и Доценко Кириллу Евгеньевичу.

Теги:
Хабы:
+8
Комментарии0

Информация

Сайт
www.sber.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия