В этой статье мы рассмотрим новый API Angular CLI, который позволит вам расширять существующие возможности CLI и добавлять новые. Мы обсудим, как работать с этим API, и какие существуют точки его расширения, позволяющие добавлять новый функционал в CLI.
История
Около года назад мы представили файл рабочей области (angular.json) в Angular CLI и переосмыслили многие базовые принципы реализации его команд. Мы пришли к тому, что разместили команды в «коробках»:
- Schematic commands – «Схематические команды». К настоящему времени, вероятно, вы уже слышали о Schematics – библиотеке, используемой CLI для генерации и изменения вашего кода. Она появилась в 5-й версии и, в настоящее время, используется в большинстве команд, которые касаются вашего кода, таких, как new, generate, add и update.
- Miscellaneous commands – «Прочие команды». Это команды, которые не относятся непосредственно к вашему проекту: help, version, config, doc. Недавно появилась ещё analytics, а также наши пасхалки (Тссс! Никому ни слова!).
- Task commands – «Команды задач». Эта категория, по большому счету, «запускает процессы, выполняемые над кодом других людей». – Как пример, build – сборка проекта, lint – отладка и test – тестирование.
Мы начали проектировать angular.json довольно давно. Изначально, он был задуман как замена Webpack-конфигурации. Кроме того, он должен был позволить разработчикам самостоятельно выбирать реализацию сборки проекта. В итоге, у нас получилась базовая система запуска задач, которая оставалась простой и удобной для наших экспериментов. Мы назвали этот API «Architect».
Несмотря на то, что Architect официально не поддерживался, он пользовался популярностью среди разработчиков, которые хотели настраивать сборку проектов, а также, среди сторонних библиотек, которым было необходимо контролировать их workflow. Nx использовал его для выполнения команд Bazel, Ionic использовал его для запуска unit-тестов на Jest, а пользователи могли расширять свои конфигурации Webpack-ов с помощью таких инструментов, как ngx-build-plus. И это было только начало.
Официально поддерживаемая, стабилизированная и улучшенная версия этого API используется в Angular CLI версии 8.
Концепция
Architect API предлагает инструменты для планирования и координации задач, которые используются в Angular CLI для реализации его команд. Он использует функции, называемые
«builder»-ами – «сборщиками», которые могут выступать в роли задач или же планировщиков других сборщиков. Кроме того, он использует angular.json в качестве набора инструкций для самих сборщиков.
Это очень общая система, созданная для того, чтобы быть гибкой и расширяемой. Она содержит API для построения отчетов, ведения логов и тестирования. При необходимости, систему можно расширять для новых задач.
Сборщики
Сборщики – это функции, реализующие логику и поведение для задачи, которая может заменить команду CLI. – Например, запустить линтер.
Функция сборщика принимает два аргумента: входное значение (или опции) и контекст, который обеспечивает взаимосвязь между CLI и самим сборщиком. Разделение ответственности здесь такое же, как в Schematics – опции задает пользователь CLI, за контекст отвечает API, а вы (разработчик) устанавливаете необходимое поведение. Поведение может быть реализовано синхронно, асинхронно, либо же просто выводить определенное количество значений. Вывод обязательно должен иметь тип BuilderOutput, содержащий логическое поле success и необязательное поле error, содержащее сообщение об ошибке.
Файл рабочей области и задачи
Architect API полагается на angular.json – файл рабочей области, для хранения задач и их настроек.
angular.json делит рабочую область на проекты, а их, в свою очередь, на задачи. Например, ваше приложение, созданное с помощью команды ng new, это один из таких проектов. Одной из задач в этом проекте будет задача build, которую можно запустить с помощью команды ng build. По-умолчанию, эта задача имеет три ключа:
- builder – имя сборщика, который необходимо использовать для выполнения задачи, в формате НАЗВАНИЕ_ПАКЕТА: НАЗВАНИЕ_СБОРЩИКА.
- options – настройки, используемые при запуске задачи по-умолчанию.
- configurations – настройки, которые применятся при запуске задачи с указанной конфигурацией.
Настройки применяются следующим образом: когда запускается задача, настройки берутся из блока options, затем, если была указана конфигурация, её настройки записываются поверх существующих. После этого, если в scheduleTarget() были переданы дополнительные настройки – блок overrides, они запишутся последними. При использовании Angular CLI, в overrides передаются аргументы командной строки. После того, как все настройки переданы сборщику, он проверяет их по своей схеме, и только, если настройки ей соответствуют, будет создан контекст, а сборщик начнет работу.
Дополнительная информация о рабочей области здесь.
Создание собственного сборщика
В качестве примера, давайте создадим сборщик, который будет запускать команду в командной строке. Для создания сборщика, воспользуйтесь фабрикой createBuilder и верните объект BuilderOutput:
import { BuilderOutput, createBuilder } from '@angular-devkit/architect';
export default createBuilder((options, context) => {
return new Promise<BuilderOutput>(resolve => {
resolve({ success: true });
});
});
Теперь, давайте добавим немного логики в наш сборщик: мы хотим, контролировать сборщик через настройки, создавать новые процессы, ждать, пока процесс завершится и, если процесс завершился успешно (то есть, вернул код 0), сигнализировать об этом в Architect:
import { BuilderOutput, createBuilder } from '@angular-devkit/architect';
import * as childProcess from 'child_process';
export default createBuilder((options, context) => {
const child = childProcess.spawn(options.command, options.args);
return new Promise<BuilderOutput>(resolve => {
child.on('close', code => {
resolve({ success: code === 0 });
});
});
});
Обработка вывода
Сейчас, метод spawn передаёт все данные в стандартный вывод процесса. Мы же можем хотеть передавать их в logger – регистратор. В этом случае, во-первых, будет облегчена отладка при тестировании, а во-вторых, сам Architect может запускать наш сборщик в отдельном процессе или же отключать стандартный вывод процессов (например, в приложении Electron).
Для этого, мы можем использовать Logger, доступный в объекте context, который позволит нам перенаправлять вывод процесса:
import { BuilderOutput, createBuilder } from '@angular-devkit/architect';
import * as childProcess from 'child_process';
export default createBuilder((options, context) => {
const child = childProcess.spawn(options.command, options.args, { stdio: 'pipe' });
child.stdout.on('data', (data) => {
context.logger.info(data.toString());
});
child.stderr.on('data', (data) => {
context.logger.error(data.toString());
});
return new Promise<BuilderOutput>(resolve => {
child.on('close', code => {
resolve({ success: code === 0 });
});
});
});
Отчеты о выполнении и статусе
Заключительная часть API, относящаяся к реализации вашего собственного сборщика – отчеты о выполнении и текущем статусе.
В нашем случае, команда либо завершается, либо выполняется, поэтому нет смысла добавлять отчет о выполнении. Однако, мы можем сообщить наш статус родительскому сборщику, чтобы он понимал, что происходит.
import { BuilderOutput, createBuilder } from '@angular-devkit/architect';
import * as childProcess from 'child_process';
export default createBuilder((options, context) => {
context.reportStatus(`Executing "${options.command}"...`);
const child = childProcess.spawn(options.command, options.args, { stdio: 'pipe' });
child.stdout.on('data', (data) => {
context.logger.info(data.toString());
});
child.stderr.on('data', (data) => {
context.logger.error(data.toString());
});
return new Promise<BuilderOutput>(resolve => {
context.reportStatus(`Done.`);
child.on('close', code => {
resolve({ success: code === 0 });
});
});
});
Чтобы передавать отчет о выполнении, используйте метод reportProgress с текущими и (необязательно) итоговыми значениями, в качестве аргументов. total может быть любым числом. Например, если вы знаете, сколько файлов вам нужно обработать, в total можно передать их количество, тогда в current можно передать число уже обработанных файлов. Именно так сборщик tslint сообщает о своем прогрессе.
Проверка входных значений
Объект options, передаваемый в сборщик, проверяется с помощью JSON Schema. Это похоже на Schematics, если вы знаете, что это такое.
В нашем примере сборщика, мы ожидаем, что наши параметры будут объектом, который получает два ключа: command – команду (строка) и args – аргументы (массив строк). Наша схема проверки будет выглядеть так:
{
"$schema": "http://json-schema.org/schema",
"type": "object",
"properties": {
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
}
Схемы – действительно мощные инструменты, которые могут проводить большое количество проверок. Для получения дополнительной информации о схемах JSON, вы можете обратиться к официальному сайту JSON Schema.
Создание пакета сборщика
Существует один ключевой файл, который необходимо создать для нашего собственного сборщика, чтобы сделать его совместимым с Angular CLI – builders.json, который отвечает за взаимосвязь нашей реализации сборщика, его имени и схемы проверки. Сам файл выглядит вот так:
{
"builders": {
"command": {
"implementation": "./command",
"schema": "./command/schema.json",
"description": "Runs any command line in the operating system."
}
}
}
Затем, в файл package.json мы добавляем ключ builders, указывающий на файл builders.json:
{
"name": "@example/command-runner",
"version": "1.0.0",
"description": "Builder for Architect",
"builders": "builders.json",
"devDependencies": {
"@angular-devkit/architect": "^1.0.0"
}
}
Это подскажет Architect, где искать файл определения сборщика.
Таким образом название нашего сборщика – "@example/command-runner:command". Первая часть названия, перед двоеточием (:) – название пакета, определяемое с помощью package.json. Вторая часть – название сборщика, определяемое с помощью файла builders.json.
Тестирование собственных сборщиков
Рекомендуемый способ тестирования сборщиков – интеграционное тестирование. Это связано с тем, что создать context непросто, поэтому вам стоит воспользоваться планировщиком от Architect.
Чтобы упростить шаблоны, мы продумали простой способ создания экземпляра Architect: сначала вы создаете JsonSchemaRegistry (для проверки схемы), затем, TestingArchitectHost и, в конце концов, экземпляр Architect. Теперь вы можете составить файл конфигурации builders.json.
Вот пример запуска сборщика, который выполняет команду ls и проверяет, что команда успешно выполнилась. Учтите, что мы будем пользоваться стандартным выводом процессов в logger.
import { Architect, ArchitectHost } from '@angular-devkit/architect';
import { TestingArchitectHost } from '@angular-devkit/architect/testing';
import { logging, schema } from '@angular-devkit/core';
describe('Command Runner Builder', () => {
let architect: Architect;
let architectHost: ArchitectHost;
beforeEach(async () => {
const registry = new schema.CoreSchemaRegistry();
registry.addPostTransform(schema.transforms.addUndefinedDefaults);
// Аргументы TestingArchitectHost – рабочая и текущая директории.
// Сейчас мы их не используем, поэтому они одинаковые.
architectHost = new TestingArchitectHost(__dirname, __dirname);
architect = new Architect(architectHost, registry);
// Тут мы передаем либо имя NPM-пакета,
// либо путь до package.json файла пакета.
await architectHost.addBuilderFromPackage('..');
});
// Это может не работать в Windows
it('can run ls', async () => {
// Создаем регистратор, хранящий массив всех зарегистрированных сообщений.
const logger = new logging.Logger('');
const logs = [];
logger.subscribe(ev => logs.push(ev.message));
// "run" может содержать множество выводов, а также информацию о ходе работы сборщика.
const run = await architect.scheduleBuilder('@example/command-runner:command', {
command: 'ls',
args: [__dirname],
}, { logger });
// "result" – следующий вывод выполняемого процесса.
// Он имеет тип "BuilderOutput".
const output = await run.result;
// Останавливаем сборщик. Architect действительно прекращает сохранение состояний
// сборщика в памяти, так как сборщики ждут, чтобы снова быть запущенными.
await run.stop();
// Ожидаем успешное завершение.
expect(output.success).toBe(true);
// Ожидаем, что этот файл будет выведен.
// `ls $__dirname`.
expect(logs).toContain('index_spec.ts');
});
});
Чтобы запустить пример, указанный выше, вам потребуется пакет ts-node. Если вы собираетесь использовать Node, переименуйте index_spec.ts в index_spec.js.
Использование сборщика в проекте
Давайте создадим простой angular.json, демонстрирующий всё, что мы узнали про сборщики. Если предположить, что мы запаковали наш сборщик в example/command-runner, а затем создали новое приложение с помощью ng new builder-test, файл angular.json может выглядеть так (часть содержимого была удалена для краткости):
{
// ... удалено для краткости.
"projects": {
// ...
"builder-test": {
// ...
"architect": {
// ...
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
// ... разные опции
"outputPath": "dist/builder-test",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json"
},
"configurations": {
"production": {
// ... разные опции
"optimization": true,
"aot": true,
"buildOptimizer": true
}
}
}
}
Если бы мы решили добавить новую задачу для применения (например) команды touch к файлу (обновляет дату изменения файла), с использованием нашего сборщика, мы бы выполнили npm install example/command-runner, а затем, внесли бы изменения в angular.json:
{
"projects": {
"builder-test": {
"architect": {
"touch": {
"builder": "@example/command-runner:command",
"options": {
"command": "touch",
"args": [
"src/main.ts"
]
}
},
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/builder-test",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json"
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"aot": true,
"buildOptimizer": true
}
}
}
}
}
}
}
В Angular CLI есть команда run, которая является основной командой для запуска сборщиков. В качестве первого аргумента она принимает строку формата ПРОЕКТ: ЗАДАЧА[: КОНФИГУРАЦИЯ]. Чтобы запустить нашу задачу, мы можем воспользоваться командой ng run builder-test:touch.
Теперь мы можем хотеть переопределить какие-то аргументы. К сожалению, пока мы не можем переопределять массивы из командной строки, однако мы можем изменить саму команду для демонстрации: ng run builder-test:touch --command=ls. – Это выведет файл src/main.ts.
Watch Mode – режим наблюдения
Предполагается, что по-умолчанию, сборщики будут вызываться единожды и завершаться, однако, они могут возвращать Observable, чтобы реализовывать собственный режим наблюдения (как это делает сборщик Webpack). Architect будет подписан на Observable до тех пор, пока он не завершится или не остановится и сможет подписаться на сборщик снова, если сборщик будет вызван с теми же параметрами (хоть и не гарантированно).
- Сборщик должен возвращать объект BuilderOutput после каждого выполнения. После завершения, он может войти в режим наблюдения, вызванный внешним событием и, если он запустится снова, он должен будет вызвать функцию context.reportRunning() для уведомления Architect о том, что сборщик снова работает. Это защитит сборщик от остановки его Architect-ом при новом вызове.
- Architect сам отписывается от Observable, когда сборщик останавливается (с помощью run.stop(), например), с использованием Teardown-логики – алгоритма уничтожения. Это позволит вам останавливать и очищать сборку, если этот процесс уже запущен.
Резюмируя вышесказанное, если ваш сборщик наблюдает за внешними событиями, он работает в три этапа:
- Выполнение. Например, компиляция Webpack. Этот этап заканчивается, когда Webpack заканчивает сборку, а ваш сборщик отправляет BuilderOutput в Observable.
- Наблюдение. – Между двумя запусками ведется наблюдение за внешними событиями. Например, Webpack следит за файловой системой на предмет любых изменений. Этот этап заканчивается, когда Webpack возобновляет сборку и вызывается context.reportRunning(). После этого этапа снова начинается этап 1.
- Завершение. – Задача полностью выполнена (например, ожидалось, что Webpack запустится определенное количество раз) или запуск сборщика был остановлен (с помощью run.stop()). В этом случае, выполняется алгоритм уничтожения Observable, и он очищается.
Заключение
Вот, краткое изложение того, что мы узнали в этой публикации:
- Мы предоставляем новый API, который позволит разработчикам изменять поведение команд Angular CLI и добавлять новые, используя сборщики, реализующие необходимую логику.
- Сборщики могут быть синхронными, асинхронными и реагирующими на внешние события. Они могут вызываться несколько раз, а также, вызывать другие сборщики.
- Параметры, которые получает сборщик при запуске задачи, сначала считываются из файла angular.json, затем, перезаписываются параметрами из конфигурации, если она есть, а потом, перезаписываются флагами командной строки, если они были добавлены.
- Рекомендованный способ тестирования сборщиков – интеграционные тесты, однако вы можете выполнять модульное тестирование отдельно от логики сборщика.
- Если сборщик возвращает Observable, он должен очищаться после прохождения алгоритма уничтожения.
В ближайшем будущем частота использования этих API будет увеличиваться. Например, реализация Bazel сильно с ними связана.
Мы уже видим, как сообщество создает новые CLI сборщики для использования, например, jest и cypress для тестирования.
lexey111
Спасибо, интересно.
Но вот непонятен такой момент как пайпинг. Простая задача: мне нужно в процессе сборки взять определённый файл в сыром виде, до любой обработки, выполнить эту самую некую обработку и отдать его дальше, в watch-mode — то, что в вебпаке реализуется с помощью Rule.use chaining и его внутренней виртуальной файловой системы — кэша, и то, что как минимум до текущей версии CLI было сломано в CLI-плагине.
Вот, например, похожее issue: github.com/angular/angular-cli/issues/9559 давненько висит.
Проблема (как минимум со связкой webpack 4, typescript, AOT) в том, что AngularCompilerPlugin игнорирует результаты предыдщих лоадеров и берёт оригинальный исходный файл с диска. Я понимаю, что это больше связано с кэшированием проекта tsc, а не ng, но до определённого момента были стандартные хуки, потом можно было написать файл-трансформер, а теперь остались только совсем хакерские методы прямой работы с AST но они не всегда в принципе возможны. Кстати, с актуальностью плагина тоже печальная ситуация.
Есть ощущение, что «обычный» «прямой» вебпак-сборщик теперь вышел из моды и поддерживается по остаточному принципу. Но, может быть, я чего-то не заметил и теперь такой сценарий может быть реализован прямо из CLI?