Привет! С вами снова Максим. Во второй части будет о том, что мы придумали.
Когда мы решили пилиться после сбоя, про который я рассказал в первой части, начали с личного кабинета. Его попилить на микрофронтенды легко, потому что мы разделили весь код по модулям, замкнув бизнес-логику в конкретном модуле, и вынесли общие части в общие библиотеки. Осталось достать пилу и отпиливать модули — делать их отдельными приложениями.
Как настраивать Module Federation
Мы поставили цель вынести все страницы ЛК в отдельные изолированные приложения.
В терминах Module Federation есть такие приложения:
Host — агрегатор. Он собирает все части нашего пазла в целостное приложение. А еще может выполнять роль хранилища общих данных для всех приложений: данные по пользователю, текущая цветовая схема и язык.
Remote — микрофронт, который выполняет определенную задачу в нашем ЛК, например позволяет работать с транзакциями. Такой тип приложений может работать в двух режимах с host-приложением: встраиваться в него и standalone — когда мы хотим запустить эту часть изолированно от всего, мы сами должны получать пользователей и данные по ним.
Такой исход монолита в микросервисы задевает не только фронтов, бэк-разработчики чаще нас используют микросервисный подход при построении архитектуры своих решений. А как известно, чем больше микросервисов, тем сложнее этим управлять и взаимодействовать с ними с фронта. И тут мы понимаем, что уже 2023 год, JS работает не только в браузерах, и начинаем внедрять BFF-подход.
Зачем держать сотни интеграций на фронте, если можно сделать единое API для работы с фронтом и уже на стороне BFF выбирать, в какой сервис и с какими данными нам пойти. Это упрощает разработку, особенно когда у дальних сервисов разное входное и выходное API. На фронт все придет в том формате, в котором удобно нам.
Причины выбора Module Federation я описал, когда рассказывал про микрофронты с помощью webpack.
Мы выбрали Module Federation, потому что сейчас это одно из лучших коробочных решений на рынке. Плюс мы используем Angular, который с 12-й версии полностью поддерживает всю эту историю. Как и React с 17 и Vue 3.
Настройка Module Federation в самом начале выглядит вот так:
plugins: [
new ModuleFederationPlugin({
remotes: {
main: 'main@http://localhost:4201/remoteEntry.js',
operations: 'operations@http://localhost:4202/remoteEntry.js',
stores: 'stores@http://localhost:4203/remoteEntry.js',
},
shared: share({
'@angular/core': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/common': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/common/http': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/router': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/forms': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
...sharedMappings.getDescriptors(),
}),
}),
sharedMappings.getPlugin(),
],
Есть блок, отвечающий за ремоуты:
remotes: {
main: 'main@http://localhost:4201/remoteEntry.js',
operations: 'operations@http://localhost:4202/remoteEntry.js',
stores: 'stores@http://localhost:4203/remoteEntry.js',
},
А еще есть блок с зависимостями. В нем мы говорим, какие зависимости должны быть синглтонами и пошарены между фронтэндами. Все приложения получат один и тот же инстанс нашей зависимости.
Думаю, те, кто хоть раз видел документацию Webpack Module Federation, встречали похожий конфиг:
shared: share({
'@angular/core': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/common': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/common/http': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/router': {singleton: true, strictVersion: true, requiredVersion: 'auto'},
'@angular/forms': {singleton: true, strictVersion: true, requiredVersion: 'auto’},
...sharedMappings.getDescriptors(),
}),
Такие конфигурации порождают множество вопросов. Но сначала скажем о плюсах
Плюсы и минусы Module Federation
Сначала перечислю плюсы MFE.
Работает из коробки. Не нужно дополнительных настроек. Проследовали по документации, подняли два приложения — все работает. Считаем, что мы микрофронтендер и молодец.
Удобное управление зависимостями. Известно, какие зависимости должны шариться между сервисами, какие не должны, какие будут иметь свой инстанс, а какие не будут. Все видим сразу же.
Из конфига видно сразу все приложения. Первым делом хорошо вынести определения зависимостей в отдельный файлик, чтобы шарить между всеми, иначе потом запутаемся: кто-то укажет зависимость в ремоуте, но не в хосте.
Но есть и минусы MFE.
Динамическое управление. Для динамического управления есть env-переменные, докер-переменные и nginx. Для каждого случая переменные можно устанавливать на этапе сборки или билда. Нам интересно подставление переменных на этапе деплоя, потому что мы уже будем знать, на какое окружение разворачивается наш код, и сможем собрать конфиг. На этапе деплоя можно держать n количество значений env-переменных для любого окружения и, не влияя на сам сорс-код, менять значение в конфиге, который код подгружает себе.
Фолбэки. Module Federation работает с помощью http-загрузки, то есть он идет и загружает чанк зависимого приложения. Что будет, если оно не загрузится? В Webpack нет fall back на загрузке наших ремоутов. Он просто выкинет exception и покажет белую страницу. И в доке не написано, как этим управлять.
Ремоут в ремоуте — когда внутрь host-приложения встроено remote-приложение, которое является дочерним для хоста, — это наш микрофронт. И внутрь remote-приложения встроено еще одно remote-приложение, получается матрешка: микрофронтенд внутри микрофронта. Может показаться, что такой кейс выдуман, но на самом деле это реальный бизнесовый продовый кейс, который успешно реализован.
Управление динамикой
В вопросе динамики первое, что приходит в голову, — нужен конфиг, которым можно управлять динамически. Это значит, что мы создаем конфиг, который заполняется динамически на основе данных о том, где будет разворачиваться приложение.
Приложение считывает созданный конфиг и берет из него те настройки, которые нужно применить. Как только мы загрузили эту конфигурацию, приложение видит дочерние микрофронты и из этого собирает карту роутинга.
Конфигурация — это простой массив объектов:
[
{
"remoteEntry": "http://localhost:4201/remoteEntry.js",
"remoteName": "main",
"exposedModule": "./Module",
"displayName": "navigation.main",
"routePath": "main",
"ngModuleName": "RemoteEntryModule"
},
{
"remoteEntry": "http://localhost:4202/remoteEntry.js",
"remoteName": "stores",
"exposedModule": "./Module",
"displayName": "navigation.stores",
"routePath": "stores",
"ngModuleName": "RemoteEntryModule"
}
]
Почти все поля перекликаются с Webpack, в котором мы все описываем. У Module Federation есть несколько enum и интерфейсов, которые позволяют типизировать объекты.
Мы берем параметр, делаем свой тип, добавляем туда переменные, которые нам нужны, расширяем как нам нужно и описываем в этом конфиге.
import {LoadRemoteModuleOptions} from '@angular-architects/module-federation';
export type Microfrontend = LoadRemoteModuleOptions & {
remoteName: string;
displayName: string;
routePath: string;
ngModuleName: string;
};
Код загрузчика:
export class MicrofrontendLoaderService {
constructor(
private readonly router: Router,
private readonly httpClient: HttpClient,
private readonly destroy$: TuiDestroyService
) {}
buildDynamicRoutes(): Observable<boolean> {
return this.resolveConfig().pipe(
takeUntil(this.destroy$),
tap(cfg =>
this.router.resetConfig(
buildApplicationRoutes(cfg),
),
),
mapTo(true),
)
}
private resolveConfig(): Observable<Microfrontend[]> {
return this.httpClient.get<Microfrontend[]>('/assets/config/mf/config.json')
}
}
Первое, что делает загрузчик, — идет за конфигом. Конфиг лежит локально рядом с приложением на этапе деплоя, и приложение его считывает. Дальнейший план — отдельно управлять этой конфигурацией, то есть унести его на S3 и кэшировать.
Приложение на старте вычитывает конфиг и передает в специальную функцию. На выходе получается массив с роутами, которые нужно преобразовать и построить дерево. Чтобы этот конфиг загрузить, мы в angular вешаем его загрузку на хук APP Initializer:
providers: [
{
provide: APP_INITIALIZER,
useFactory: (microfrontendService: MicrofrontendService) => () => microfrontendService.buildDynamicRoutes(),
deps: [MicrofrontendService],
multi: true,
},
],
Функцию, которая все перетряхивает, я назвал router shaker. Она может выглядеть немного громоздкой, потому что в итоге возвращает массив объектов, который выглядит точно так же, как дефолтный конфиг для роутинга:
import {loadRemoteModule} from '@angular-architects/module-federation';
export function buildApplicationRoutes(options: Microfrontend[]): Routes {
const mfRoutes: Routes = Array.from(options).map(o => ({
path: o.routePath,
loadChildren: () => loadRemoteModule(o).then(m => m[o.ngModuleName]),
canActivate: [AuthGuard],
}));
return [
...mfRoutes,
{
path: '',
redirectTo: 'main',
pathMatch: 'full',
},
{
path: '**',
redirectTo: '404',
pathMatch: 'full',
},
];
}
Самое главное здесь — запись, которая берет наш массив объектов из конфига и превращает его в массив объектов, нужный для роутера:
const mfRoutes: Routes = Array.from(options).map(o => ({
path: o.routePath,
loadChildren: () => loadRemoteModule(o).then(m => m[o.ngModuleName]),
canActivate: [AuthGuard],
}));
Здесь могут появиться языко-специфические истории, но, я думаю, все знают, как вставить динамически новый роут в приложение.
Теперь у нас есть конфигурационный файлик, который лежит рядом с микрофронтом, и мы динамически все загружаем. Можно сказать, что мы — динамические микрофронтендеры и можем этим управлять.
Плюсы динамических загрузок — вместо заключения
У динамических загрузок три основных плюса:
Кэширование конфига в коде при обращении.
Конфиг как отдельная репа с CI/CD и безрелизное добавление новых сервисов.
CDN для раздачи конфига.
Кэширование — чтобы не гонять постоянно файлик. Он будет меняться редко, поэтому нам нужно кэширование. И кэширование внутри кода, потому что рано или поздно этот список объектов внутри кода понадобится, чтобы хотя бы хедер отрисовать динамически.
Для одной из задач нам понадобилось подложить конфиг в storage, чтобы доставать его из других приложений, минуя Angular. Бью себя по рукам, но лучшего решения не найти.
Приложение большое, и работает над ним не только моя команда, поэтому конфиг улетел в отдельный репозиторий со своим CI/CD, деплоем в CDN. В любой момент я могу прийти, потушить одно из приложений закомментировав его или поставив флаг disable, задеплоить на прод host-приложение, которое использует этот конфиг, и сразу же его подтянут и будут использовать. CDN нужен, чтобы все было быстро, хорошо кэшировалось и мы не думали о каких-либо проблемах.
Вот такие динамические пирожочки второй части, а в третьей расскажу про фолбэки. Не переключайтесь!