Создание веб-приложения с использованием микрофронтендов и 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 (     <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 "Банковские карты"

Структура 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истема ролей веб-приложения

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

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

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

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

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

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/785798/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *