В первой, второй и третьей частях мои коллеги рассказали, как и почему мы распиливали монолит.
Если коротко, то мы создали решение, которое позволило в рамках одной открытой страницы браузера запускать несколько независимых 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 на полгода. Тут также нужно учесть, что сейчас это даже не стабильная связка.
На этой ноте наше повествование о распиливании монолита официально закончено. Мы были рады поделиться своим опытом и будем рады почитать про ваш! Больше хардкорных тем в ленту «Хабра»!
Полезные ресурсы
Здесь оставлю ссылки на предыдущие части
Комментариев нет:
Отправить комментарий