Делаем удобный таск-раннер и публикуем его как npm-пакет
Рано или поздно при работе с тем или иным языком программирования возникает желание написать на нем свой вспомогательный инструмент для разработки. И впрямь, трудно представить современный мир программирования, а тем более мир javascript, на котором мне довелось писать последнее время, без огромного количества библиотек/фреймворков/консольных утилит.
Подходящая для этого ситуация недавно сложилась в одном из моих проектов по работе. Все мы знаем, что нам очень часто
приходится запускать различные скрипты/программы, которые требуют тех или иных переменных окружения или просто параметров командной строки. Для этого в проекте использовался better-npm-run и все бы
было хорошо и не требовало каких-то альтернатив, если бы не одно "но": в большом проекте скопилось огромное количество однотипных тасков, единственное отличие которых между собой заключалось лишь в одной из переменных окружения, что отражалось в соответствующей части названия, по которому происходит запуск таска, к тому же это приводило к необъятному размеру package.json
, в котором хранились десятки конфигураций запускаемых команд. Поясню:
{
// ...
"betterScripts": {
"build:main": {
"command": "webpack --config --progress --colors",
"env": {
"CHUNK_NAME": "main"
}
},
"build:contents": {
"command": "webpack --config --progress --colors",
"env": {
"CHUNK_NAME": "contents"
}
},
"build:info": {
"command": "webpack --config --progress --colors",
"env": {
"CHUNK_NAME": "info"
}
}
// ...
}
}
Запуск этих команд производился согласно документации с помощью вызова в консоли скажем bnr build:main
. В данном случае main
- это одна из частей приложения, которых на самом деле много.
Постановка задачи
Пелену конфигов нужно вынести в отдельный файл scripts.json
, сократив до шаблонов следующего вида:
javascript
[
{
"name": "build:<%name%>",
"command": "webpack --config --progress --colors",
"env": {
"CHUNK_NAME": "<%name%>"
}
},
// ...
]
Оформление конфига в виде массива нужно по причине отсутствия, необходимого для подбора шаблона имени, приоритета при аналогичной реализации в виде объекта так как, напомню, ключи в js объекте неупорядочены. Это кстати важный момент: очень часто начинающие программисты ожидают, что перебор ключей объекта будет происходить в том же порядке, в котором они определены в коде. Запуск должен производиться похожим образом prun build:name
.
Таким образом, нам нужно решить в коде следующие задачи:
- Загрузка конфигурации
- Создание ряда регулярных выражений
- Поиск первого подходящего к имени
- Выполнение таска, ассоциированного с ним
Автор этой статьи много знает, потому что учился у менторов mkdev Выбрать ментора
Довольно слов - покажите нам код
Главный файл:
#!/usr/bin/env node
var join = require('path').join;
var fullScriptsPath = join(process.cwd(), 'scripts.json');
var Pattern = require('./lib/Pattern'); // Здесь инкапсулирована логика соответствия конкретного шаблона
var run = require('./lib/run'); // Логика по запуску необходимого таска с определенной конфигурацией
var err = require('./lib/logger').err; // Различные вспомогательные функции
var exit = require('./lib/logger').exit; //
var log = require('./lib/logger').log; //
var m = require('./lib/logger').modifiers; //
var commandName = process.argv[2];
try {
var scripts = require(fullScriptsPath);
} catch(e) {
exit(e.toString()); // Если при открытии файла конфигурации что-то пошло не так
}
!commandName && exit('No script name presented!');
for(var i = 0; i < scripts.length; i++) { // Перебор по всем таскам с поиском первого подходящего
var pattern = new Pattern(scripts[i]);
if (pattern.matches(commandName)) {
log('Starting script: ');
log(commandName + '\n', m.FgRed);
run(pattern.getDataForExecution(), function (code) {
code && exit('script exit code: ' + code);
log('Finishing script: ');
log(commandName + '\n', m.FgRed);
});
break;
}
if (i + 1 === scripts.length) { // Ну и если подходящего таска так и не найдено
err('No pattern for such script: ');
exit(commandName + '\n', m.FgRed);
}
}
Вспомогательный класс для поиска подходящего таска по имени - в нашем случае он должен предоставлять следующий интерфейс:
- Конструктор
- функция matches(commandName) - подходит ли данный паттерн под название запущенного таска
- функция getDataForExecution() - предоставление конкретных данных, с подставленными значениями параметров
function build(name) {
// Вспомогательная функция для создания регулярного выражения для сопоставляющегося с именем таска,
// а также массива имен параметров в том порядке, в котором они идут в шаблоне
var re = /<%\w+%>/, match, data = [];
while(match = re.exec(name)) {
name = name.replace(match[0], '(\\w+)');
data.push(match[0].slice(2, -2)) // убираем из найденного <% и %> и сохраняем название параметра
}
return {
regexp: new RegExp('^' + name + '$'), // создаем регулярное выражение из строки добавив якоря начала и конца
paramNames: data
}
}
function TemplateEngine(tpl, data) {
// Замена имен параметров в строке на их значения
var re = /<%([^%>]+)?%>/, match;
while(match = re.exec(tpl)) {
tpl = tpl.replace(match[0], data[match[1]])
}
return tpl;
}
function Pattern(script) {
this.script = script;
var regexpData = build(script.name);
this.regexp = regexpData.regexp;
this.paramNames = regexpData.paramNames;
}
Pattern.prototype.matches = function matches(commandName) {
var match = this.regexp.exec(commandName);
if (!match){
return false;
}
this.params = {};
for(var i = 0; i < this.paramNames.length; ++i) { // если подходит, сохраняем значения параметров
this.params[this.paramNames[i]] = match[i + 1];
}
return true;
};
Pattern.prototype.getDataForExecution = function getDataForExecution() {
// Простой хак, чтобы не обходить объект рекурсивно при замене параметров на их значения в конфиге
return JSON.parse(TemplateEngine(JSON.stringify(this.script), this.params))
};
module.exports = Pattern;
Ну и файл с кодом, который непосредственно запустит тот или иной таск:
require('dotenv').config({silent: true});
var spawn = require('child_process').spawn;
module.exports = function run(script, callback) {
var argv = process.argv.splice(3);
var command = (typeof script === 'string' ? script : script.command) + ' ' + argv.join(' ');
script.env = script.env || {};
var env = {};
Object.assign(env, process.env, script.env);
var sh = 'sh', shFlag = '-c';
if (process.platform === 'win32') {
sh = 'cmd';
shFlag = '/c';
command = '"' + command.trim() + '"';
}
spawn(sh, [shFlag, command], {
env: env,
windowsVerbatimArguments: process.platform === 'win32',
stdio: 'inherit'
}).on('close', callback);
};
Скажу честно, последний, по большей части, я взял из проверенных временем исходников better-npm-run, добавив лишь callback после завершения дочернего процесса.
Выкладывание пакета в публичный доступ
Ну вот мы и подошли к финальной и возможно самой долгожданной части: выкладыванию нашего кода в публичный репозиторий npm. По сути тут все просто, но для начала подготовим наш package.json для публикации:
{
"name": "prun",
"version": "1.0.1", // версия
"author": "Risent Veber <risentveber@gmail.com>", // имя автора
"description": "Comfortable scripts runner that supports simple templates",
"repository": {
"type": "git", // адрес и тип
"url": "git://github.com/risentveber/prun.git" // репозитория
},
"license": "MIT", // лицензия
"main": "index.js", // точка входа
"scripts": { // скрипты для тестирования travis'oм
"test:case1": "node index.js case:1",
"test:case2": "node index.js case:2:special",
"test": "npm run test:case1 && npm run test:case2"
},
"bin": {
"prun": "index.js" // имя, под которым наша утилита будет запускаться из консоли
},
"keywords": [ // ключевые слова, по которым идет поиск пакета другими разработчиками
"script",
"runner",
"task",
"pattern",
"run",
"manager"
],
"dependencies": {
"dotenv": "^2.0.0"
}
}
Согласно документации https://docs.npmjs.com/getting-started/publishing-npm-packages для этого необходимо всего три шага:
- Регистрируемся на сайте www.npmjs.com, из-под созданной учетки будем выкладывать пакет
npm login
- здесь просто логинимся под только что созданной учетной записьюnpm publish
- собственно публикация(!NB опубликовать каждую версию можно только один раз)
Вывод
Как видите, это совсем несложно написать что-то свое, что с легкостью может быть использовано другими. Весь код данной утилиты вместе с тестами вы можете найти у меня в репозитории.
P.S. Спасибо Кириллу за его сайт и книгу: в свое время они оказали колоссальное положительное влияние на меня как разработчика.