В наше цифровое время веб-приложения становятся все более масштабней и сложней. Такие веб-приложения могут быть разделены на несколько модулей, разработанных отдельными командами, которые удобно запускать в производство по отдельности.
По мере того, как приложения со временем усложняются, требуя масштабируемости «на лету» и высокой скорости реагирования, архитектура микро-фронтенд, основанная на компонентах Angular, становится все более эффективным решением для сложных веб-приложений.
Микро-фронтенд — это архитектура, которая рассматривает веб-приложение как набор приложений, разрабатываемых отдельными командами. Каждая команда специализируется на определенной области бизнеса или цели. Такая кросс-функциональная команда создает функциональность сверху донизу, от сервера до пользовательского интерфейса.
Мы разработаем приложение с микро-фронтенд архитектурой, показанное на рисунке ниже:
Эта часть содержит по крайней мере 2 компонента, готовых к экспорту. Прежде всего, нам нужно создать новое приложение и настроить angular builder, который позволит нам использовать пользовательские конфигурации webpack.
Теперь нам нужно создать webpack.config.js и webpack.prod.config.js файлы в корне нашего приложения.
Модуль Federation позволяет нам совместно использовать общие пакеты npm между различными микро-фронтендами. Это уменьшает полезную нагрузку для модулей с отложенной загрузкой.
Мы можем настроить минимально необходимую версию, допускается две или более версий для одного пакета. Более подробная информация о возможных вариантах плагина находится здесь: ссылка на плагин.
У нас есть exposes раздел, здесь мы можем определить, какие элементы нам нужно разрешить экспортировать из нашего приложения. В нашем случае мы экспортируем только 2 компонента.
Теперь нужно добавить пользовательский конфигурационный файл в angular.json и изменить сборщик по умолчанию на ngx-build-plus:
Этот модуль будет содержать всю логику для страницы входа / регистрации.
Также создаем приложение и устанавливаем пользовательский сборщик для использования конфигураций webpack.
После этого создаем webpack.config.js и webpack.prod.config.js
Как можно заметить, здесь мы экспортируем только модуль страницы регистрации. Мы можем использовать его как модуль с отложенной загрузкой.
Кроме того, нам нужно изменить builder по умолчанию на ngx-build-plus и добавить конфигурации webpack в файл angular.json (так же, как мы делали для предыдущего модуля).
Этот модуль предоставляет данные для авторизованного пользователя. Так же создаем приложение со своим конфигурационным файлом webpack:
Основное приложение, которое загружает все микро-фронтенды в одно, называется Shell.
Добавляем пользовательский конфигурационный webpack:
Настроим конфигурационный webpack в файле angular.json.
В environment/environment.ts мы объявляем все микро-фронтенды (для версии prod нам нужно заменить адрес локального хоста на развернутый общедоступный адрес):
Создадим утилиты для объединения модулей.
и утилиты для сборки lazy loaded маршрутов:
Нам нужно определить микро-фронтеннд сервис:
Файл для типа:
Нам нужно определить микро-фронтеды согласно маршрутам:
Сервис в нашем основном приложении:
Необходимо загрузить Footer и Header компоненты. Для этого нам надо обновить app компонент:
а файл src/app/app.component.ts будет выглядеть так:
У нас есть несколько способов обмена данными между различными микро-фронтендами. В нашем случае мы решили использовать пользовательское событие для связи. Пользовательское событие позволяет нам отправлять пользовательские данные с помощью полезной нагрузки события.
Один модуль должен отправлять пользовательские события следующим образом:
Другие микро-фронтенды могут подписаться на это событие:
Архитектура микро-фронтендов приобретает все большую и большую популярность, поскольку с течением времени кодовые базы веб-приложений становятся все более сложными. Крайне важно уметь проводить четкие границы между микро-фронтед приложениями и командами их разрабатывающих. Очень важно установить правильное взаимодействия и согласованность между техническими командами, что позволит успешно разрабатывать, развивать, поддерживать и внедрять сложные веб-приложения.
Все исходники на github
По мере того, как приложения со временем усложняются, требуя масштабируемости «на лету» и высокой скорости реагирования, архитектура микро-фронтенд, основанная на компонентах Angular, становится все более эффективным решением для сложных веб-приложений.
Микро-фронтенд — это архитектура, которая рассматривает веб-приложение как набор приложений, разрабатываемых отдельными командами. Каждая команда специализируется на определенной области бизнеса или цели. Такая кросс-функциональная команда создает функциональность сверху донизу, от сервера до пользовательского интерфейса.
Плюсы микро-фронтенд архитектуры
- Автоматизация CI /CD. Поскольку каждое приложение интегрируется и развертывается независимо, это упрощает CI/CD. Так как все модули разделены, то не нужно беспокоиться обо всем приложении при внедрении нового модуля. Если в коде модуля есть ошибка, CI/CD прервет весь процесс сборки.
Гибкость команд разработчиков. Многочисленные команды могут разрабатывать и развивать информационные системы, работая по отдельности. - Единая ответственность. Каждая команда микро-фронтеда на 100% фокусируется на функциональности своего микро-фронтенд приложения.
- Возможность повторного использования. Микро-фронтенд приложение может быть повторно использовано несколькими командами в разных системах.
- Технологический агностицизм. Архитектура микро-фронтенд не зависит от технологии. Возможно использовать компоненты, разработанные на разных фреймворков веб-разработки (React, Vue, Angular и т.д.).
- Простой порог входа в систему. Небольшие модули легче изучать и понимать новым разработчикам, входящим в команды, чем монолитную архитектуру с огромной структурой кода.
Демонстрационное приложение
Мы разработаем приложение с микро-фронтенд архитектурой, показанное на рисунке ниже:
Модуль 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