Как мы распилили монолит. Часть 4. И как Angular между приложениями пошарили
В первой, второй и третьей частях мои коллеги рассказали, как и почему мы распиливали монолит.
Если коротко, то мы создали решение, которое позволило в рамках одной открытой страницы браузера запускать несколько независимых Angular-приложений, шарить между ними данные, управлять роутингом и аутентификацией. Мы научились бороться с утечками памяти и решать конфликты глобальных стилей приложений. Но одна проблема оставалась открытой — каждое приложение несло в своем банде Angular, RxJS, zone.js и т. д. И в этой статье я расскажу, как мы ее решили.
Исследование
Как правило, решение неочевидных проблем начинается с исследования. Нам предстояло исследовать уже существующие техники дедупликации загружаемых библиотек.
Итак, дано:
десятки приложений, созданных с помощью Angular CLI или nx;
доступ к конфигурации webpack через кастомные билдеры;
артефакты сборок webpack каждого приложения;
одна страница браузера.
Минимальная цель: запустить дочерние приложения на Angular, на котором уже работает Frame Manager.
Мы выделили для себя четыре гипотетических варианта решения проблемы:
Monorepo.
Micro application like a package.
Webpack Externals.
Webpack Module Federation.
Первые два способа — про организацию кода и не вяжутся с уже существующей архитектурой, которая была описана в предыдущих частях, но мы решили, что нужно рассмотреть все варианты, это же всего лишь исследование. Остальные два — про конфигурацию webpack и кажутся здесь более уместными. Ниже мы рассмотрим каждый из них.
Monorepo & Micro app like a package
Monorepo — это подход, когда все приложения и библиотеки хостятся в одном репозитории.
Micro app like a package — имеется в виду подход, когда каждое приложение собирается как npm-пакет и устанавливается в host-приложение.
Плюсы и минусы обоих подходов схожи (поэтому они и объединены в одном пункте).
Минусы
Максимально возможная оптимизация бандла средствами Angular.
Одни и те же версии библиотек у всех приложений.
Отсутствие костылей с инициализацией.
Плюсы
Всегда нужно релизить все.
Миграции всех приложений при апдейте зависимостей.
Много инфраструктурных изменений.
Вывод напрашивается сам (хотя он был понятен еще до осознания этих плюсов/минусов) — оба решения совершенно нам не подходят.
Webpack Externals
Webpack Externals — это конфигурационная опция webpack, позволяющая исключать зависимости из бандла. Если в пользовательском окружении есть доступ к библиотеке, например через глобальные переменные, то эта опция как раз для такого случая.
Плюсы
Можно добиться экстремально маленького бандла. То есть взять и исключить вообще все внешние библиотеки, оставив только код самого приложения.
Минусы
И опять неутешительный вывод — нам необходимо решение чуть более сложное чем просто вырезать Angular из бандла.
Webpack Module Federation
Webpack Module Federation — это подход, когда несколько отдельных сборок формируют одно приложение. Эти отдельные сборки не должны иметь зависимости друг от друга, поэтому их можно разрабатывать и развертывать индивидуально. Звучит многообещающе!
Плюсы
Минусы
На момент исследования Angular не поддерживал webpack 5. Сейчас поддерживает экспериментально.
На момент исследования никто не знал, когда Angular будет поддерживать webpack 5. На момент публикации, можно сказать, ничего не изменилось.
Итого: нестабильность, неопределённость и разочарование.
Результат исследования
Нам не подошло ни одно из существующих решений.
Исследование 2.0
Да, мы запустили еще одно исследование, но уже на основе результатов предыдущего мы составили список требований к будущему решению:
Приложения остаются самодостаточными. Каждое приложение продолжает так же независимо разрабатываться и имеет возможность самостоятельно запускаться как изолированно в iframe, так и без него.
Архитектурные изменения минимальны. Решение быстро и безболезненно интегрируется в любое приложение без глобальных изменений в организации кода и кодовой базы.
Приемлемое время и сложность имплементации.
Фолбэки. В этом пункте под фолбэками мы имеем в виду возможность приложения самостоятельно догрузить библиотеку для работы, если никакое другое приложение этой библиотекой еще не поделилось.
Библиотеки разных версий живут в своих бандлах.
В итоге этого исследования решение все-таки было найдено и через три дня уже был готов прототип.
@tinkoff/shared-library-webpack-plugin
Мы сделали webpack-плагин, который отвечает всем нашим требованиям. Если коротко, то плагин добавляет еще одну сущность в бандлы, которую мы назвали shared chunks. Эти сущности умеют использовать все приложения, собранные с помощью плагина. Если подробнее, то стоит начать с основных сущностей webpack.
Основные сущности webpack, которые выделили мы:
Runtime — отвечает за запуск приложения, поставляет такие функции, как require, также умеет загружать lazy chunks и т. д.
Entries — чанки, содержащие точку входа приложения.
Lazy chunks — ленивые чанки, загружаемые в приложение по требованию.
Плагин добавляет еще одну сущность:
Shared chunks — отдельный чанк, содержащий библиотеки, которые могут использоваться несколькими приложениями.
Как выглядит сборка на примере Angular
Допустим, мы имеем два приложения, сгенерированных с помощью Angular CLI. С помощью любого кастомного билдера с поддержкой модификации конфигурации webpack и в оба приложения добавляем плагин со следующими настройками:
В настройках мы указываем, что приложение будет делиться всеми используемыми пакетами Angular (@angular/**), а также zone.js. Артефакты такой сборки будут выглядеть примерно так.
Здесь мы видим, что каждый пакет Angular выделен в свой чанк, как и библиотека zone.js. Когда первое приложение загрузится, оно запросит все свои чанки, включая Angular и zone.js, и запустится.
При загрузке второго приложения будут запрошены runtime, main и polyfills. И… приложение запустится. Никакой повторной загрузки Angular и zone.js. Второе приложение использует уже загруженные ранее экземпляры.
Как плагин работает
При сборке плагин:
Анализирует ресурсы и ищет библиотеки, которые указаны в настройках.
Выделяет библиотеки для шаринга в отдельные чанки (те самые shared chunks), хэширует имена с учетом версии.
Учит entries и runtime работать с shared chunks и сообщает каждому entry, какие shared chunks ему нужны для работы.
Как загружается приложение
На клиенте загружается runtime и entries. Тут все стандартно.
Каждая точка входа проверяет наличие обязательных для запуска шареных библиотек и сообщает о результатах runtime.
Runtime скачивает недостающие библиотеки и маркирует их как загруженные.
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
Самым нетерпеливым — ссылка на репозиторий
Дано:
Хост-приложение. В него входит верхний тулбар и навигация слева.
Два дочерних приложения. Каждое из них состоит из тулбара и некоего форматированного текста. В нашем случае это отрисованные md-файлы.
То есть на клиента загружается хост-приложение, которое в зависимости от роута подгружает дочернее приложение, предварительно подготавливая для него окружение.
Каждое приложение несет в себе свой экземпляр Angular, zone.js и т. д. Общий вес JavaScript после загрузки на клиента всех трех приложений составит 282.8kb в gzip.
В каждом приложении в сборку включаем плагин со следующими настройками:
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%(!).
Так происходит из-за отключения 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, что мы тоже отражаем в конфигурации.
Собираем, запускаем, открываем браузер и видим…
Хост-приложение грузит 129kb, что на 12kb больше, чем загружает хост-приложение, собранное без плагина. Но что с остальными приложениями? Они грузят все те же ≈23kb. Итого суммарно на клиента попадает на 38% меньше JavaScript кода. То есть было 282.8kb, а стало 174.6kb. Неплохо? Кажется, что немного лучше, чем неплохо, и даже хорошо! Для 20+ приложений со схожей архитектурой количество профита будет еще больше!
Ниже привожу табличку для наглядности сборки демо без плагина, с плагином и с usedExports.
Подведем итоги
При разработке плагина мы оглядывались на выявленные в ходе первого исследования требования и как результат — все требования соблюдены и учтены (ставим мысленные пять чеков к пунктам из абзаца с требованиями). Количество загружаемого на клиента JavaScript-кода уменьшено. Дочерние приложения быстрее загружаются и инициируются, потребляют меньше памяти и вычислительных ресурсов. Мы перестали бояться webpack. И обогнали появление Webpack 5 в Angular на полгода. Тут также нужно учесть, что сейчас это даже не стабильная связка.
На этой ноте наше повествование о распиливании монолита официально закончено. Мы были рады поделиться своим опытом и будем рады почитать про ваш! Больше хардкорных тем в ленту «Хабра»!
Полезные ресурсы
Здесь оставлю ссылки на предыдущие части
Как мы распилили монолит. Часть 1
Привет, меня зовут Ваня. Я решаю архитектурные задачи на фронтенде в Тинькофф Бизнесе и сейчас расск…
habr.comКак мы распилили монолит. Часть 2, Frame Manager
Привет, меня зовут Стас, я работаю в команде Тинькофф Бизнеса. В прошлой статье мой коллега Ваня рас…
habr.comКак мы распилили монолит. Часть 3, Frame Manager без фреймов
Привет. В прошлой статье я рассказал про Frame manager — оркестратор фронтовых приложений. Описанная…
habr.com