Интро

Привет! В данной статье мы разберём процесс разработки веб-приложения на основе подхода микрофронтендов с использованием технологии Module Federation.

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

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

Для реализации выберем AntdDesign, React.js в комбинации с Module Federation

Схема работы

На схеме представлена архитектура веб-приложения, использующего микрофронтенды с интеграцией через Module Federation. В вверху изображения находится Host, который является главным приложением (Main app) и служит контейнером для остальных микроприложений.

Существуют два микрофронтенда: Cards и Transactions, каждое из которых разработано отдельной командой и выполняет свои функции в рамках банковского приложения.

Также на схеме присутствует компонент Shared, который содержит общие ресурсы, такие как типы данных, утилиты, компоненты и прочее. Эти ресурсы импортируются как в Host, так и в микроприложения Cards и Transactions, что обеспечивает консистентность и переиспользование кода во всей экосистеме приложения.

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

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

Общая структура веб-приложения

Мы организуем наши приложения внутри директории packages и настроим Yarn Workspaces, что позволит нам эффективно использовать общие компоненты из модуля shared между различными пакетами.

"workspaces": [
    "packages/*"
  ],

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

Webpack-конфиг для host-приложения

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

const deps = require('./package.json').dependencies;
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  // Остальная конфигурация Webpack, не связанная непосредственно с Module Federation
  // ...

  plugins: [
    // Плагин Module Federation для интеграции микрофронтендов
    new ModuleFederationPlugin({
      remotes: {
        // Определение удаленных микрофронтендов, доступных для этого микрофронтенда
        'remote-modules-transactions': isProduction
          ? 'remoteModulesTransactions@https://microfrontend.fancy-app.site/apps/transactions/remoteEntry.js'
          : 'remoteModulesTransactions@http://localhost:3003/remoteEntry.js',
        'remote-modules-cards': isProduction
          ? 'remoteModulesCards@https://microfrontend.fancy-app.site/apps/cards/remoteEntry.js'
          : 'remoteModulesCards@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        // Определение общих зависимостей между разными микрофронтендами
        react: { singleton: true, requiredVersion: deps.react },
        antd: { singleton: true, requiredVersion: deps['antd'] },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
        'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
        axios: { singleton: true, requiredVersion: deps['axios'] },
      },
    }),
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'src', 'index.html'), // Шаблон HTML для Webpack
    }),
  ],

  // Другие настройки Webpack
  // ...
};

Webpack-конфиг для приложения "Банковские карты"

const path = require('path');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const deps = require('./package.json').dependencies;

module.exports = {
  // Остальная конфигурация Webpack...

  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'src', 'index.html'), // Шаблон HTML для Webpack
    }),
    // Конфигурация Module Federation Plugin
    new ModuleFederationPlugin({
      name: 'remoteModulesCards', // Имя микрофронтенда
      filename: 'remoteEntry.js', // Имя файла, который будет служить точкой входа для микрофронтенда
      exposes: {
        './Cards': './src/root', // Определяет, какие модули и компоненты будут доступны для других микрофронтендов
      },
      shared: {
        // Определение зависимостей, которые будут использоваться как общие между различными микрофронтендами
        react: { requiredVersion: deps.react, singleton: true },
        antd: { singleton: true, requiredVersion: deps['antd'] },
        'react-dom': { requiredVersion: deps['react-dom'], singleton: true },
        'react-redux': { singleton: true, requiredVersion: deps['react-redux'] },
        axios: { singleton: true, requiredVersion: deps['axios'] },
      },
    }),
  ],

  // Другие настройки Webpack...
};

Теперь мы легко можем импортировать наши приложения в host-приложение.

import React, { Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Main } from '../pages/Main';
import { MainLayout } from '@host/layouts/MainLayout';

// Ленивая загрузка компонентов Cards и Transactions из удаленных модулей
const Cards = React.lazy(() => import('remote-modules-cards/Cards'));
const Transactions = React.lazy(() => import('remote-modules-transactions/Transactions'));

const Pages = () => {
  return (
    <Router>
		   <MainLayout>
          {/* Использование Suspense для управления состоянием загрузки асинхронных компонентов */}
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              <Route path={'/'} element={<Main />} />
              <Route path={'/cards/*'} element={<Cards />} />
              <Route path={'/transactions/*'} element={<Transactions />} />
            </Routes>
          </Suspense>
        </MainLayout>
    </Router>
  );
};
export default Pages;

Далее для команды "Банковские карты" настроим Redux Toolkit

Структура Redux "Банковские карты"
// Импортируем функцию configureStore из библиотеки Redux Toolkit
import { configureStore } from '@reduxjs/toolkit';

// Импортируем корневой редьюсер
import rootReducer from './features';

// Создаем хранилище с помощью функции configureStore
const store = configureStore({
  // Устанавливаем корневой редьюсер
  reducer: rootReducer,
  // Устанавливаем промежуточное ПО по умолчанию
  middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
});

// Экспортируем хранилище
export default store;

// Определяем типы для диспетчера и состояния приложения
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
// Импортируем React
import React from 'react';

// Импортируем главный компонент приложения
import App from '../app/App';

// Импортируем Provider из react-redux для связи React и Redux
import { Provider } from 'react-redux';

// Импортируем наше хранилище Redux
import store from '@modules/cards/store/store';

// Создаем главный компонент Index
const Index = (): JSX.Element => {
  return (
    // Оборачиваем наше приложение в Provider, передавая в него наше хранилище
    <Provider store={store}>
      <App />
    </Provider>
  );
};

// Экспортируем главный компонент
export default Index;

В приложении должна быть система ролей

Cистема ролей веб-приложения
  • USER - может просматривать страницы,

  • MANAGER - имеет права на редактирование,

  • ADMIN - может редактировать и удалять данные.

Host-приложение отправляет запрос на сервер для получения информации о пользователе и сохраняет эти данные в своем хранилище. Необходимо изолированно получить эти данные в приложении "Банковские карты".

Для этого нужно написать middleware для Redux-стора host-приложения, чтобы сохранять данные в глобальный объект window

// Импортируем функцию configureStore и тип Middleware из библиотеки Redux Toolkit
import { configureStore, Middleware } from '@reduxjs/toolkit';

// Импортируем корневой редьюсер и тип RootState
import rootReducer, { RootState } from './features';

// Создаем промежуточное ПО, которое сохраняет состояние приложения в глобальном объекте window
const windowStateMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    const result = next(action);
    (window as any).host = store.getState();
    return result;
  };

// Функция для загрузки состояния из глобального объекта window
const loadFromWindow = (): RootState | undefined => {
  try {
    const hostState = (window as any).host;
    if (hostState === null) return undefined;
    return hostState;
  } catch (e) {
    console.warn('Error loading state from window:', e);
    return undefined;
  }
};

// Создаем хранилище с помощью функции configureStore
const store = configureStore({
  // Устанавливаем корневой редьюсер
  reducer: rootReducer,
  // Добавляем промежуточное ПО, которое сохраняет состояние в window
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(windowStateMiddleware),
  // Загружаем предварительное состояние из window
  preloadedState: loadFromWindow(),
});

// Экспортируем хранилище
export default store;

// Определяем тип для диспетчера
export type AppDispatch = typeof store.dispatch;

Вынесем константы в модуль shared

Общие компоненты
export const USER_ROLE = () => {
  return window.host.common.user.role;
};

Для синхронизации изменения роли пользователя между всеми микрофронтендами мы задействуем event bus. В модуле shared реализуем обработчики для отправки и приёма событий.

// Импортируем каналы событий и типы ролей
import { Channels } from '@/events/const/channels';
import { EnumRole } from '@/types';

// Объявляем переменную для обработчика событий
let eventHandler: ((event: Event) => void) | null = null;

// Функция для обработки изменения роли пользователя
export const onChangeUserRole = (cb: (role: EnumRole) => void): void => {
  // Создаем обработчик событий
  eventHandler = (event: Event) => {
    // Приводим событие к типу CustomEvent
    const customEvent = event as CustomEvent<{ role: EnumRole }>;
    // Если в событии есть детали, выводим их в консоль и вызываем callback-функцию
    if (customEvent.detail) {
      console.log(`On ${Channels.changeUserRole} - ${customEvent.detail.role}`);
      cb(customEvent.detail.role);
    }
  };

  // Добавляем обработчик событий на глобальный объект window
  window.addEventListener(Channels.changeUserRole, eventHandler);
};

// Функция для остановки прослушивания изменения роли пользователя
export const stopListeningToUserRoleChange = (): void => {
  // Если обработчик событий существует, удаляем его и обнуляем переменную
  if (eventHandler) {
    window.removeEventListener(Channels.changeUserRole, eventHandler);
    eventHandler = null;
  }
};

// Функция для отправки события об изменении роли пользователя
export const emitChangeUserRole = (newRole: EnumRole): void => {
  // Выводим в консоль информацию о событии
  console.log(`Emit ${Channels.changeUserRole} - ${newRole}`);
  // Создаем новое событие
  const event = new CustomEvent(Channels.changeUserRole, {
    detail: { role: newRole },
  });
  // Отправляем событие
  window.dispatchEvent(event);
};

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

import React, { useEffect, useState } from 'react';
import { Button, Card, List, Modal, notification } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import { getCardDetails } from '@modules/cards/store/features/cards/slice';
import { AppDispatch } from '@modules/cards/store/store';
import { userCardsDetailsSelector } from '@modules/cards/store/features/cards/selectors';
import { Transaction } from '@modules/cards/types';
import { events, variables, types } from 'shared';
const { EnumRole } = types;
const { USER_ROLE } = variables;
const { onChangeUserRole, stopListeningToUserRoleChange } = events;

export const CardDetail = () => {
  // Использование Redux для диспетчеризации и получения состояния
  const dispatch: AppDispatch = useDispatch();
  const cardDetails = useSelector(userCardsDetailsSelector);

  // Локальное состояние для роли пользователя и видимости модального окна
  const [role, setRole] = useState(USER_ROLE);
  const [isModalVisible, setIsModalVisible] = useState(false);

  // Эффект для загрузки деталей карты при монтировании компонента
  useEffect(() => {
    const load = async () => {
      await dispatch(getCardDetails('1'));
    };
    load();
  }, []);

  // Функции для управления модальным окном
  const showEditModal = () => {
    setIsModalVisible(true);
  };

  const handleEdit = () => {
    setIsModalVisible(false);
  };

  const handleDelete = () => {
    // Отображение уведомления об удалении
    notification.open({
      message: 'Card delete',
      description: 'Card delete success.',
      onClick: () => {
        console.log('Notification Clicked!');
      },
    });
  };

  // Эффект для подписки и отписки от событий изменения роли пользователя
  useEffect(() => {
    onChangeUserRole(setRole);
    return stopListeningToUserRoleChange;
  }, []);

  // Условный рендеринг, если детали карты не загружены
  if (!cardDetails) {
    return <div>loading...</div>;
  }

  // Функция для определения действий на основе роли пользователя
  const getActions = () => {
    switch (role) {
      case EnumRole.admin:
        return [
          <Button key="edit" type="primary" onClick={showEditModal}>
            Edit
          </Button>,
          <Button key="delete" type="dashed" onClick={handleDelete}>
            Delete
          </Button>,
        ];
      case EnumRole.manager:
        return [
          <Button key="edit" type="primary" onClick={showEditModal}>
            Edit
          </Button>,
        ];
      default:
        return [];
    }
  };

  // Рендеринг компонента Card с деталями карты и действиями
  return (
    <>
      <Card
        actions={getActions()}
        title={`Card Details - ${cardDetails.cardHolderName} `}
      >
        {/* Отображение различных атрибутов карты */}
        <p>PAN: {cardDetails.pan}</p>
        <p>Expiry: {cardDetails.expiry}</p>
        <p>Card Type: {cardDetails.cardType}</p>
        <p>Issuing Bank: {cardDetails.issuingBank}</p>
        <p>Credit Limit: {cardDetails.creditLimit}</p>
        <p>Available Balance: {cardDetails.availableBalance}</p>
        {/* Список последних транзакций */}
        <List
          header={<div>Recent Transactions</div>}
          bordered
          dataSource={cardDetails.recentTransactions}
          renderItem={(item: Transaction) => (
            <List.Item>
              {item.date} - {item.amount} {item.currency} - {item.description}
            </List.Item>
          )}
        />
        <p>
          <b>*For demonstration events from the host, change the user role.</b>
        </p>
      </Card>
      {/* Модальное окно для редактирования */}
      <Modal
        title="Edit transactions"
        open={isModalVisible}
        onOk={handleEdit}
        onCancel={() => setIsModalVisible(false)}
      >
        <p>Form edit card</p>
      </Modal>
    </>
  );

Для настройки развертывания приложения через GitHub Actions, создадим файл конфигурации .yml, который определяет рабочий процесс CI/CD. Вот пример простого конфига:

name: Build and Deploy Cards Project

# Этот workflow запускается при событиях push или pull request,
# но только для изменений в директории 'packages/cards'.
on:
  push:
    paths:
      - 'packages/cards/**'
  pull_request:
    paths:
      - 'packages/cards/**'

# Определение задач (jobs) для выполнения
jobs:
  # Первая задача: Установка зависимостей
  install-dependencies:
    runs-on: ubuntu-latest  # Задача выполняется на последней версии Ubuntu

    steps:
      - uses: actions/checkout@v2  # Выполняет checkout кода репозитория

      - name: Set up Node.js  # Устанавливает Node.js версии 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кэширование Node модулей для ускорения сборки
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Install Dependencies  # Установка зависимостей проекта через Yarn
        run: yarn install

  # Вторая задача: Тестирование и сборка
  test-and-build:
    needs: install-dependencies  # Эта задача требует завершения задачи install-dependencies
    runs-on: ubuntu-latest  # Запускается на последней версии Ubuntu

    steps:
      - uses: actions/checkout@v2  # Выполняет checkout кода репозитория

      - name: Use Node.js  # Использует Node.js версии 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кэширование Node модулей
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Build Shared Modules  # Сборка общих модулей
        run: yarn workspace shared build

      - name: Test and Build Cards  # Тестирование и сборка workspace Cards
        run: |
          yarn workspace cards test
          yarn workspace cards build

      - name: Archive Build Artifacts  # Архивация артефактов сборки для развертывания
        uses: actions/upload-artifact@v2
        with:
          name: shared-artifacts
          path: packages/cards/dist

  # Третья задача: Развертывание Cards
  deploy-cards:
    needs: test-and-build  # Эта задача требует завершения задачи test-and-build
    runs-on: ubuntu-latest  # Запускается на последней версии Ubuntu

    steps:
      - uses: actions/checkout@v2  # Выполняет checkout кода репозитория

      - name: Use Node.js  # Использует Node.js версии 16
        uses: actions/setup-node@v2
        with:
          node-version: '16'

      - name: Cache Node modules  # Кэширование Node модулей
        uses: actions/cache@v2
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}

      - name: Download Build Artifacts  # Скачивание артефактов сборки из предыдущей задачи
        uses: actions/download-artifact@v2
        with:
          name: shared-artifacts
          path: ./cards

      - name: Deploy to Server  # Развертывание артефактов на сервере с помощью SCP
        uses: appleboy/scp-action@master
        with:
		  host: ${{ secrets.HOST }}
          username: root
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: 'cards/*'
          target: '/usr/share/nginx/html/microfrontend/apps'
Pipeline веб-приложения "Банковские карты"
Структура собранных бандлов на сервере

На скриншоте представлено распределение собранных бандлов. Здесь мы можем добавить такие функции, как версионирование и A/B-тестирование, управляя ими через Nginx.

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

Этот подход ускоряет процесс сборки, так как больше не требуется ожидать проверки всего приложения. Код можно обновлять по частям и проводить регрессивное тестирование для каждого отдельного компонента.

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

Тестовый стенд для демонстрации функционала и исходный код в GitHub репозитории.

Спасибо за внимание)