Привет! С вами снова Максим. Во второй части будет о том, что мы придумали.

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

Как настраивать Module Federation

Мы поставили цель вынести все страницы ЛК в отдельные изолированные приложения.

Главную страницу, операции, магазины и многое другое решили вынести отдельно
Главную страницу, операции, магазины и многое другое решили вынести отдельно

В терминах Module Federation есть такие приложения:

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

Remote — микрофронт, который выполняет определенную задачу в нашем ЛК, например позволяет работать с транзакциями. Такой тип приложений может работать в двух режимах с host-приложением: встраиваться в него и standalone — когда мы хотим запустить эту часть изолированно от всего, мы сами должны получать пользователей и данные по ним.

Есть хедер — часть host-приложения, есть футер и блок — то место, куда встраивается remote. То есть личный кабинет очень-очень хорошо ложится на эту историю
Есть хедер — часть host-приложения, есть футер и блок — то место, куда встраивается remote. То есть личный кабинет очень-очень хорошо ложится на эту историю

Такой исход монолита в микросервисы задевает не только фронтов, бэк-разработчики чаще нас используют микросервисный подход при построении архитектуры своих решений. А как известно, чем больше микросервисов, тем сложнее этим управлять и взаимодействовать с ними с фронта. И тут мы понимаем, что уже 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-приложение, получается матрешка: микрофронтенд внутри микрофронта. Может показаться, что такой кейс выдуман, но на самом деле это реальный бизнесовый продовый кейс, который успешно реализован.

Управление динамикой

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

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

На выходе получаем схему: хост приложения идет за конфигом, передает конфигурацию angular- роутеру или react-роутеру и все работает хорошо
На выходе получаем схему: хост приложения идет за конфигом, передает конфигурацию angular- роутеру или react-роутеру и все работает хорошо

Конфигурация — это простой массив объектов:

[
  {
  "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],
}));

Здесь могут появиться языко-специфические истории, но, я думаю, все знают, как вставить динамически новый роут в приложение.

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

Плюсы динамических загрузок — вместо заключения

У динамических загрузок три основных плюса:

  1. Кэширование конфига в коде при обращении.

  2. Конфиг как отдельная репа с CI/CD и безрелизное добавление новых сервисов.

  3. CDN для раздачи конфига.

Кэширование — чтобы не гонять постоянно файлик. Он будет меняться редко, поэтому нам нужно кэширование. И кэширование внутри кода, потому что рано или поздно этот список объектов внутри кода понадобится, чтобы хотя бы хедер отрисовать динамически.

Для одной из задач нам понадобилось подложить конфиг в storage, чтобы доставать его из других приложений, минуя Angular. Бью себя по рукам, но лучшего решения не найти. 

Приложение большое, и работает над ним не только моя команда, поэтому конфиг улетел в отдельный репозиторий со своим CI/CD, деплоем в CDN. В любой момент я могу прийти, потушить одно из приложений закомментировав его или поставив флаг disable, задеплоить на прод host-приложение, которое использует этот конфиг, и сразу же его подтянут и будут использовать. CDN нужен, чтобы все было быстро, хорошо кэшировалось и мы не думали о каких-либо проблемах.

Вот такие динамические пирожочки второй части, а в третьей расскажу про фолбэки. Не переключайтесь! 

Комментарии (0)