Создание веб-приложения с использованием микрофронтендов и Module Federation

Интро

Интро

Привет! В данной статье мы разберём процесс разработки веб-приложения на основе подхода микрофронтендов с использованием технологии 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 (
    
		   
          {/* Использование Suspense для управления состоянием загрузки асинхронных компонентов */}
          Loading...
}> } /> } /> } /> ); }; export default Pages;

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

Структура Redux

Структура 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;
// Импортируем 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, передавая в него наше хранилище
    
      
    
  );
};

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

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

Cистема ролей веб-приложения

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 
loading...
; } // Функция для определения действий на основе роли пользователя const getActions = () => { switch (role) { case EnumRole.admin: return [ , , ]; case EnumRole.manager: return [ , ]; default: return []; } }; // Рендеринг компонента Card с деталями карты и действиями return ( <> {/* Отображение различных атрибутов карты */}

PAN: {cardDetails.pan}

Expiry: {cardDetails.expiry}

Card Type: {cardDetails.cardType}

Issuing Bank: {cardDetails.issuingBank}

Credit Limit: {cardDetails.creditLimit}

Available Balance: {cardDetails.availableBalance}

{/* Список последних транзакций */} Recent Transactions
} bordered dataSource={cardDetails.recentTransactions} renderItem={(item: Transaction) => ( {item.date} - {item.amount} {item.currency} - {item.description} )} />

*For demonstration events from the host, change the user role.

{/* Модальное окно для редактирования */} setIsModalVisible(false)} >

Form edit card

);

Для настройки развертывания приложения через 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 веб-приложения

Pipeline веб-приложения «Банковские карты»

Структура собранных бандлов на сервере

Структура собранных бандлов на сервере

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

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

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

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

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

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

© Habrahabr.ru