Всё и сразу: автоматическая проверка размера бандла
Привет всем, меня зовут Илья. В ИТ я работаю около 6 лет, последние 2 года — в компании «Яндекс.Деньги» фронтенд-разработчиком. В обязанности входит поддерживать/развивать части приложений, в данный момент проект «Личный кабинет» (и нет, это не просто «в ие неправильные отступы поправить», «кнопке цвет поменять» или «быстро форму сошлепать»).
В отделе разработки у нас есть внутренний проект Challenges — так мы называем задачи, не связанные напрямую с основными продуктами. Это задачки, которые будут общественно полезны, помогут улучшить инфраструктуру, CI/CD и другое. Например, доработать бота, оповещающего о релизах, сделать плагин для IDEA по автогенерации assert по объекту и провести рефакторинг старой UI-библиотеки. Такие «вызовы» можно брать по желанию — они прекрасно подходят для разбавления бизнес-задач.
В один прекрасный момент я взял давно интересующую меня задачку по реализации автоматической проверки размера клиентского бандла приложения. Ведь всё, что можно проверять автоматически, лучше проверять автоматически.
Естественно, итогом стала не просто конкретная реализованная задача, а библиотека с правилами и общими функциями (ядро/core) для реализации любых чекеров (я надеюсь). В процессе решения задачки были набиты шишки, о которых тоже хочется рассказать.
Итак, есть Jenkins, Bitbucket, почта (или Telegram, Slack — что понадобится) и необходимость проверять размер бандла. Надо это всё подружить.
В Bitbucket для каждого pull request есть 2 автоматические проверки, без которых невозможен merge: задача в Jira переведена в статус «Проверена», и все автоматические проверки в Jenkins запущены и завершены успешно. Дальше по настроенному в Bitbucket хуку в Jenkins приходят оповещения об изменении ветки, для которой запускается вся пачка проверок. Джоба с проверкой (commit_checker) имеет множество pipeline:
- запуск lint-скрипта;
- запуск тестов;
- проверка версии пакета — для библиотек важно, чтобы в новом pull request была повышена версия, так как после merge автоматически будет запущен build и publish в Nexus, локальный npm;
- проверка отсутствия пререлизных версий библиотек в package.json.
Когда всё завершается, по хуку Bitbucket информирует об успешном или не очень успешном завершении commit_checker. В случае ошибок также приходит письмо с пояснениями, что нужно поправить, или ссылкой на лог. В этой challenge-задаче мне нужно было расширить commit_checker и добавить ещё один pipeline для проверки размера бандла.
Так как лучше подойти к организации кода checker-ов для фронтенд-приложений?
Всё началось со сбора полных требований и хотелок от разработчиков. Я узнал, что в результате хочется видеть инфу о размере бандла (какая неожиданность) — сколько драгоценных байтов принёс к бандлу конкретный pull request. Точнее, видеть оповещение, если увеличение подозрительно большое. Например, обычная фронтовая ситуация: тянем новую, классную, супермодную либу, чтобы сложить два числа, которая весит 3 Мб. Также хорошо будет знать, не выходит ли общий размер продовского бандла за пределы разумного максимума (то есть получать оповещение с ругательствами). Ну и, конечно, блокировать merge до починки.
После этого я собрал информацию о текущем состоянии. Посоветовавшись с коллегами, выбрали итоговые размеры, к которым стремимся: для всех есть общее правило в 600 Кб (это, безусловно, огромный бандл, но с чего-то начинать надо).
Теперь появилась цель — сервис, который шлёт оповещения, если в pull request подозрительно увеличился размер бандла, либо блокирует merge и шлёт оповещения, если превышено максимальное значение. Всё просто.
Надо было понять, как это всё лучше запилить. Следовало поискать подходы и реализации, как добавляются новые проверки, через какой API шлются оповещения (письма в почте, Telegram). Мне посоветовали пару существующих библиотек, и я начал копаться в них.
Здесь есть несколько важных моментов. Мы используем Jenkins для запуска проверок и job с деплоем на тестовую схему или началом релиза (каждый микросервис имеет свой набор данных для job). Есть groove-скрипты для получения части информации и выполнения определенной проверки, а есть npm-пакеты с логикой проверки, и в groove просто запускается bin пакета. Джоба дженкинса либо успешно, либо не очень завершается — срабатывают хуки, и Bitbucket понимает, можно ли совершить merge или нет.
Значит, надо понять, где лучше реализовывать логику проверки: в groove-скриптах или на node в новом пакете — размазывать дальше это волшебство не хочется.
Что же лучше: Groove или Node?
Исходя из этого, решил писать чекер на Node.
После изучения существующих библиотек, я отметил хорошие практики и сделал новую библиотеку. После реализации я увидел общую логику (отправка писем, поиск задач по Jira), которая в каждом чекере создавалась заново копипастой. Значит, нужна общая либа.
Первая попытка
Так появилось ядро, checker-core. Модуль собрал в себе всю общую логику, документацию о том, как создавать новые чекеры.
Теперь настала очередь логики проверки размера бандла. Первая идея — проверять в лоб: собирать прод-бандл, смотреть в папки/файлы, указанные как dist, и проверять их вес, сравнивать с максимальным. На паре приложений отдебажил мелкие ошибки — работает.
Следующая часть — проверка единовременного увеличения от pull request.
Первый вопрос — где хранить предыдущий размер бандла? Варианты такие:
- Собирать сначала ветку dev/master (рабочая версия), потом собирать новую фича-ветку и их сравнивать. Проблема — будет очень долго.
- Хранить во внешнем сервисе эти данные и запрашивать/сохранять во время проверок. Проблема — сейчас это долго и сложно, должен быть путь проще.
- Использовать Git и сохранять данные, например, в package.json, затем получать по API Bitbucket информацию из ветки dev/master и сравнивать с данными, полученными во время проверки. Отлично, подходит.
Второй вопрос —, а когда сохранять новый размер?
Можно, конечно, в прекоммите, но люди его могут и пропустить — это ненадёжно. Самый надёжный способ — во время проверки. Мне показалось, что это просто, поэтому я так и сделал.
Теперь чекер умеет собирать приложение, определять размер бандла, сверять с максимальным и предыдущим и обновлять в BitBucket (в пул реквест приедет комит от Jenkins с обновлённым package.json). И тут я его запустил…
Я думал, что всё идёт хорошо, но почти тут же повалились письма с темой: «Алярм!», и, естественно, всё пошло не так, и раскалённый стул доставил меня почти до Марса.
Что же произошло?
Первая проблема. Приложения-то проверялись, а вот библиотеки проверять не нужно было: их у нас много, а в серверных библиотеках и бандла-то нет.
Вторая проблема. В моём гениальном плане по отправке коммита с новым размером было одно упущение: хук, который запускает проверки для ветки, срабатывает после отправки нового коммита в битбакет… Увидев все оповещения в почте и вопросы людей в общих чатах: «А что с Jenkins? Что-то он виснет», — я понял, что положил всем Jenkins (но быстро всё починил). Сделал коммит → запустил проверку → проверка сделала комит → и всё заново.
И третья, последняя проблема. Все наши приложения имеют несколько подприложений, иными словами, есть несколько бандлов под разные группы страниц (группировка, уменьшение размера бандла). Так вот, я считал размер всех бандлов вместе.
Вторая попытка
Теперь надо убрать проверку для библиотек (изи, просто не запущу для них) и разницы размера бандла с девом и доработать проверку максимального размера.
Мне рассказали о модуле size-limit: он делает то, что мне надо, и я могу использовать информацию по всем отдельным бандлам для сравнения.
Переписываю. Отлично, проверяю… Всё заработало!
Теперь можно собрать всю информацию, установить максимумы и составить roadmap для уменьшения размеров бандлов.
Третья попытка
Пока наводил красоту в коде чекеров, я увидел, что size-limit обновился и там появились новые фичи, — круто, можно и обновить. Но, к сожалению, в нём ушло API взаимодействия из кода, и осталась только CLI. Оукей. Тогда придётся запускать cli-команды и обрабатывать их результат. Это и стало третьим переписыванием.
Теперь, наконец-то, можно запускать, тестировать и собирать фидбэк, чтобы улучшить взаимодействие.
Checker-core
Он имеет нехитрую структуру и документацию!
/src — всё содержимое чекера
|__ /tasks — обособленные и переиспользуемые при необходимости действия; например, получение метаинформации, отправка нотификации, могут использовать утилиты, выбрасывать исключения. Нужны для выполнения какой-то `job`.
|__ /models — модели, используемые в чекере; здесь происходит расширение моделей из `core`. Сущность, агрегирующая в себе информацию об объекте, например, npm-модуль. Регистрируется одна на `job`, все `tasks` используют инстанс модели, созданной в `job,` и мутируют его. Иначе говоря, это агрегатор данных по описываемой сущности для переиспользования между `tasks` и внутри `job`.
|__ /types — общие типы.
|__ /utils — вспомогательные утилиты (например, логгер, фильтрация версий пакетов (npm)).
|__ /errors — ошибки, которыми оперирует чекер.
|__ /enums — общие перечисления.
|__ /decorators — декораторы, используемые в `utils`, `tasks`, `job`.
|__ /declarations — определение модулей, не имеющих типов для Typescript.
Модели
Checker
Модель Checker содержит основные методы, необходимые для чекера. Экземпляр этой модели используется для передачи данных между тасками и получения информации о приложении, зависимых либах, лиде и т.п.
Module
Модель Module используется для работы с конкретным сервисом/библиотекой, которая будет проверяться. Она может содержать специфичные проверки (сравнение бандла, сверка версий и т.п.).
Таски
get-issue-assignees
Получение ответственных за последнюю вмерженную задачу. В модель Checker устанавливается информация о project, assignee, lead.
Examples
import {
Checker,
Logger,
IChecker,
JiraManagerSettings,
GetIssueAssignee
} from 'checker-core';
import {JiraSettings} from 'jira-manager';
import {IJobSettings} from '../types/IJobSettings';
export class ExampleJob {
readonly _checker: IChecker;
_jiraSettings: JiraManagerSettings;
constructor(settings: IJobSettings) {
if (settings === void 0) {
throw new Error('Has no Checker settings');
}
this._checker = new Checker(settings.checker);
this._jiraSettings = new JiraSettings(settings.jira);
Logger.info('-- ExampleJob:');
Logger.info(this._checker.appName);
}
start() {
const getIssueAssignee = new GetIssueAssignee();
return getIssueAssignee.run(this._checker, this._jiraSettings);
}
}
get-last-merged-issue
Получение последней вмерженной задачи. В модель Checker устанавливается информация о последней вмерженной задаче.
Examples
import {Checker, Logger, IChecker, GetLastMergedIssue} from 'checker-core';
import {IJobSettings} from '../types/IJobSettings';
export class ExampleJob {
readonly _checker: IChecker;
constructor(settings: IJobSettings) {
if (settings === void 0) {
throw new Error('Has no Checker settings');
}
this._checker = new Checker(settings);
Logger.info('-- ExampleJob:');
Logger.info(this._checker.appName);
}
start() {
const getLastMergedIssue = new GetLastMergedIssue();
return getLastMergedIssue.run(this._checker);
}
}
get-last-commit-issue
Получение последней закоммиченной задачи. В модель Checker устанавливается информация о последней закоммиченной задаче.
Examples
import {Checker, Logger, IChecker, GetLastCommitIssue} from 'checker-core';
import {IJobSettings} from '../types/IJobSettings';
export class ExampleJob {
readonly _checker: IChecker;
constructor(settings: IJobSettings) {
if (settings === void 0) {
throw new Error('Has no Checker settings');
}
this._checker = new Checker(settings);
Logger.info('-- ExampleJob:');
Logger.info(this._checker.appName);
}
start() {
const getLastCommitIssue = new GetLastCommitIssue();
return getLastCommitIssue.run(this._checker);
}
}
send-notification и send-smtp-notification
Посылка уведомлений на почту. Посылает переданный контент письма ответственному за последнюю задачу и на общую рассылку.
Разница между send-notification и send-smtp-notification — в способе отправки. Для smtp-письма нужно передать параметры по интерфейсу IMailTransportSettings.
Examples
import {
Checker,
Logger,
IChecker,
IMailTransportSettings,
IMailContent,
SendSmtpNotification
} from 'checker-core';
import {IJobSettings} from '../types/IJobSettings';
export class ExampleJob {
readonly _checker: IChecker;
_emailSettings: IMailTransportSettings;
constructor(settings: IJobSettings) {
if (settings === void 0) {
throw new Error('Has no Checker settings');
}
this._checker = new Checker(settings.checker);
this._emailSettings = settings.email;
Logger.info('-- ExampleJob:');
Logger.info(this._checker.appName);
}
start() {
const sendSmtpNotification = new SendSmtpNotification();
const mailContent: IMailContent = {
subject: 'Долг',
content: 'Где деньги, Лебовски?'
};
return sendSmtpNotification.run(this._checker, mailContent, this._emailSettings);
}
}
Типы
Содержит основные используемые типы: объекты для передачи настроек, интерфейсы моделей и утилит.
Утилиты
Logger
Утилита для логирования.
Npm
Утилита для получения данных из Nexus.
mailTransport
Утилита для выбора способа отправки письма (нативно через mail linux или через модуль nodemailer по smtp).
Decorators
logAsyncMethod
Декоратор для метода класса, используется для логирования входящих параметров и результата выполнения метода в режиме дебага.
Если process.env.IS_DEBUG_MODE включен, производит логирование.
validate
Декоратор для валидации параметров метода. Подробные примеры можно посмотреть в тестах для декораторов.
Examples
import {
notNullProp, validate, required,
validInfo, notNull, requiredProp
} from 'checker-core';
class TestSum {
@validate
static sum(
@required
@notNull
@requiredProp(['a'])
@notNullProp(['a'])
@validInfo('a', Object)
a: Object,
@required
@notNull
@requiredProp(['b'])
@notNullProp(['b'])
@validInfo('b', Object)
b: Object
) {
return a.a + b.b;
}
}
Errors
MissingRequiredArgument
Ошибки при валидации обязательного аргумента метода.
MissingRequiredProperty
Ошибки при валидации обязательного свойства в объекте, переданном в метод.
NullArgument
Ошибки при валидации аргумента метода, если он null.
NullProperty
Ошибки при валидации свойства в объекте, переданном в метод, если он null.
TypeMismatch
Ошибки при валидации аргумента на несоответствие типов с переданным.
bundle-size-checker
Структура для конкретных чекеров описана в доке checker-core и очень похожа на core, появляется несколько новых сущностей:
/src — всё содержимое чекера
|__ /bin — исполняемые файлы, CLI чекера для запуска из консоли, выполняют 2 основные функции: подготавливают данные из аргументов запуска команды и запускают одну или несколько `job`. Также содержат логику результирующего действия на выполнение всей проверки, например, выход с сигналом 1 `process.exit(1)`;
|__ /jobs — класс, в котором инициализируются все необходимые модели (`checker` + те, что будут нужны), содержит логику выполнения конкретного действия необходимого для проверки. Могут использовать одну или несколько `task`, `utils`, также могут выбрасывать исключения;
|__ /tasks — обособленные и переиспользуемые при необходимости действия; например, получение метаинформации, отправка нотификации, могут использовать утилиты, выбрасывать исключения. Нужны для выполнения какой-то `job`;
|__ /models — модели, используемые в чекере, здесь происходит расширение моделей из `core`. Сущность, агрегирующая в себе информацию об объекте, например, npm-модуль. Регистрируется одна на `job`, все `tasks` используют инстанс модели, созданной в `job`, и мутируют его. Иначе говоря, это агрегатор данных по описываемой сущности для переиспользования между `tasks` и внутри `job`;
|__ /types — общие типы;
|__ /utils — вспомогательные утилиты (например, логгер, фильтрация версий пектов (npm));
|__ /errors — ошибки, которыми оперирует чекер;
|__ /enums — общие перечисления;
|__ /decorators — декораторы используемые в `utils`, `tasks`, `job`;
|__ /declarations — определение модулей, не имеющих типов для Typescript.
По сути, job — это класс, который получает все настройки, создает экземпляр checker-модели, запускает таски, переиспользуя их результат, а в конце, если упало с какой-то ошибкой, запускает таску, которая решает, как реагировать на ошибку (какое письмо послать, зафейлить чекер и т.п.).
Например:
import path from 'path';
import {JiraSettings} from 'jira-manager';
import {
Logger,
JiraManagerSettings,
IMailTransportSettings,
MissingRequiredProperty
} from 'checker-core';
import {Module} from '../models/module';
import {CompareBundleSize} from '../tasks/compare-bundle-size';
import {Checker} from '../models';
import {IJobCheckBundleSizeSettings} from '../types/IJobCheckBundleSizeSettings';
import {IModule} from '../types/IModule';
import {IModuleSettings} from '../types/IModuleSettings';
import {GetMetaInfo} from '../tasks/get-meta-info';
import {CheckBundleSizeNotEmpty} from '../tasks/check-bundle-size-not-empty';
import {GetBundleSize} from '../tasks/get-bundle-size';
import {ReadFileError} from '../errors/ReadFileError';
import {IPackageJson} from '../types/IPackageJson';
import {IChecker} from '../types/IChecker';
import {FilterException} from '../tasks/filter-exception';
// Описывает логику проверки размера бандла модуля.
export class CheckBundleSize {
readonly _checker: IChecker;
readonly _jobName = 'CheckBundleSize';
_moduleSettings: IModuleSettings;
_checkerModule: IModule;
_jiraSettings: JiraManagerSettings;
_emailSettings: IMailTransportSettings;
constructor(settings: IJobCheckBundleSizeSettings) {
if (settings.checker === void 0) {
throw new MissingRequiredProperty(this._jobName, 'constructor', 'settings', 'checker');
}
this._moduleSettings = settings.module;
this._emailSettings = settings.emailSettings;
this._jiraSettings = new JiraSettings(settings.jira);
this._checker = new Checker(settings.checker);
try {
const packageJsonPath = path.resolve(this._checker.rootPath, 'package.json');
const packageJson: IPackageJson = require(packageJsonPath);
this._checkerModule = new Module(
packageJson,
this._moduleSettings.maxBundleSize,
this._moduleSettings.needCheckBundleSize
);
} catch (err) {
Logger.info('Can nott load package.json');
throw new ReadFileError(this._jobName, 'package.json');
}
Logger.info(`${this._jobName}:`);
Logger.info(this._checker.appName);
}
// Выполняет проверку размера бандла модуля
async start() {
try {
const getBundleSizeTask = new GetBundleSize();
const getMetaInfo = new GetMetaInfo();
const checkBundleSizeEmpty = new CheckBundleSizeNotEmpty();
const compareBundleSize = new CompareBundleSize();
const getBundleSize = await getBundleSizeTask.run(this._checker, this._checkerModule);
if (!getBundleSize) {
return;
}
await getMetaInfo.run(this._checker, this._jiraSettings);
await checkBundleSizeEmpty.run(this._checker, this._checkerModule);
await compareBundleSize.run(this._checker, this._checkerModule);
} catch (err) {
const filterException = new FilterException(this._emailSettings);
await filterException.run(this._checker v , this._checkerModule, err);
throw err;
}
}
}
Эта джоба запускается в bin реализацию, которая отвечает за получение параметров из аргументов cli и передачу объекта с настройками в джобу.
…
const checkBundleSize = new CheckBundleSize(settings);
checkBundleSize.start()
.then(() => {
process.exit(0);
})
.catch((err: Error) => {
console.error(err);
console.error(`Can fail build: ${canFailBuild}`);
if (canFailBuild) {
process.exit(1);
} else {
process.exit(0);
}
});
Было очень круто и полезно погрузиться в весь процесс сборки/проверки приложений, понять и разобраться в местах, где недоставало понимания (ну, и поломать дженкинсы, гы). Приведение в порядок, унификация правил и документаций сами по себе принесли довольно много пользы: стало проще разобраться с существующим кодом, написать новый, плюс сделать лучше отрефакторенный код. Отзывы коллег и некоторые мои идеи помогли составить roadmap для улучшения и доработок. Фактически теперь добавление автопроверок стало простым и быстрым, обновление и доработки — тоже.
Уже несколько раз проверка размера бандла принесла свои плоды, когда неожиданный минор библиотеки вдруг принёс кучу картинок или поломал сборку.
Сначала эта проблема казалась мне точечной. И сейчас хочется спросить: сталкивались ли вы с такой же проблемой у себя? Если будет спрос, то готов вынести свое решение в open source.