Как мы распилили монолит. Часть 4. И как Angular между приложениями пошарили

6d3a8598200db1630e018fd1d6a2b900.png

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

Если коротко, то мы создали решение, которое позволило в рамках одной открытой страницы браузера запускать несколько независимых Angular-приложений, шарить между ними данные, управлять роутингом и аутентификацией. Мы научились бороться с утечками памяти и решать конфликты глобальных стилей приложений. Но одна проблема оставалась открытой — каждое приложение несло в своем банде Angular, RxJS, zone.js и т. д. И в этой статье я расскажу, как мы ее решили.

Исследование

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

Итак, дано:

  • десятки приложений, созданных с помощью Angular CLI или nx;

  • доступ к конфигурации webpack через кастомные билдеры;

  • артефакты сборок webpack каждого приложения;

  • одна страница браузера.

Минимальная цель: запустить дочерние приложения на Angular, на котором уже работает Frame Manager.

Мы выделили для себя четыре гипотетических варианта решения проблемы:

  1. Monorepo.

  2. Micro application like a package.

  3. Webpack Externals.

  4. Webpack Module Federation.

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

Monorepo & Micro app like a package

Monorepo — это подход, когда все приложения и библиотеки хостятся в одном репозитории. 

Micro app like a package — имеется в виду подход, когда каждое приложение собирается как npm-пакет и устанавливается в host-приложение.

Плюсы и минусы обоих подходов схожи (поэтому они и объединены в одном пункте).

Минусы

  • Максимально возможная оптимизация бандла средствами Angular.

  • Одни и те же версии библиотек у всех приложений.

  • Отсутствие костылей с инициализацией.

Плюсы

  • Всегда нужно релизить все.

  • Миграции всех приложений при апдейте зависимостей.

  • Много инфраструктурных изменений.

Вывод напрашивается сам (хотя он был понятен еще до осознания этих плюсов/минусов) — оба решения совершенно нам не подходят.

Webpack Externals

Webpack Externals — это конфигурационная опция webpack, позволяющая исключать зависимости из бандла. Если в пользовательском окружении есть доступ к библиотеке, например через глобальные переменные, то эта опция как раз для такого случая.

Плюсы

  • Можно добиться экстремально маленького бандла. То есть взять и исключить вообще все внешние библиотеки, оставив только код самого приложения.

Минусы

a56bc2ebbed0643cf4dc3b0d591adaa6.jpg

И опять неутешительный вывод — нам необходимо решение чуть более сложное чем просто вырезать Angular из бандла.

Webpack Module Federation

Webpack Module Federation — это подход, когда несколько отдельных сборок формируют одно приложение. Эти отдельные сборки не должны иметь зависимости друг от друга, поэтому их можно разрабатывать и развертывать индивидуально. Звучит многообещающе!

Плюсы

Минусы

  • На момент исследования Angular не поддерживал webpack 5. Сейчас поддерживает экспериментально.

  • На момент исследования никто не знал, когда Angular будет поддерживать webpack 5. На момент публикации, можно сказать, ничего не изменилось.

Итого: нестабильность, неопределённость и разочарование.

Результат исследования

Нам не подошло ни одно из существующих решений.

f3555a7fa3aeec2ce2363d8aed9a15af.jpeg

Исследование 2.0

Да, мы запустили еще одно исследование, но уже на основе результатов предыдущего мы составили список требований к будущему решению:  

  1. Приложения остаются самодостаточными. Каждое приложение продолжает так же независимо разрабатываться и имеет возможность самостоятельно запускаться как изолированно в iframe, так и без него.

  2. Архитектурные изменения минимальны. Решение быстро и безболезненно интегрируется в любое приложение без глобальных изменений в организации кода и кодовой базы.

  3. Приемлемое время и сложность имплементации.

  4. Фолбэки. В этом пункте под фолбэками мы имеем в виду возможность приложения самостоятельно догрузить библиотеку для работы, если никакое другое приложение этой библиотекой еще не поделилось. 

  5. Библиотеки разных версий живут в своих бандлах.

В итоге этого исследования решение все-таки было найдено и через три дня уже был готов прототип. 

d9bdf0f715a5b0d7d69323768c9fde59.jpg

@tinkoff/shared-library-webpack-plugin

Мы сделали webpack-плагин, который отвечает всем нашим требованиям. Если коротко, то плагин добавляет еще одну сущность в бандлы, которую мы назвали shared chunks. Эти сущности умеют использовать все приложения, собранные с помощью плагина. Если подробнее, то стоит начать с основных сущностей webpack.

Основные сущности webpack, которые выделили мы:

  • Runtime — отвечает за запуск приложения, поставляет такие функции, как require, также умеет загружать lazy chunks и т. д.

  • Entries — чанки, содержащие точку входа приложения.

  • Lazy chunks — ленивые чанки, загружаемые в приложение по требованию.

Плагин добавляет еще одну сущность:

  • Shared chunks — отдельный чанк, содержащий библиотеки, которые могут использоваться несколькими приложениями.

Как выглядит сборка на примере Angular 

Допустим, мы имеем два приложения, сгенерированных с помощью Angular CLI. С помощью любого кастомного билдера с поддержкой модификации конфигурации webpack и в оба приложения добавляем плагин со следующими настройками:

29999449ca62acb44ee87046d93b77c4.png

В настройках мы указываем, что приложение будет делиться всеми используемыми пакетами Angular (@angular/**), а также zone.js. Артефакты такой сборки будут выглядеть примерно так.

229f297a0c2cf5d8acdfffd8c28e5edb.png

Здесь мы видим, что каждый пакет Angular выделен в свой чанк, как и библиотека zone.js. Когда первое приложение загрузится, оно запросит все свои чанки, включая Angular и zone.js, и запустится.

При загрузке второго приложения будут запрошены runtime, main и polyfills. И… приложение запустится. Никакой повторной загрузки Angular и zone.js. Второе приложение использует уже загруженные ранее экземпляры.

Как плагин работает

При сборке плагин:

  1. Анализирует ресурсы и ищет библиотеки, которые указаны в настройках.

  2. Выделяет библиотеки для шаринга в отдельные чанки (те самые shared chunks), хэширует имена с учетом версии.

  3. Учит entries и runtime работать с shared chunks и сообщает каждому entry, какие shared chunks ему нужны для работы.

Как загружается приложение

  1. На клиенте загружается runtime и entries. Тут все стандартно.

  2. Каждая точка входа проверяет наличие обязательных для запуска шареных библиотек и сообщает о результатах runtime.

  3. Runtime скачивает недостающие библиотеки и маркирует их как загруженные.

  4. Runtime запускает приложение.

Кажется, что все просто. Но, как правило, просто только для автора кода. И то только первые пару месяцев.

А что насчет библиотек с разными версиями?

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

Это значит, что для модулей @angular/core@10.0.0 и @angular/core@10.0.1 будет сформировано одинаковое имя — angularCore-10.0. Мы видим, что фикс-версия просто отсутствует. Именно поэтому чанки, имеющие одинаковые имена, грузятся единожды.

Если третье приложение имеет в зависимостях @angular/core/@9.x.x, то для shared chunk будет сформировано имя angularCore-9.x. Понятно, что в таком случае приложение загрузит свою версию библиотеки и будет работать с ней.

В случае проблем совместимости стандартное поведение формирования имени чанка можно изменить тремя параметрами: chunkname, suffix и separator. 

Demo

Самым нетерпеливым — ссылка на репозиторий

e05555004b29303175dafa3cea48c68d.png

Дано:

  • Хост-приложение. В него входит верхний тулбар и навигация слева.

  • Два дочерних приложения. Каждое из них состоит из тулбара и некоего форматированного текста. В нашем случае это отрисованные md-файлы.

То есть на клиента загружается хост-приложение, которое в зависимости от роута подгружает дочернее приложение, предварительно подготавливая для него окружение.

Каждое приложение несет в себе свой экземпляр Angular, zone.js и т. д. Общий вес JavaScript после загрузки на клиента всех трех приложений составит 282.8kb в gzip.

68bbd8f9f451f0ba51f69d2f3da7eb2a.png

В каждом приложении в сборку включаем плагин со следующими настройками:

const {
  SharedLibraryWebpackPlugin,
} = require('@tinkoff/shared-library-webpack-plugin');

module.exports = {
  plugins: [
    new SharedLibraryWebpackPlugin({
      libs: [
        '@angular/core',
        '@angular/common',
        '@angular/common/http',
        '@angular/platform-browser',
        '@angular/platform-browser/animations',
        '@angular/animations',
        '@angular/animations/browser',
        'zone.js/dist/zone',
      ],
    }),
  ],
};

Из конфигурации видно, что мы хотим пошарить между приложениями основные модули Angular и zone.js. Собираем, запускаем, открываем браузер и видим ужасную картину, когда размер хост-приложения увеличился на 58%(!). 

4ef6b3cd6d38f5041a68163338bccc5d.png

Так происходит из-за отключения tree shaking для shared chunks. Причина отключения очень проста: заранее неизвестно, какую часть библиотеки будет использовать другое приложение.

Но при загрузке дочерних приложений мы видим совсем другую картину. Они стали грузить на ≈70% меньше JavaScript для запуска, так как Angular и zone.js уже были загружены. Общий размер загружаемого JavaScript упал на 19%.

Но можно ли еще уменьшить количество загружаемого кода? Оказывается, можно, стоит лишь чуть больше поиграть с webpack.

Используемые экспорты

Как я писал выше, мы блокируем возможность webpack вырезать неиспользуемый в приложении код, потому что никогда заранее не знаем, какую часть шареной библиотеки будет использовать дочернее приложение. А что, если знаем?

В теории хост-приложение должно поставить на клиента шареную библиотеку, которая содержит объединение экспортов всех дочерних приложений. С этой мыслью мы добавили новую опцию в конфигурацию плагина — usedExports, которая принимает массив строк. Фактически это перечисление экспортов из библиотеки, которые webpack должен включить в shared chunk в дополнение к уже используемым в приложении.

Итак, пора проверить, как это работает на нашем демо. Для этого мы модифицируем настройки плагина:

const {
  SharedLibraryWebpackPlugin,
} = require('@tinkoff/shared-library-webpack-plugin');

module.exports = {
  plugins: [
    new SharedLibraryWebpackPlugin({
      libs: [
        { name: '@angular/core', usedExports: [] },
        { name: '@angular/common', usedExports: [] },
        { name: '@angular/common/http', usedExports: [] },
        { name: '@angular/platform-browser', usedExports: ['DomSanitizer'] },
        { name: '@angular/platform-browser/animations', usedExports: [] },
        { name: '@angular/animations', usedExports: [] },
        { name: '@angular/animations/browser', usedExports: [] },
        'zone.js/dist/zone',
      ],
    }),
  ],
};

Пустой массив в usedExports означает, что webpack включит в shared chunk только экспорты, используемые в самом приложении. Опытным путем мы узнали, что для корректной работы одному из приложений понадобится DomSanitizer из модуля @angular/platform-browser, что мы тоже отражаем в конфигурации.

Собираем, запускаем, открываем браузер и видим… 

84e74e1c57f35644c1dbd7da13bfdd81.png

Хост-приложение грузит 129kb, что на 12kb больше, чем загружает хост-приложение, собранное без плагина. Но что с остальными приложениями? Они грузят все те же ≈23kb. Итого суммарно на клиента попадает на 38% меньше JavaScript кода. То есть было 282.8kb, а стало 174.6kb. Неплохо? Кажется, что немного лучше, чем неплохо, и даже хорошо! Для 20+ приложений со схожей архитектурой количество профита будет еще больше!

Ниже привожу табличку для наглядности сборки демо без плагина, с плагином и с usedExports.

dd8aa4af3b598f5704918a9e9db95e65.png

Подведем итоги

При разработке плагина мы оглядывались на выявленные в ходе первого исследования требования и как результат — все требования соблюдены и учтены (ставим мысленные пять чеков к пунктам из абзаца с требованиями). Количество загружаемого на клиента JavaScript-кода уменьшено. Дочерние приложения быстрее загружаются и инициируются, потребляют меньше памяти и вычислительных ресурсов. Мы перестали бояться webpack. И обогнали появление Webpack 5 в Angular на полгода. Тут также нужно учесть, что сейчас это даже не стабильная связка.

На этой ноте наше повествование о распиливании монолита официально закончено. Мы были рады поделиться своим опытом и будем рады почитать про ваш! Больше хардкорных тем в ленту «Хабра»!

Полезные ресурсы

Здесь оставлю ссылки на предыдущие части

Как мы распилили монолит. Часть 1

Привет, меня зовут Ваня. Я решаю архитектурные задачи на фронтенде в Тинькофф Бизнесе и сейчас расск…

habr.com

Как мы распилили монолит. Часть 2, Frame Manager

Привет, меня зовут Стас, я работаю в команде Тинькофф Бизнеса. В прошлой статье мой коллега Ваня рас…

habr.com

Как мы распилили монолит. Часть 3, Frame Manager без фреймов

Привет. В прошлой статье я рассказал про Frame manager — оркестратор фронтовых приложений. Описанная…

habr.com

© Habrahabr.ru