Yamato DaiwaE(CMA)S(cript) extensions

Класс ConsoleCommandsParser

Класс, предназначенный для разбора (парсинга) векторов аргументов консольных команд с валидацией и преобразованием их к объектным TypeScript-типам. Возможно также сгенерировать справочный текст для всех поддерживаемых команд.

Демо

Нижеследующей пример пример будет полностью разобран.

Минимальные теоретические знания

В отношении анатомии консольных команд официальная терминология и стандарты отсутствуютимеются лишь более-менее устоявшиеся соглашения. В сухом остатке, process.argv (сокращение от «arguments vector»«вектор аргументов», унаследованная от языка C++ концепция) — всего лишь массив строк, а что с этими строками делать — решает разработчик приложения и/или его заказчики.

По сути, разбор консольных команд — это частный случай задачи анализа произвольных внешних структурированных данных, в котором такими данными будет индексный массив строк, а значит последовательность элементов играет ключевое значение. Node.js не предоставляет никакой функциональности для разбора таких массивов и их валидации, а сделать это безопасно (насколько возможно) с точки зрения TypeScript-типизации может ещё и не каждая сторонняя библиотека для работы с консольными командами.

Соглашения по терминологии

В общем виде консольная команда представляет собой последовательность строчных значений, разделённых пробелом:

На примере утилиты Webpack, конкретная команда может иметь вид:

Команда
По пути, это имя приложения — полное или сокращённое. Например, у консольного интерфейса фреймворка Angular это ng, но чаще команды либо совпадают с полным именем приложения, либо близки к нему, например webpack, gulp, lerna.
Опция (option) / Ключ опции (option key)
Начинается с двойного дефиса (как --mode в примере выше).
Параметр
Значение опции. Например, development является параметром опции --mode.
  • Если опция не имеет параметра, то она рассматривается как опция булевского типа со значением true. Соответственно, когда этой опции не указано, это равносильно значению false.
  • Опция может иметь сокращение, состоящее из одного дефиса и одной буквы (например, -d). Примечательно, но для приведённого выше в качестве примера webpack-а сокращение -m имеется, только одно не является сокращением по отношению к --mode. Как видно, сокращения хотя и быстры для ввода, но требуют повседневного использования для запоминания.
  • Аргументы могут иметь пробелы, но при этом они должны быть обёрнуты в кавычки.

Чем же тогда является build в примере выше? Хороший вопрос и он заслуживает отдельного подраздела.

Термин «командная фраза» («command phrase»)

Командная фраза («command phrase»)первый аргумент консольной команды, не являющиеся опцией и ссылающийся на конкретную функциональность консольного приложения. Например, в yda build --mode DEVELOPMENT командной фразой является build, однако ввиду того, что она не обязана состоять из одного слова, она и называется «фразой». В случаях, когда командная фраза состоит из нескольких слов, помимо оборачивания её в кавычки можно использовать слитные способы записи нескольких слов, такие как верблюжий регистр.

Командная фраза может быть явной или неявной (командной фразой по умолчанию). Например, в webpack build --mode development командная фраза может build является командной фразой по умолчанию, а потому может быть опущена (webpack --mode development).

Дискриминантное объединение в TypeScript

Для использования ConsoleCommandsParser необходимо понимать концепцию дискриминантного объединения в языке TypeScript. Суть её в том, что некоторый тип (данных) может быть одним из нескольких объектных типов с определённым набором свойств, одно из которых отвечает за идентификацию конкретного подтипа. Таким образом, дискриминантное объединения — это обобщение для нескольких конкретных объектных типов, подобно тому, как под «легковым автомобилем» может подразумеваться «седан», «хэтчбэк», «минивэн» и другие, при этом набор типов в классификации должен быть конечным, а в документации к автомобилю имеется графа «тип», благодаря которой можно однозначно сказать, к какому типу принадлежит конкретный автомобиль.

Рассмотрим это на примере, имеющему отношение ConsoleCommandsParser. Допустим, разрабатываемая консольная утилита имеет командные фразы build, pack, deploy и help. Какой именно функциональности приложения они соответствуют — сейчас неважно, однако если для пользователей консольного приложения желательны короткие идентификаторы, а для разработчиков — содержательные, то можно короткие поместить в значение перечисления:

Создадим объект для каждой командной фразы, который будет включать в себя свойство phrase с одним из значений определённого выше перечисления CommandPhrases, а так же опции, актуальные для соответствующих командных фраз. Пускай все командные фразы, кроме последней, будут иметь опции.

Какую именно командную фразу введёт пользователь — мы заранее знать не можем, однако при правильно описанных правилах валидации (мы разберём, как это делать) это будет одна из приведённых выше командных фраз с соответствующим ей набором опций:

Метод parse класса ConsoleCommandsParser вернёт объект определённого выше типа SupportedCommandsAndParametersCombinations с двумя дополнительными свойствами: NodeJS_InterpreterAbsolutePath и executableFileAbsolutePath — это первые 2 элемента массива process.argv. По сути, TypeScript-обобщение ParsedCommand в ParsedCommand<SupportedCommandsAndParametersCombinations> сообщает TypeScript-y, что к одному из подтипов объединения SupportedCommandsAndParametersCombinations будут добавлены эти два свойства:

Но как мы узнаем, какую именно командную фразу ввёл пользователь приложения? Поскольку у всех подтипов дискриминантного объединения SupportedCommandsAndParametersCombinations имеется играющее роль идентификатора свойство phrase с уникальными в пределах этого объединения значением, то с помощью условных конструкций (в данном случае идеально подходит switch/case) мы можем определить конкретный подтип:

В результате, внутри каждого case-блока мы можем обращаться к свойствам parsedConsoleCommand, соответствующим текущей командной фразе, а если попытаться вызывать свойство, ссылающееся на опцию другой командной фразы, TypeScript это заметит в виде ошибки. Правда, в примере много необязательных свойств, поэтому перед тем, как ими пользоваться, потребуется проверка на undefined.

Пошаговое руководство по созданию консольного интерфейса

Шаг 1 — Определение начального набора командных фраз

Решите, какие командные фразы будут доступны в Вашем приложении.

  • Возможно, у Вас будет только одна командная фраза. Если Ваше приложение узкоспециализированное, то это вполне нормально.
  • В будущем Вы сможете добавить другие командные фразы, потому на этом этапе можете ограничиться теми, назначение которых Вы более-менее осмыслили.

Поместите все Ваши командные фразы в перечисление. Ключи перечисления не  обязательно должны совпадать с теми последовательностями символов, которые пользователь будет вводить в консоль: пускай ключи лучше будут подлиннее, но понятными для программистов без документации, а если Вы, как и разработчики большинства консольных приложений, хотите сделать вводимые командные фразы короткими, то поместите их в значения элементов перечисления. К счастью, TypeScript позволяет определять строчные перечисления, что невозможно или ограниченно во многих других языках программирования.

Как видно, в данном примере в качестве ключей перечисления выбраны словосочетания без глаголов — вместо них используются отглагольные существительные, потому если наименование начинается с глагола, но при этом не принадлежит функции или методу, то это создаёт путаницу. Но это это требования стилистические, с которыми Вы можете не согласиться и установить собственные.

Рекомендуется писать этот код в отдельном файле, например ApplicationConsoleLineInterface.ts. Кроме того, для чёткого определения контекста рекомендуем обернуть всё содержимое этого файла в пространство имён, например ApplicationConsoleLineInterface. Хотя некоторые разработчики выступают против использование пространств имёнTypeScript), такое негативное отношение к пространствам имён обычно связано с устаревшими способами разбиения кода на модули, а такое использование, как в нашем примере (контекстный контейнер для типов и констант) не несёт в себе никаких проблем.

Шаг 2 — Определение опций для командных фраз

Определитесь, какие опции Вы будете поддерживать для каждой командной фразы, а также их тип, обязательность и значения по умолчанию (если таковые имеются) каждой опции.

  • Опять же, для того, чтобы быстрее получить обратную связь от своего кода, на начальном этапе можно ограничиться минимальным набором опций, наиболее осмысленных во время проектирования.
  • Некоторые командные фразы могут не иметь опций — это нормально, а если Вы в будущем почувствуете необходимость в опциях, то всегда сможете их добавить. В нашем примере опций не будет у командной фразы referenceGenerating.

Теперь, для каждой командной фразы определите объектный тип со следующими свойствами:

phrase
Должно содержать элемент перечисления командных фраз (CommandPhrases в примере выше), соответствующий той же командной фразе, что и тип, который Вы определяете. Это свойство играет роль идентификатора, с помощью которого предстоит определять, какую именно командную фразу ввёл пользователь приложения. В качестве типа данного свойства нужно указывать не само перечисление (CommandPhrases в примере выше), а его конкретный элемент (например, CommandPhrases.projectBuilding) — с точки зрения дискриминантных объединений в TypeScript это не бессмысленно.
Опции командны

Все опции для текущей командной фразы. Ключи не обязательно должны совпадать с теми, которые пользователь будет вводить в консоль, поэтому давайте им такие имена, чтобы их смысл был ясен программистам без документации. Тип каждого из этих свойств должен быть одним из следующих поддерживаемых типов:

  • string
  • number
  • boolean
  • Объект на основе ParsedJSON из главного пакета (@yamato-daiwa/es-extensions)

Если же опция необязательная и при этом не  планируется значения по умолчанию, то перед двоеточием — разделителем ключа и типа значениянеобходимо поставить вопросительный знак.

Наконец, объявите TypeScript-объединение, в котором перечислены все только что созданные объектные типы для каждой командной фразы (в примере ниже это SupportedCommandsAndParametersCombinations).

  • Снова обращаем Ваше внимание на то, какие могут быть стилистические требования по именованию типов: имя каждого типа, соответствующего конкретной командной фразе, не содержит глаголов и шаблонно заканчивается на ConsoleCommand.
  • Использование утилитарного типа Readonly не обязательно, но рекомендуется, потому что тем самым мы явно выражаем, что свойства определённых нами объектных типов не подлежат изменениям в процессе выполнения программы.

Шаг 3 — Определение спецификации консольного приложения

На основе спецификации консольного приложения будет осуществляться разбор (парсинг) и валидация введённой консольной команды, а при необходимости — генерация справки по использованию этого консольного приложения.

Определите константу типа ConsoleCommandsParser.CommandLineInterfaceSpecification — это многоуровневый объект, в котором должна содержатся спецификация всех командных фраз включая опции каждой из них, а также данные для генерации справки. Если Вы последовали совету обернуть код в пространство имён такое как ApplicationConsoleLineInterface, то длинного имени константе наподобие consoleCommandLineInterfaceSpecification не требуется: у нас объявлен контекст ApplicationConsoleLineInterface, поэтому константу можно назвать просто specification.

  1. Свойству applicationName укажите имя Вашего приложения. Оно не обязано совпадать с вводимым в консоль именем приложения, а если оно состоит из нескольких слов, то можно ввести их с разделением пробелом. Это значение будет использовано при генерации справки.
  2. В отличие от applicationName, свойство applicationDescription — описание приложения — необязательное, тем не менее заполнить его коротким описанием рекомендуется, чтобы сгенерированная справка была полноценной.
  3. Свойство commandPhrases должно содержать спецификации каждой командной фразы. Представляет собой объект типа «ассоциативный массив», ключи которого должны совпадать с командными фразами. Надёжнее всего с использованием скобочной записи сослать ключи на нужный элемент перечисления CommandPhrases, например [CommandPhrases.packingOfBuild]. Что касается значений этого ассоциативного массива, то:
    • Если соответствующая командная фраза является командной фразой по умолчанию, то укажите булевскому свойству isDefault значение true. Поскольку командная фраза по умолчанию может быть только одна, то isDefault: true должно быть указано максимум у одной командной фразы, иначе выброшено исключение при запуске приложения.
    • Подобно рассмотренному выше applicationDescription, для спецификаций командных фраз предусмотрено свойство description — описание командной фразы, которое хотя и не обязательно, но настоятельно рекомендуется указать, чтобы сгенерировалась качественная справка. В примере выше описание пропущено лишь у командной фразы help (CommandPhrases.referenceGenerating).
    • Если у командной фразы есть опции, то их спецификацию необходимо указать в свойстве options, которое также представляет собой объект типа «ассоциативный массив».

Определение опций командных фраз

Большую часть времени этого этапа займёт именно определение опций комадных фраз, если таковых много. Сложного, однако, ничего нет: через ключи объекта необходимо перечислить опции такими, какими они будут введены в консоль, а через значения (тоже объектного типа) — настройки этих опций, такие как тип, обязательность и так далее. Итак, для каждой опции:

  1. Укажите тип опции в виде элемента перечисления ConsoleCommandsParser.ParametersTypes. На данный момент поддерживаются строчные, числовые и булевские опции, а так же объектные (должны быть введены в консоль в формате JSON5).
  2. Если опция имеет значение по умолчанию, то укажите значение соответствующего типа свойству defaultValue. В противном случае, необходимо явно указать булевское свойство required (естественно true, если опция обязательна). При этом для необязательной булевской опции указать defaultValue TypeScript не позволит, поскольку пропуск этого значения при вводе в консоль равносилен явно указанному значению false.
  3. Если Вы пишете качественный код, а смысл короткого (в целях скорости ввода) имени опции, которое будет введено в консоль, неясен без обращения к документации, то определите свойство newName более содержательным именем для программистов, которые будут поддерживать Ваш код.
  4. Если Вы хотите определить для опции однобуквенное сокращение, укажите соответствующее значение свойству shortcut. Дефис, который пользователь должен будет указать при использовании этого сокращения, здесь, при определении, указывать не обязательно.

Все остальные свойства зависят от типа опции. Рассмотрим их.

Специальные свойства строчных опций

На данный момент такое свойство только одиноallowedAlternatives. Если Вы хотите, чтобы строковая опция могла принимать только значения из ограниченного набора вариантов, то укажите их в виде массива свойству allowedAlternatives.

Специальные свойства числовых опций
numbersSet
Множество чисел, обязательное свойство для данного типа опций. Его значением должен быть один из элементов перечисления, заимствованного из главного пакета библиотеки — RawObjectDataProcessor.NumbersSets:
naturalNumber
Натуральное число
nonNegativeInteger
Неотрицательное целое число
negativeInteger
Отрицательное целое число
negativeIntegerOrZero
Отрицательное целое число либо ноль
anyInteger
Любое целое число
positiveDecimalFraction
Положительная десятичная дробь
negativeDecimalFraction
Отрицательная десятичная дробь
decimalFractionOfAnySign
Любая десятичная дробь
anyRealNumber
Любое действительное число
minimalValue
Минимальное значение
maximalValue
Максимальное значение
Специальные свойства объектных опций

На данный момент такое свойство только одно, но обязательноеvalidValueSpecification. С помощью него необходимо задать правила валидации объекта, в который будет преобразована строка формата JSON5.

Это свойство имеет тип RawObjectDataProcessor.PropertiesSpecification , заимствованный из главного пакета библиотеки и очень похоже на спецификацию опций командной фразы, только здесь используется API класса RawObjectDataProcessor.

Шаг 4 — Создание логики разбора консольных команд

На этом определение спецификации консольного приложения завершено; теперь её можно использовать при разборе (парсинге) введённой консольной команды. Для этого необходимо у класса ConsoleCommandsParser вызывать статический метод parse:

  • Обязательным параметром этого метода является спецификация консольного приложения, которую Вы определили на предыдущем шаге.
  • Если не указывать 2го параметра, то вектор аргументов будет взят из process.argv.
  • В качестве параметра обобщения необходимо указать TypeScript-объединение SupportedCommandsAndParametersCombinations, которое Вы определили на 2-ом шаге.

Теперь нам нужно определить, какая именно командная фраза была введена, а также безопасно (без типа any и ошибок TypeScript) обратиться к её опциям (разве что в случае необязательными опциями без значений по умолчанию потребуется проверка на undefined, прежде чем ими полноценно пользоваться). Сделать это можно с помощью switch/case:

Шаг 5 — Обеспечение запуска приложения по имени

На данный момент приложение уже можно запускать по пути файла с помощью ts-node, например:

Хотя такой вариант многих не устроит (потому что хотят запускать консольное приложение по команде, то есть имени приложения — согласно запутанной, но устоявшейся терминологии), рассмотрим вкратце, когда он подходит и на завершённых предыдущих этапах можно остановиться. Прежде всего это случаи, когда разрабатываемый консольный интерфейс планируется применять исключительно в рамках одного проекта без публикации в npm или другими способами. В частности, это:

Серверные приложения
Как правило, в таких приложениях одна командная фраза для запуска сервера, а через опции передаются HTTP-порт, уровень логирования и так далее.
Скрипты для автоматизации
Обычно это генерация, копирование или модификация файлов, причём задача насколько специфическая, что средства общего назначения, такие как Gulp, не подходят.

Хорошо, но как же всё-таки сделать возможным вызов утилиты по её имени, подобно gulp, webpack и так далее?

Во-первых, поскольку рантайм Node.js не поддерживает TypeScript, то сначала необходимо обеспечить транспайлинг исходного кода в JavaScript. Сделать это можно как с помощью консольного интерфейса пакета TypeScript, так и с помощью специализированных утилит, таких как тот же самый Webpack ( потребуются один или более плагинов для работы с TypeScript).

Однако перед тем, как запускать транспайлинг, необходимо экспортировать из точки входа функцию или же класс, вызов одного из методов которого приведёт к выполнению логики приложения. «Экспортировать из точки входа» — звучит странно, ведь обычно в точку входа много что импортируется, но ничего не экспортируется. Объясняется это тем, что в случае консольных Node.js-утилит точка входа выполняется не напрямую, а через через исполняемый файл. Вот пример точки входа, которая экспортирует функцию executeApplication:

Выполнив преобразование исходного кода в JavaScript, создайте ещё один JavaScript-файл (скажем, Executable.js) вручную с содержимым подобным следующему:

Здесь первая строка содержит шебанг, указывающий на то, что этот файл необходимо выполнять с помощью Node.js. Далее следует импортировать функцию, запускающую приложение (executeApplication в примере выше) и вызвать её.

Вопрос

Почему нельзя это файл сделать точкой входа? Это же вроде как обычный JavaScript-файл, если не считать шебанга.

Ответ
В принципе можно, просто в таких исполняемых файлах не принято хранить логику, более сложную чем вызов одной функции или одного метода. Однако это лишь рекомендация, а потому кто-то её соблюдает (например, разработчики Gulp), кто-то — нет (например, разработчики Webpack, на моменты весны 2024 года).

Теперь, на этот исполняемый файл необходимо сослаться из файла package.json Вашего проекта. Для того, чтобы утилита была доступна по имени, надо заполнить поле bin объектом типа «ассоциативный массив», ключами которого будут команды, а значениямиотносительные пути к соответствующим исполняемым файлам (расширение «.js» можно опустить):

Более подробную информацию о поле «bin» Вы можете получить в официальной документации npm.

Для того, чтобы устанавливать Вашу утилиту в другие проекты, нужно заполнить и другие поля package.json, такие как name и version. Ещё больше полей потребуется заполнить, если Вы собираетесь публиковать утилиту в npm. На официальной сайте npm есть краткое руководство по данной теме.