Создание консольного приложения на примере swarm-host-cli

Работая с Swarm-Host в какой-то момент понимаешь, что создание однотипных заготовок под Фабрики и Роуты — задача, которую неплохо бы автоматизировать. Имея опыт работы с ember-cli хочется реализовать нечто подобное, но попроще. Делать генератор под Yeoman желания нет, так как хочется прочувствовать все, что есть «под капотом» у cli-приложения.

Для начала покажем, что получилось, а потом уже — как это получилось.

swarm-host-cli — это консольное приложение, с помощью которого можно создать скелет для нового проекта на основе swarm-host, а так же можно создавать и удалять Фабрики и Роуты.

Исходный код есть на GitHub — onechiporenko/swarm-host-cli.

Установить его можно следующей командой:

1
npm i -g swarm-host-cli

После этого вам будет доступна команда swarm-host.

Для создания нового проекта в пустой директории надо выполнить следующее:

1
swarm-host init cool-name

После этого в текущей директории будут созданы файлы package.json, tslint.json, tsconfig.json, index.ts и server.ts. Так же будут созданы две папки — routes и factories. Файлы конфигурации для TypeScript и TSLint вопросов не вызывают. В package.json уже добавлены все необходимые зависимости (не забудьте выполнить npm i). В index.ts находится импорт и запуск сервера из server.ts, который выглядит так:

1
2
3
4
5
6
7
8
9
10
11
12
13
import {Lair, Server} from 'swarm-host';
 
const server = Server.getServer();
 
// server.namespace = '/api/v1';
// server.verbose = true;
// server.port = 12345;
// server.delay = 200;
 
server.addRoutesFromDir(`${__dirname}/routes`);
server.addFactoriesFromDir(`${__dirname}/factories`);
 
export default server;

То есть, после выполнения swarm-host init cool-name и npm i вы получаете уже готовый сервер, который можно запустить командой npm run build.

Для создания Роутов есть следующая команда:

1
swarm-host generate route some/long/path

После ее выполнения будет создан файл routes/some/long/path.ts. При чем, если какой-то папки в указанном пути не существует, то она будет создана автоматически. Созданный Роут выглядит так:

1
2
3
4
5
import {Route} from 'swarm-host';
 
export default Route.createRoute('get', '/some/long/path', (req, res, next, lair) => {
  res.json({});
});

Для создаваемого Роута можно указать метод запроса и url:

1
swarm-host g route some/long/path --method=post --url=api/v2/path

В этом случае файл routes/some/long/path.ts будет выглядеть так:

1
2
3
4
5
import {Route} from 'swarm-host';
 
export default Route.createRoute('post', '/api/v2/path', (req, res, next, lair) => {
  res.json({});
});

Удалить существующий Роут можно командой:

1
swarm-host destroy some/long/path

Создание Фабрик реализовано через следующую команду:

1
swarm-host generate factory unit

Будет создан файл factories/unit.ts:

1
2
3
4
5
6
7
import {Factory} from 'swarm-host';
 
export default Factory.create({
  name: 'unit',
  attrs: {
  }
});

Фабрика без атрибутов — это не очень полезная Фабрика. В консольной команде можно их можно указать слеюущим образом:

1
2
swarm-host generate factory unit \
  name:string age:number squad:has-one:squad:units objectives:has-many:objective

В этом случае Фабрику unit будет такой:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {Factory} from 'swarm-host';
 
export default Factory.create({
  name: 'unit',
  attrs: {
    name: Factory.field({
      value() {
        return '';
      },
      preferredType: 'string'
    }),
    age: Factory.field({
      value() {
        return 0;
      },
      preferredType: 'number'
    }),
    objectives: Factory.hasMany('objective', null),
    squad: Factory.hasOne('squad', 'units'),
  }
});

В команде generate factory все, что идет после имени Фабрики будет рассматриваться как атрибуты. Их формат такой:

  • name:type. Тут name — это имя атрибута, а type — его тип.
  • name:relation:factory. Тут name — это имя атрибута, relation — это тип связи (has-one или has-many), а factory — имя Фабрики, с которой создается связь.
  • name:relation:factory:inverse. В этом случае name, relation и factory точно такие же, как и в предыдущем варианте, а inverse — это имя атрибута у Фабрики из factory, которое используется для «обратной» связи.

Удаление существующей Фабрики идет через команду:

1
swarm-host destroy factory unit

У команд generate и destroy есть алиасы g и d соответственно.

В плане использования swarm-host-cli — все. Теперь рассмотрим, как она реализована.

Для cli-приложения, работающего с файловой системой, надо три модуля:

  • Обработчик параметров командной строки
  • Шаблонизатор
  • Модуль для выполнения команд в командной строке

Разумеется, все это можно реализовать без установки дополнительных пакетов, но это будет долго. Потому воспользуемся некоторыми готовыми решениями:

yargs — это инструмент для создания интерактивных cli. С его помощью можно декларативно описать, какие команды и с какими параметрами должны поддерживаться в приложении. С помощью inquirer будут созданы диалоги подтверждения для ряда комманд.

Ниже представлена файловая структура приложения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/
│   index.ts
│
└───commands
    │   destroy.ts
    │   generate.ts
    │   init.ts
    │
    ├───destroy
    │       factory.ts
    │       route.ts
    │
    └───generate
           factory.ts
           route.ts

index.ts — это «точка входа» в наше приложение. в нем происходит инициализация yargs:

1
2
3
4
5
6
require('yargs')
  .wrap(yargs.terminalWidth())
  .commandDir('commands')
  .demandCommand()
  .help()
  .argv;

Тут стоит обратить внимание на метод commandDir. В нем указывается директория, где находятся описания команд. В нашем случае — это commands с тремя файлами destroy.ts, generate.ts и init.ts. Начнем с последнего. Файл init.ts — это описание команды для создания скелета нового приложения на основе swarm-host.

Любой файл-описание команды должен содержать экспорт со следующими данными:

  • command — собственно команда
  • desc — ее описание
  • builder — тут все, что относится к команде (описание параметров, примеры и прочее)
  • handler — функция-обработчик данной команды. Она выполняется, когда пользователь вводит текущую команду

В init.ts вышеуказанные данные выглядят так:

1
2
3
4
5
6
7
8
exports.command = 'init';
exports.desc = 'initialize swarm-host project in the empty directory';
exports.builder = yargs => {
  yargs.example('`swarm-host init`', 'Initialize new Swarm-Host in the current dir');
};
exports.handler = argv => {
  // ...
};

handler будет рассмотрен чуть позже.

Файлы generate.ts и destroy.ts выглядят так:

1
2
3
4
exports.command = 'generate <command>';
exports.aliases = ['g'];
exports.desc = 'generate some instance';
exports.builder = yargs => yargs.commandDir('generate');
1
2
3
4
exports.command = 'destroy <command>';
exports.aliases = ['d'];
exports.desc = 'destroy some instance';
exports.builder = yargs => yargs.commandDir('destroy');

Каждый из них тоже подлючает вложенные директории commands/generate и commands/destroy как такие, в которых есть описания команд. В каждой их них находятся по два файла — factory.ts и route.ts. Каждый из этих четырех файлов содержит экспорт command, desc, builder и handler. Таким образом получаются те 5 команд, которые были рассмотрены ранее:

  • init
  • generate factory
  • generate route
  • destroy factory
  • destroy route

Перед тем как рассматривать обработчики команд, надо разобраться как работают ejs и shelljs.

Как и многие шаблонизаторы, ejs дает простые языковые конструкции для вывода данных. Шаблон для Роута выглядит так:

1
2
3
4
5
import {Route} from 'swarm-host';
 
export default Route.createRoute('<%= method %>', '<%= url %>', (<%= req %>, res, next, lair) => {
  res.json({});
});

Здесь есть три переменных — method, url и req. Их значения будут высчитаны в обработчике команды и подставлены в этот шаблон.

Шаблон Фабрики немного сложнее:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {Factory} from 'swarm-host';
 
export default Factory.create({
  name: '<%= name %>',
  attrs: {
<%_ attrs.forEach(function(attr){ _%>
<%_ if (attr.attrType === 'field') { _%>
    <%= attr.attrName %>: Factory.field({
      value() {
        return <%- attr.defaultValue %>;
      },
      preferredType: '<%= attr.valueType %>'
    }),
<%_ } _%>
<%_ if (attr.attrType === 'has-one') { _%>
    <%= attr.attrName %>: Factory.hasOne('<%= attr.factory %>', <%- attr.inverseAttr %>),
<%_ } _%>
<%_ if (attr.attrType === 'has-many') { _%>
    <%= attr.attrName %>: Factory.hasMany('<%= attr.factory %>', <%- attr.inverseAttr %>),
<%_ } _%>
<%_ }); _%>
  }
});

Для вывода атрибутов здесь используется цикл. Так же обработите внимание, что в коде шаблона используются разные теги (подробнее о ни можно прочитать в документации).

Что интересно — в описании ejs указано «EJS is a simple templating language that lets you generate HTML markup». Но его можно использовать для генерации файлов любого типа. В нашем случае — это TypeScript.

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

Создание файла с заданными именем и содержимым через shelljs выглядит так:

1
2
import shell = require('shelljs');
shell.echo(fileContent).to(filePath);

Создать директорию по указанному пути можно так:

1
2
import shell = require('shelljs');
shell.mkdir('-p', somePath);

Здесь -p указывает на то, что заданный путь должен быть создан полностью вместе с недостающими поддиректориями.

Удалить что-либо можно следующей командой:

1
2
import shell = require('shelljs');
shell.rm('-rf', somePath);

Что бы не было по пути somePath, оно будет удалено (если нет проблем с правами доступа).

Скопировать файл можно с использованием метода cp:

1
2
import shell = require('shelljs');
shell.cp('source.ts', 'destination.ts');

Больше ничего из shelljs использоваться не будет, так что можно переходить к обработчикам команд. Начнем с создания Фабрики или Роута. Их логика одинакова. Отличие только в содержимом созданного файла.

  • Если файл, который требуется создать, уже существует, то спросить пользователя, надо ли его перезаписать.
  • Если файл не существует или его надо перезаписать, то считывается шаблон Фабрики или Роута и через ejs заполняется требуемыми данными
  • Через shelljs.mkdir создается полный путь до нового файла.
  • Через shelljs.cat создается файл с Фабрикой или Роутом.

Под «спросить пользователя» подразумевается, что пользователь должен ввести в консоли y или n. Такой диалог можно создать с использованием inquirer:

1
2
3
4
5
6
require('inquirer').createPromptModule()({
  choices: ['n', 'y'],
  message: 'Override existing file?',
  name: 'confirmOverride',
  type: 'confirm'
}).then(({confirmOverride}) => {});

Обработчик команды создания Фабрики может выглядеть так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import ejs = require('ejs');
import fs = require('fs');
import path = require('path');
import inquirer = require('inquirer');
import shell = require('shelljs');
 
const fileExists = (path: string) => shell.cat(path).code === 0;
 
const inquirerConfirmOverride = () => inquirer.createPromptModule()({
  choices: ['n', 'y'],
  message: 'File already exists. Override it?',
  name: 'confirmOverride',
  type: 'confirm'
});
 
const writeFile = (dirPath, fileName) => {
  const tpl = fs.readFileSync(path.join(__dirname, 'blueprints/factory.ejs'), 'utf-8');
  shell.mkdir('-p', dirPath);
  shell.echo(ejs.render(tpl, {
    // some "magic" here
  })).to(path.join(dirPath, fileName));
};
 
exports.handler = argv => {
  const p = path.parse(pathToNewInstance);
  const dirPath = path.join(process.cwd(), 'factories', p.dir);
  const fileName = `${p.name}.ts`;
  if (fileExists(path.join(dirPath, fileName))) {
    inquirerConfirmOverride().then(({confirmOverride}) => {
      if (confirmOverride) {
        writeFile(dirPath, fileName);
      }
    });
  } else {
    writeFile(dirPath, fileName);
  }
};

Выше представлена немного упрощенная версия кода, используемого в swarm-host-cli. Тут все показано как один «файл», а в реальности — это несколько архитектурных единиц, связанных через паттерн Стратегия (ну или то, что осталось про него в моей памяти).

Удаление существующей Фабрики выглядит так (опять-таки упрощенно):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import path = require('path');
import inquirer = require('inquirer');
import shell = require('shelljs');
 
const inquirerConfirmDestroy = () => inquirer.createPromptModule()({
  choices: ['n', 'y'],
  message: 'File already exists. Override it?',
  name: 'confirmDestroy',
  type: 'confirm'
});
 
exports.handler = argv => {
  const p = path.parse(pathToNewInstance);
  const dirPath = path.join(process.cwd(), 'factories', p.dir);
  const fileName = `${p.name}.ts`;
  inquirerConfirmDestroy().then(({confirmDestroy}) => {
    if (confirmDestroy) {
      shell.rm('-rf', path.join(dirPath, fileName));
    }
  });
};

Команда init — это обычное копирование файлов через shell.cp и создание двух директорий через shell.mkDir. Помимо этого у данной команды есть одна опция — --skip-npm. Если ее указать, то команда npm i не будет выполнятся.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
exports.builder = yargs => {
  yargs.options({
    'skip-npm': {
      describe: 'Do not install npm packages'
    }
  });
};
exports.handler = argv => {
  const shell = require('shelljs');
  const path = require('path');
  const fs = require('fs');
  const ejs = require('ejs');
  const dirContent = shell.ls('-A', process.cwd());
  if (dirContent.length !== 0) {
    console.log('`init` can be executed only in the empty dir');
    process.exit(1);
  }
  shell.mkdir('routes');
  shell.mkdir('factories');
  const p = path.parse(process.cwd());
  const projectTplPath = path.join(__dirname, 'blueprints/project/');
  const packageJson = fs.readFileSync(path.join(projectTplPath, 'package.json'), 'utf-8');
  shell.cp(path.join(projectTplPath, '/*'), '.');
  shell.echo(ejs.render(packageJson, {
    name: projectName
  })).to('package.json');
  if (!argv.skipNpm) {
    shell.exec('npm i --save swarm-host typescript');
    shell.exec('npm i --save-dev @types/node ts-node tslint');
  }
};

Вызов shell.ls с флагом -A вернет все файлы и папки, которые есть в требуемой директории.

Здесь npm i выполняется через shell.exec. Команда, переданная в exec, выполняется синхронно.

Остался только один момент, связанный с нашим консольным приложением. Как сделать, чтоб вызов swarm-host в консоли приводил к выполнению наших команд? Это делается просто. В package.json надо описать свойство binв виде:

1
2
3
4
5
{
  "bin": {
    "swarm-host": "./dist/index.js"
  }
}

Здесь swarm-host — это название команды, а ./dist/index.js — это путь до файла, который выполняется при вызове swarm-host.

Создание консольных приложений — это не очень сложная задача, когда под рукой есть правильные «инструменты». В тоже время, это достаточно интересная задача, чтоб отвлечься от SPA и прочей FE-рутины.

Еще раз ссылка на GitHub — onechiporenko/swarm-host-cli

, ,

Оставить комментарий

Top ↑ | Main page | Back