Как стать автором
Обновить

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

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

По мере того, как приложения со временем усложняются, требуя масштабируемости «на лету» и высокой скорости реагирования, архитектура микро-фронтенд, основанная на компонентах 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
Теги:
Хабы:
+10
Комментарии7

Публикации

Информация

Сайт
hr.auriga.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия