Micro Frontend Архитектура на примере Angular

от автора

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

По мере того, как приложения со временем усложняются, требуя масштабируемости «на лету» и высокой скорости реагирования, архитектура микро-фронтенд, основанная на компонентах Angular, становится все более эффективным решением для сложных веб-приложений.

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

Плюсы микро-фронтенд архитектуры

  • Автоматизация CI /CD. Поскольку каждое приложение интегрируется и развертывается независимо, это упрощает CI/CD. Так как все модули разделены, то не нужно беспокоиться обо всем приложении при внедрении нового модуля. Если в коде модуля есть ошибка, CI/CD прервет весь процесс сборки.
    Гибкость команд разработчиков. Многочисленные команды могут разрабатывать и развивать информационные системы, работая по отдельности.
  • Единая ответственность. Каждая команда микро-фронтеда на 100% фокусируется на функциональности своего микро-фронтенд приложения.
  • Возможность повторного использования. Микро-фронтенд приложение может быть повторно использовано несколькими командами в разных системах.
  • Технологический агностицизм. Архитектура микро-фронтенд не зависит от технологии. Возможно использовать компоненты, разработанные на разных фреймворков веб-разработки (React, Vue, Angular и т.д.).
  • Простой порог входа в систему. Небольшие модули легче изучать и понимать новым разработчикам, входящим в команды, чем монолитную архитектуру с огромной структурой кода.

Демонстрационное приложение

Мы разработаем приложение с микро-фронтенд архитектурой, показанное на рисунке ниже:

image

Модуль Header & Footer

Эта часть содержит по крайней мере 2 компонента, готовых к экспорту. Прежде всего, нам нужно создать новое приложение и настроить angular builder, который позволит нам использовать пользовательские конфигурации webpack.

ng new layout
npm i --save-dev ngx-build-plus

Теперь нам нужно создать webpack.config.js и webpack.prod.config.js файлы в корне нашего приложения.

// webpack.config.js const webpack = require("webpack"); const ModuleFederationPlugin =require("webpack/lib/container/ModuleFederationPlugin");  module.exports = {   output: {     publicPath: "http://localhost:4205/",     uniqueName: "layout",   },   optimization: {     runtimeChunk: false,   },   plugins: [     new ModuleFederationPlugin({       name: "layout",       library: { type: "var", name: "layout" },       filename: "remoteEntry.js",       exposes: {         Header: './src/app/modules/layout/header/header.component.ts',         Footer: './src/app/modules/layout/footer/footer.component.ts'       },       shared: {         "@angular/core": { singleton: true, requiredVersion:'auto' },         "@angular/common": { singleton: true, requiredVersion:'auto' },         "@angular/router": { singleton: true, requiredVersion:'auto' },       },     }),   ], };  // webpack.prod.config.js module.exports = require("./webpack.config"); 

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

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

У нас есть exposes раздел, здесь мы можем определить, какие элементы нам нужно разрешить экспортировать из нашего приложения. В нашем случае мы экспортируем только 2 компонента.
Теперь нужно добавить пользовательский конфигурационный файл в angular.json и изменить сборщик по умолчанию на ngx-build-plus:

{   ...   "projects": {     "layout": {       "projectType": "application",       "schematics": {         "@schematics/angular:component": {           "style": "scss"         },         "@schematics/angular:application": {           "strict": true         }       },       "root": "",       "sourceRoot": "src",       "prefix": "app",       "architect": {         "build": {           "builder": "ngx-build-plus:browser",           "options": {             "outputPath": "dist/layout",             "index": "src/index.html",             "main": "src/main.ts",             "polyfills": "src/polyfills.ts",             "tsConfig": "tsconfig.app.json",             "inlineStyleLanguage": "scss",             "assets": [               "src/favicon.ico",               "src/assets"             ],             "styles": [               "src/styles.scss"             ],             "scripts": [],             "extraWebpackConfig": "webpack.config.js"           },           "configurations": {             "production": {               "budgets": [                 {                   "type": "initial",                   "maximumWarning": "500kb",                   "maximumError": "1mb"                 },                 {                   "type": "anyComponentStyle",                   "maximumWarning": "2kb",                   "maximumError": "4kb"                 }               ],               "extraWebpackConfig": "webpack.prod.config.js",               "fileReplacements": [                 {                   "replace": "src/environments/environment.ts",                   "with": "src/environments/environment.prod.ts"                 }               ],               "outputHashing": "all"             },             "development": {               "buildOptimizer": false,               "optimization": false,               "vendorChunk": true,               "extractLicenses": false,               "sourceMap": true,               "namedChunks": true             }           },           "defaultConfiguration": "production"         },         "serve": {           "builder": "ngx-build-plus:dev-server",           "configurations": {             "production": {               "browserTarget": "layout:build:production"             },             "development": {               "browserTarget": "layout:build:development",               "extraWebpackConfig": "webpack.config.js",               "port": 4205             }           },           "defaultConfiguration": "development"         },         "extract-i18n": {           "builder": "@angular-devkit/build-angular:extract-i18n",           "options": {             "browserTarget": "layout:build"           }         },         "test": {           "builder": "ngx-build-plus:karma",           "options": {             "main": "src/test.ts",             "polyfills": "src/polyfills.ts",             "tsConfig": "tsconfig.spec.json",             "karmaConfig": "karma.conf.js",             "inlineStyleLanguage": "scss",             "assets": [               "src/favicon.ico",               "src/assets"             ],             "styles": [               "src/styles.scss"             ],             "scripts": [],             "extraWebpackConfig": "webpack.config.js"           }         }       }     }   },   "defaultProject": "layout" }

Модуль Register Page

Этот модуль будет содержать всю логику для страницы входа / регистрации.

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

ng new registerPage
npm i --save-dev ngx-build-plus

После этого создаем webpack.config.js и webpack.prod.config.js

// webpack.config.js const webpack = require("webpack");  const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");  module.exports = {   output: {     publicPath: "http://localhost:4201/",     uniqueName: "register",   },   optimization: {     runtimeChunk: false,   },   plugins: [     new ModuleFederationPlugin({       name: "register",       library: { type: "var", name: "register" },       filename: "remoteEntry.js",       exposes: {         RegisterPageModule:           "./src/app/modules/register/register-page.module.ts",       },       shared: {         "@angular/core": { singleton: true, requiredVersion: 'auto' },         "@angular/common": { singleton: true, requiredVersion: 'auto' },         "@angular/router": { singleton: true, requiredVersion: 'auto' },       },     }),   ], };   // webpack.prod.config.js  module.exports = require("./webpack.config"); 

Как можно заметить, здесь мы экспортируем только модуль страницы регистрации. Мы можем использовать его как модуль с отложенной загрузкой.

Кроме того, нам нужно изменить builder по умолчанию на ngx-build-plus и добавить конфигурации webpack в файл angular.json (так же, как мы делали для предыдущего модуля).

Модуль Dashboard

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

// webpack.config.js const webpack = require("webpack"); const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");  module.exports = {   output: {     publicPath: "http://localhost:4204/",     uniqueName: "dashboard",   },   optimization: {     runtimeChunk: false,   },   plugins: [     new ModuleFederationPlugin({       name: "dashboard",       library: { type: "var", name: "dashboard" },       filename: "remoteEntry.js",       exposes: {         DashboardModule:           "./src/app/modules/dashboard/dashboard.module.ts",       },       shared: {         "@angular/core": { singleton: true, requiredVersion:'auto'  },         "@angular/common": { singleton: true, requiredVersion:'auto'  },         "@angular/router": { singleton: true, requiredVersion:'auto'  },       },     }),   ], }; 

Главное приложение Shell

Основное приложение, которое загружает все микро-фронтенды в одно, называется Shell.

ng new shell
npm i --save-dev ngx-build-plus

Добавляем пользовательский конфигурационный webpack:

// webpack.config.js const webpack = require("webpack"); const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");  module.exports = {   output: {     publicPath: "http://localhost:4200/",     uniqueName: "shell",   },   optimization: {     runtimeChunk: false,   },   plugins: [     new ModuleFederationPlugin({       shared: {         "@angular/core": { eager: true, singleton: true },         "@angular/common": { eager: true, singleton: true },         "@angular/router": { eager: true, singleton: true },       },     }),   ], };

Настроим конфигурационный webpack в файле angular.json.

В environment/environment.ts мы объявляем все микро-фронтенды (для версии prod нам нужно заменить адрес локального хоста на развернутый общедоступный адрес):

export const environment = {   production: false,    microfrontends: {     dashboard: {       remoteEntry: 'http://localhost:4204/remoteEntry.js',       remoteName: 'dashboard',       exposedModule: ['DashboardModule'],     },      layout: {       remoteEntry: 'http://localhost:4205/remoteEntry.js',       remoteName: 'layout',       exposedModule: ['Header', 'Footer'],     }   } };

Создадим утилиты для объединения модулей.

// src/app/utils/federation-utils.ts type Scope = unknown; type Factory = () => any; interface Container {   init(shareScope: Scope): void;   get(module: string): Factory; } declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>; declare const __webpack_share_scopes__: { default: Scope }; const moduleMap: Record<string, boolean> = {}; function loadRemoteEntry(remoteEntry: string): Promise<void> {   return new Promise<void>((resolve, reject) => {     if (moduleMap[remoteEntry]) {       return resolve();     }     const script = document.createElement('script');     script.src = remoteEntry;     script.onerror = reject;     script.onload = () => {       moduleMap[remoteEntry] = true;       resolve(); // window is the global namespace     };     document.body.append(script);   }); } async function lookupExposedModule<T>(   remoteName: string,   exposedModule: string ): Promise<T> {   // Initializes the share scope. This fills it with known provided modules from this build and all remotes   await __webpack_init_sharing__('default');   const container = window[remoteName] as Container;    // Initialize the container, it may provide shared modules   await container.init(__webpack_share_scopes__.default);   const factory = await container.get(exposedModule);   const Module = factory();   return Module as T; }  export interface LoadRemoteModuleOptions {   remoteEntry: string;   remoteName: string;   exposedModule: string; }  export async function loadRemoteModule<T = any>(   options: LoadRemoteModuleOptions ): Promise<T> {   await loadRemoteEntry(options.remoteEntry);   return lookupExposedModule<T>(     options.remoteName,     options.exposedModule   ); }

и утилиты для сборки lazy loaded маршрутов:

// src/app/utils/route-utils.ts import { loadRemoteModule } from './federation-utils'; import { Routes } from '@angular/router'; import { APP_ROUTES } from '../app.routes'; import { Microfrontend } from '../core/services/microfrontends/microfrontend.types';  export function buildRoutes(options: Microfrontend[]): Routes {   const lazyRoutes: Routes = options.map((o) => ({     path: o.routePath,     loadChildren: () => loadRemoteModule(o).then((m) => m[o.ngModuleName]),     canActivate: o.canActivate,     pathMatch: 'full'   }));    return [     ...APP_ROUTES,     ...lazyRoutes   ]; }

Нам нужно определить микро-фронтеннд сервис:

// src/app/core/services/microfrontends/microfrontend.service.ts import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { MICROFRONTEND_ROUTES } from 'src/app/app.routes'; import { buildRoutes } from 'src/app/utils/route-utils';  @Injectable({ providedIn: 'root' }) export class MicrofrontendService {   constructor(private router: Router) {}    /*    * Initialize is called on app startup to load the initial list of    * remote microfrontends and configure them within the router    */   initialise(): Promise<void> {     return new Promise<void>((resolve) => {       this.router.resetConfig(buildRoutes(MICROFRONTEND_ROUTES));       return resolve();     });   } }

Файл для типа:

// src/app/core/services/microfrontends/microfrontend.types.ts import { LoadRemoteModuleOptions } from "src/app/utils/federation-utils";  export type Microfrontend = LoadRemoteModuleOptions & {   displayName: string;   routePath: string;   ngModuleName: string;   canActivate?: any[] };

Нам нужно определить микро-фронтеды согласно маршрутам:

// src/app/app.routes.ts import { Routes } from '@angular/router'; import { LoggedOnlyGuard } from './core/guards/logged-only.guard'; import { UnloggedOnlyGuard } from './core/guards/unlogged-only.guard'; import { Microfrontend } from './core/services/microfrontends/microfrontend.types'; import { environment } from 'src/environments/environment';  export const APP_ROUTES: Routes = [];  export const MICROFRONTEND_ROUTES: Microfrontend[] = [   {     ...environment.microfrontends.dashboard,     exposedModule: environment.microfrontends.dashboard.exposedModule[0],      // For Routing, enabling us to ngFor over the microfrontends and dynamically create links for the routes     displayName: 'Dashboard',     routePath: '',     ngModuleName: 'DashboardModule',     canActivate: [LoggedOnlyGuard]   },   {     ...environment.microfrontends.registerPage,     exposedModule: environment.microfrontends.registerPage.exposedModule[0],      displayName: 'Register',     routePath: 'signup',     ngModuleName: 'RegisterPageModule',     canActivate: [UnloggedOnlyGuard]   } ] 

Сервис в нашем основном приложении:

// src/app/app.module.ts import { APP_INITIALIZER, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule } from '@angular/router';  import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { APP_ROUTES } from './app.routes'; import { LoaderComponent } from './core/components/loader/loader.component'; import { NavbarComponent } from './core/components/navbar/navbar.component'; import { MicrofrontendService } from './core/services/microfrontends/microfrontend.service';  export function initializeApp(   mfService: MicrofrontendService ): () => Promise<void> {   return () => mfService.initialise(); }  @NgModule({   declarations: [     AppComponent,     NavbarComponent,     LoaderComponent   ],   imports: [     BrowserModule,     AppRoutingModule,     RouterModule.forRoot(APP_ROUTES, { relativeLinkResolution: 'legacy' }),   ],   providers: [     MicrofrontendService,     {       provide: APP_INITIALIZER,       useFactory: initializeApp,       multi: true,       deps: [MicrofrontendService],     },   ],   bootstrap: [AppComponent] }) export class AppModule { } 

Необходимо загрузить Footer и Header компоненты. Для этого нам надо обновить app компонент:

// src/app/app.component.html <main>   <header #header></header>   <div class="content">     <app-navbar [isLogged]="auth.isLogged"></app-navbar>     <div class="page-content">       <router-outlet *ngIf="!loadingRouteConfig else loading"></router-outlet>       <ng-template #loading>         <app-loader></app-loader>       </ng-template>     </div>   </div>   <footer #footer></footer> </main> 

а файл src/app/app.component.ts будет выглядеть так:

import {   ViewContainerRef,   Component,   ComponentFactoryResolver,   OnInit,   AfterViewInit,   Injector,   ViewChild } from '@angular/core'; import { RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'; import { loadRemoteModule } from './utils/federation-utils'; import { environment } from 'src/environments/environment';  @Component({   selector: 'app-root',   templateUrl: './app.component.html',   styleUrls: ['./app.component.scss'] }) export class AppComponent implements AfterViewInit, OnInit{   @ViewChild('header', { read: ViewContainerRef, static: true })   headerContainer!: ViewContainerRef;    @ViewChild('footer', { read: ViewContainerRef, static: true })   footerContainer!: ViewContainerRef;    loadingRouteConfig = false;    constructor(private injector: Injector,     private resolver: ComponentFactoryResolver,     private router: Router     ) {}    ngOnInit() {     this.router.events.subscribe(event => {       if (event instanceof RouteConfigLoadStart) {           this.loadingRouteConfig = true;       } else if (event instanceof RouteConfigLoadEnd) {           this.loadingRouteConfig = false;       }     });   }    ngAfterViewInit(): void {     // load header     loadRemoteModule({       ...environment.microfrontends.layout,       exposedModule: environment.microfrontends.layout.exposedModule[0],     })       .then(module => {         const factory = this.resolver.resolveComponentFactory(module.HeaderComponent);         this.headerContainer?.createComponent(factory, undefined, this.injector);       });      // load footer     loadRemoteModule({       ...environment.microfrontends.layout,       exposedModule: environment.microfrontends.layout.exposedModule[1],     })       .then(module => {         const factory = this.resolver.resolveComponentFactory(module.FooterComponent);         this.footerContainer?.createComponent(factory, undefined, this.injector);       });   } } 

Взаимодействие между микро-фронтендами

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

Один модуль должен отправлять пользовательские события следующим образом:

const busEvent = new CustomEvent('app-event-bus', {         bubbles: true,         detail: {           eventType: 'auth-register',           customData: 'some data here'         }       });       dispatchEvent(busEvent);

Другие микро-фронтенды могут подписаться на это событие:

onEventHandler(e: CustomEvent) {     if (e.detail.eventType === 'auth-register') {       const isLogged = Boolean(localStorage.getItem('token'));       this.auth.isLogged = isLogged;       if (isLogged) {         this.router.navigate(['/']);       } else {         this.router.navigate(['/signup']);       }     }   }    ngOnInit() {     this.$eventBus = fromEvent<CustomEvent>(window, 'app-event-bus').subscribe((e) =>   this.onEventHandler(e));    // ...   }

Заключение

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

Все исходники на github


ссылка на оригинал статьи https://habr.com/ru/company/auriga/blog/659199/


Комментарии

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

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