Представляем «CLI Builder»?-ы

В этой статье мы рассмотрим новый API Angular CLI, который позволит вам расширять существующие возможности CLI и добавлять новые. Мы обсудим, как работать с этим API, и какие существуют точки его расширения, позволяющие добавлять новый функционал в CLI.

История


Около года назад мы представили файл рабочей области (angular.json) в Angular CLI и переосмыслили многие базовые принципы реализации его команд. Мы пришли к тому, что разместили команды в «коробках»:

  1. Schematic commands – «Схематические команды». К настоящему времени, вероятно, вы уже слышали о Schematics – библиотеке, используемой CLI для генерации и изменения вашего кода. Она появилась в 5-й версии и, в настоящее время, используется в большинстве команд, которые касаются вашего кода, таких, как new, generate, add и update.
  2. Miscellaneous commands – «Прочие команды». Это команды, которые не относятся непосредственно к вашему проекту: help, version, config, doc. Недавно появилась ещё analytics, а также наши пасхалки (Тссс! Никому ни слова!).
  3. 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. По-умолчанию, эта задача имеет три ключа:

  1. builder – имя сборщика, который необходимо использовать для выполнения задачи, в формате НАЗВАНИЕ_ПАКЕТА: НАЗВАНИЕ_СБОРЩИКА.
  2. options – настройки, используемые при запуске задачи по-умолчанию.
  3. 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 до тех пор, пока он не завершится или не остановится и сможет подписаться на сборщик снова, если сборщик будет вызван с теми же параметрами (хоть и не гарантированно).

  1. Сборщик должен возвращать объект BuilderOutput после каждого выполнения. После завершения, он может войти в режим наблюдения, вызванный внешним событием и, если он запустится снова, он должен будет вызвать функцию context.reportRunning() для уведомления Architect о том, что сборщик снова работает. Это защитит сборщик от остановки его Architect-ом при новом вызове.
  2. Architect сам отписывается от Observable, когда сборщик останавливается (с помощью run.stop(), например), с использованием Teardown-логики – алгоритма уничтожения. Это позволит вам останавливать и очищать сборку, если этот процесс уже запущен.

Резюмируя вышесказанное, если ваш сборщик наблюдает за внешними событиями, он работает в три этапа:

  1. Выполнение. Например, компиляция Webpack. Этот этап заканчивается, когда Webpack заканчивает сборку, а ваш сборщик отправляет BuilderOutput в Observable.
  2. Наблюдение. – Между двумя запусками ведется наблюдение за внешними событиями. Например, Webpack следит за файловой системой на предмет любых изменений. Этот этап заканчивается, когда Webpack возобновляет сборку и вызывается context.reportRunning(). После этого этапа снова начинается этап 1.
  3. Завершение. – Задача полностью выполнена (например, ожидалось, что Webpack запустится определенное количество раз) или запуск сборщика был остановлен (с помощью run.stop()). В этом случае, выполняется алгоритм уничтожения Observable, и он очищается.

Заключение


Вот, краткое изложение того, что мы узнали в этой публикации:

  1. Мы предоставляем новый API, который позволит разработчикам изменять поведение команд Angular CLI и добавлять новые, используя сборщики, реализующие необходимую логику.
  2. Сборщики могут быть синхронными, асинхронными и реагирующими на внешние события. Они могут вызываться несколько раз, а также, вызывать другие сборщики.
  3. Параметры, которые получает сборщик при запуске задачи, сначала считываются из файла angular.json, затем, перезаписываются параметрами из конфигурации, если она есть, а потом, перезаписываются флагами командной строки, если они были добавлены.
  4. Рекомендованный способ тестирования сборщиков – интеграционные тесты, однако вы можете выполнять модульное тестирование отдельно от логики сборщика.
  5. Если сборщик возвращает Observable, он должен очищаться после прохождения алгоритма уничтожения.

В ближайшем будущем частота использования этих API будет увеличиваться. Например, реализация Bazel сильно с ними связана.

Мы уже видим, как сообщество создает новые CLI сборщики для использования, например, jest и cypress для тестирования.

Комментарии (1)


  1. lexey111
    14.05.2019 11:08

    Спасибо, интересно.

    Но вот непонятен такой момент как пайпинг. Простая задача: мне нужно в процессе сборки взять определённый файл в сыром виде, до любой обработки, выполнить эту самую некую обработку и отдать его дальше, в watch-mode — то, что в вебпаке реализуется с помощью Rule.use chaining и его внутренней виртуальной файловой системы — кэша, и то, что как минимум до текущей версии CLI было сломано в CLI-плагине.

    Вот, например, похожее issue: github.com/angular/angular-cli/issues/9559 давненько висит.

    Проблема (как минимум со связкой webpack 4, typescript, AOT) в том, что AngularCompilerPlugin игнорирует результаты предыдщих лоадеров и берёт оригинальный исходный файл с диска. Я понимаю, что это больше связано с кэшированием проекта tsc, а не ng, но до определённого момента были стандартные хуки, потом можно было написать файл-трансформер, а теперь остались только совсем хакерские методы прямой работы с AST но они не всегда в принципе возможны. Кстати, с актуальностью плагина тоже печальная ситуация.

    Есть ощущение, что «обычный» «прямой» вебпак-сборщик теперь вышел из моды и поддерживается по остаточному принципу. Но, может быть, я чего-то не заметил и теперь такой сценарий может быть реализован прямо из CLI?