
В этой статье мы рассмотрим новый 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 для тестирования.