翻译计划-用node.js开发一个可交互的命令行应用

发表于:April 15, 2017 at 08:18 PM

近几年, Node.js 在软件开发的一致性上助力很大.无论是前端开发,服务端脚本,跨平台桌面/移动端应用或是物联网应用,Node.js 都可以帮你完成.由于 Node.js 的出现,编写命令行工具比之前容易很多,这不是随意说说,而是可交互,真正有价值的并且能减少开发耗时的命令行工具.

译者:Icarus 原文链接:How To Develop An Interactive Command Line Application Using Node.js

如果你是一名前端开发者,那你一定听说过或者使用过诸如 Gulp, Angular CLI, Cordova, Yeoman或其它的命令行工具.举个例子,在使用 Angular CLI 的情况下,通过执行ng new <project-name>这个命令,你会创建一个基于基础配置的 Angular 项目.像 Yeoman 这样的命令行工具会在运行过程中需要你输入一些内容从而帮助你个性化定制项目的配置.Yeoman 中的生成器(generators)会帮助你在生产环境部署项目.这就是我们今天要学习的部分.

拓展阅读

A Detailed Introduction To Webpack An Introduction To Node.js And MongoDB Server-Side Rendering With React, Node And Express Useful Node.js Tools, Tutorials And Resources

在这个教程中,我们会开发一个命令行应用,它可以接收一个 CSV 格式的用户信息文件,通过使用 SendGrid API可以像这些用户发送电子邮件.下面是教程的内容大纲:

  1. “Hello,World”
  2. 处理命令行参数
  3. 运行时的用户输入
  4. 异步网络会话
  5. 美化控制台的输出
  6. 封装成 shell 命令
  7. JavaScript 之外

”Hello,World”

这个教程假设你的系统里已经安装好了 Node.js. 如果你没有,请先安装它.在安装 Node.js的同时会附带一个叫 npm 的包管理器.使用 npm 你可以安装很多开源的包.你可以在 npm 的官网站点上获取全部的包列表.这个项目我们会用到一些开源的模块(之后会更多).现在,让我们用 npm 创建一个 Node.js 项目.

$ npm init
name: broadcast
version: 0.0.1
description: CLI utility to broadcast emails
entry point: broadcast.js

我创建了一个名为 broadcast 的文件夹,在里面我执行了 npm init 命令.正如你看到的那样,我已经提供了诸如项目名称,描述,版本号和入口文件等项目的基础信息.入口文件是最主要的 JS 文件,在这里脚本开始编译运行.Node.js 默认把 index.js 文件当做入口文件,而在这个例子里我们把入口文件改为 broadcast.js.当你执行 npm init命令的时候,你会得到更多的选项,比如 Git 仓库地址,开源许可证和作者名.你可以填写这些选项或者空着它们.

npm init成功执行之后,你会在文件夹里看到一个 package.json 文件已经创建好了.这是我们的配置文件.与此同时,它也保存着我们在创建项目时提供的信息.你可以在npm 官方文档中浏览更多有关package.json的内容.

既然项目已经创建好了,那就让我们创建一个”Hello world”程序.开始之前,你需要在你的项目中新建一个 broadcast.js文件,这个是之后主要用到的文件,在文件中写入如下代码段:

console.log("hello world");

现在让我们运行一下.

$ node broadcast
hello world

正如你看到的那样,“hello world”在控制台打印出来了.你可以使用node broadcast.js或者node broadcast来执行脚本. Node.js足以分辨它们的区别.

根据package.json的文档,有一个名为 dependencies 的选项,在这里我们可以填写所有我们计划在项目中使用的第三方模块,同时附上它们的版本号.像之前提到的,我们会使用很多第三方的开源模块去开发这个工具.在我们的项目中,package.json像下面这样:

{
  "name": "broadcast",
  "version": "0.0.1",
  "description": "CLI utility to broadcast emails",
  "main": "broadcast.js",
  "license": "MIT",
  "dependencies": {
    "async": "^2.1.4",
    "chalk": "^1.1.3",
    "commander": "^2.9.0",
    "csv": "^1.1.0",
    "inquirer": "^2.0.0",
    "sendgrid": "^4.7.1"
  }
}

你一定注意到了,我们会用到 Async, Chalk, Commander, CSV, Inquirer.jsSendGrid这些模块.随着我们教程的深入,这些模块的具体用法和细节会慢慢解释.

处理命令行参数

读取命令行参数并不是很难.你可以用 process.argv 很简单的去读取它们.但是分析它们的取值和选项是一项很繁琐的工作.为了避免重复造轮子,我们会使用 Commander 模块.Commander 是一个开源的 Node.js模块,它可以帮助你编写交互式的命令行工具.它带来很多解释命令行选项的有趣特性并且拥有类似 Git 的子命令,但我最喜欢的是它可以自动生成帮助命令.你不需要去写额外的代码 - 执行 --help 或者 -h选项就可以了.当你开始定义各种各样的命令行选项时,帮助命令会自动生成,让我们来试一试:

$ npm install commander --save

这会在你的 Node.js 项目中安装 Commander 模块.在 npm install 命令中加入 --save参数会自动将 Commander 模块添加到 package.json 文件中的 dependencies 参数中.在我们之前填写的 package.json 文件中,我们已经把所有的依赖都写好了,所以我们可以不加 --save 参数.

var program = require("commander");

program
  .version("0.0.1")
  .option("-l, --list [list]", "list of customers in CSV file")
  .parse(process.argv);

console.log(program.list);

正如你看到的那样,处理命令行的参数就是这么直截了当.我们已经定义了一个 --list 参数.现在,我们在 --list 参数后面提供任何值,这个值都会储存在方括号包裹中的变量里.在这里,就是 list.你可以从 program 这个 Commander 的实例中获取到 list 的值.现在,这个程序只接受一个文件路径作为 --list 参数的取值,然后把它打印在控制台中.

$ node broadcast --list input/employees.csv
input/employees.csv

你一定注意到了这里我们定义了另一个方法 version.任何时候只要我们带着 --version 或者 -V参数执行命令,定义中的值就会传入这个方法并且把它打印在控制台.

$ node broadcast --version
0.0.1

相似的,当你带着 --help 参数执行命令的时候,控制台会打印出所有你定义的选项和子命令.在这里,看起来是下面这样的:

$ node broadcast --help

  Usage: broadcast [options]

  Options:

    -h, --help                 output usage information
    -V, --version              output the version number
    -l, --list <list>          list of customers in CSV file

既然已经可以在命令行参数中接受文件路径,我们就可以开始使用 CSV 模块来读取 CSV 文件了.CSV 模块是处理 CSV 文件的一个解决方案.从创建一个 CSV 文件到解析处理它,这个模块可以解决任何相关的问题.

因为计划使用 sendGrid API 来发送电子邮件,我们可以使用下面的文档作为一个 CSV 文件的示例.使用 CSV 模块,我们会读取其中的数据并且在表格中展示姓名和对应的电子邮件地址.

First nameLast nameEmail
DwightSchrutedwight.schrute@dundermifflin.com
JimHalpertjim.halpert@dundermifflin.com
PamBeeslypam.beesly@dundermifflin.com
RyanHowardryan.howard@dundermifflin.com
StanleyHudsonstanley.hudson@dundermifflin.com

现在,让我们写一个程序来读取 CSV 文件并且将其中的数据打印在控制台.

const program = require("commander");
const csv = require("csv");
const fs = require("fs");

program
  .version("0.0.1")
  .option("-l, --list [list]", "List of customers in CSV")
  .parse(process.argv);

let parse = csv.parse;
let stream = fs.createReadStream(program.list).pipe(parse({ delimiter: "," }));

stream.on("data", function (data) {
  let firstname = data[0];
  let lastname = data[1];
  let email = data[2];
  console.log(firstname, lastname, email);
});

使用 Node.js原生的文件模块,我们可以通过命令行参数来读取文件.文件模块执行后是我们提前定义的事件 data,它会在数据被读取时被触发.CSV 模块中的 parse 方法会将 CSV 文件分割成独立的行并且触发多次 data 事件.每一个 data 事件传递一个列数据的数组.这些数据就会以下面这种形式被打印出来:

$ node broadcast --list input/employees.csv
Dwight Schrute dwight.schrute@dundermifflin.com
Jim Halpert jim.halpert@dundermifflin.com
Pam Beesly pam.beesly@dundermifflin.com
Ryan Howard ryan.howard@dundermifflin.com
Stanley Hudson stanley.hudson@dundermifflin.com

运行时的用户输入

现在我们了解了如何接收命令行参数并且去解析它们.但是如果我们希望在运行过程中接受用户的输入呢?一个名为 Inquirer.js 的模块让我们接受许多种输入的方式,从直接输入文本到输入密码甚至到一个多选列表.

在这个样例里,我们会在运行过程的输入中接收发送者的电子邮件地址和姓名.


let questions = [
  {
    type : "input",
    name : "sender.email",
    message : "Sender's email address - "
  },
  {
    type : "input",
    name : "sender.name",
    message : "Sender's name - "
  },
  {
    type : "input",
    name : "subject",
    message : "Subject - "
  }
];
let contactList = [];
let parse = csv.parse;
let stream = fs.createReadStream(program.list)
    .pipe(parse({ delimiter : "," }));

stream
  .on("error", function (err) {
    return console.error(err.message);
  })
  .on("data", function (data) {
    let name = data[0] + " " + data[1];
    let email = data[2];
    contactList.push({ name : name, email : email });
  })
  .on("end", function () {
    inquirer.prompt(questions).then(function (answers) {
      console.log(answers);
    });
  });

首先,你会注意到上面的示例中我们创建了一个名为 contactList 的数组,它是我们用来存储 CSV 文件中的数据的.

Inquirer.js 带来了一个名为 prompt 的方法,这个方法接收一个问题的数组,里面保存着运行期间我们想要问的问题.在这里,我们想要知道发送者的姓名,电子邮件地址和他们邮件的主题.我们已经创建了一个保存了所有问题的 questions 数组.这个数组接受对象作为数组成员,对象中包含 type 属性,可以选择 input,passwordraw list等值.完整的可用值可以在官方文档中找到.在这里,name 定义了保存用户输入的索引(key).prompt 方法返回一个 promise 对象.当用户回答所有的问题之后,这个 promise 对象会触发一系列的成功或失败的回调.answers 作为 then 回调的参数传递,用户的回复可以通过它来获取.下面是执行代码时发生的事情:

$ node broadcast -l input/employees.csv
? Sender's email address -  michael.scott@dundermifflin.com
? Sender's name -  Micheal Scott
? Subject - Greetings from Dunder Mifflin
{ sender:
   { email: 'michael.scott@dundermifflin.com',
     name: 'Michael Scott' },
  subject: 'Greetings from Dunder Mifflin' }

异步网络会话

既然我们已经可以从 CSV 文件中读取接收者的数据并且接收到发送者通过命令行提示填写的信息,是时候发送电子邮件了.我们会使用 SendGrid API来发送电子邮件.


let __sendEmail = function (to, from, subject, callback) {
  let template = "Wishing you a Merry Christmas and a " +
    "prosperous year ahead. P.S. Toby, I hate you.";
  let helper = require('sendgrid').mail;
  let fromEmail = new helper.Email(from.email, from.name);
  let toEmail = new helper.Email(to.email, to.name);
  let body = new helper.Content("text/plain", template);
  let mail = new helper.Mail(fromEmail, subject, toEmail, body);

  let sg = require('sendgrid')(process.env.SENDGRID_API_KEY);
  let request = sg.emptyRequest({
    method: 'POST',
    path: '/v3/mail/send',
    body: mail.toJSON(),
  });

  sg.API(request, function(error, response) {
    if (error) { return callback(error); }
    callback();
  });
};

stream
  .on("error", function (err) {
    return console.error(err.response);
  })
  .on("data", function (data) {
    let name = data[0] + " " + data[1];
    let email = data[2];
    contactList.push({ name : name, email : email });
  })
  .on("end", function () {
    inquirer.prompt(questions).then(function (ans) {
      async.each(contactList, function (recipient, fn) {
        __sendEmail(recipient, ans.sender, ans.subject, fn);
      });
    });
  });

使用 SendGrid 模块需要我们去获取一个 API key.你可以在 SendGrid 的仪表盘生成这个 API key(需要创建一个账户),我们需要把它存在 Node.js 环境变量的 SENDGRID_API_KEY 中.你可以使用 process.env 来获取环境变量.

在上面的代码中,我们使用 SendGrid APIAsync 模块异步发送邮件.Async 模块是 Node.js 中最有用的模块之一.处理异步回调经常会导致回调地狱, 这通常出现在你的一个回调函数里处理了太多其他的回调函数,导致回调没有尽头.对于一个 JavaScript 开发者来说处理回调中的错误太过复杂,而 Async 模块可以帮你去解决回调地狱,提供了像 each, series, map 等许多实用的方法.这些方法能帮助我们更好的组织代码,从另一个方面讲,会让我们的异步代码更像同步的写法.

在这个示例中,相较于向 SendGrid 发送同步请求,我们选择发送异步请求来发送电子邮件.基于请求的响应,我们会发送随后的请求,使用 Async 模块中的 each 方法,我们遍历了 contactList 数组并且触发 __sendEmail函数.这个函数接受收件人和发送人的信息,邮件主题和异步请求的回调函数.__sendEmail 使用SendGrid API来发送电子邮件,它的官方文档上可以了解更多关于它的内容.一旦一封电子邮件成功送达,异步请求的回调函数就会触发,接着就会根据 contactList 下一项的内容继续发送邮件.到这里,我们已经成功创建了一个可以接收 CSV 文件输入并且发送邮件的命令行应用!

美化控制台的输出

既然已经完成了基本功能,现在让我们想一下如何美化控制台的输出结果,比如说错误和成功的信息.为了实现这个功能,我们需要使用用来优化控制台命令展示的 Chalk 模块.


stream
  .on("error", function (err) {
    return console.error(err.response);
  })
  .on("data", function (data) {
    let name = data[0] + " " + data[1];
    let email = data[2];
    contactList.push({ name : name, email : email });
  })
  .on("end", function () {
    inquirer.prompt(questions).then(function (ans) {
      async.each(contactList, function (recipient, fn) {
        __sendEmail(recipient, ans.sender, ans.subject, fn);
      }, function (err) {
        if (err) {
          return console.error(chalk.red(err.message));
        }
        console.log(chalk.green('Success'));
      });
    });
  });

在上面的代码片段中,我们在发送邮件的过程中添加了一个回调函数,它在任何一个异步过程里由于执行过程中的错误导致的完成或中断都会被触发.当异步过程没有完成,控制台会打印红色的信息,相反的,我们用绿色打印成功的信息.

如果你浏览一下 Chalk 的文档,你会发现有很多可自定义的选项,包括一系列的控制台颜色可选,还有下划线和加粗字体.

封装成 shell 命令

既然我们的工具已经完成了,是时候去让它执行起来像一个普通的 shell 命令了.首先,让我们在 broadcast.js 的顶部添加一个注释(shebang),这会告诉 shell 如何去执行这个脚本.

#!/usr/bin/env node

const program = require("commander");
const inquirer = require("inquirer");

现在让我们配置一下 package.json 来让命令变得可执行.


  "description": "CLI utility to broadcast emails",
  "main": "broadcast.js",
  "bin" : {
    "broadcast" : "./broadcast.js"
  }

我们已经添加了一个新的属性 bin ,在这里我们提供了执行 broadcast.js 需要用到的命令.最后一步,让我们把脚本装载到全局环境上,这样我们就可以像一个普通的 shell 命令一样去执行它.

$ npm install -g

在执行这个命令之前,确认你在项目的目录中.安装完成后,你可以进行测试.

$ broadcast --help

这应该会打印出执行 node broadcat --help 后所有可用的选项.现在你可以准备向世界展示你自己的工具了.

有一件事要记住: 在开发过程中,当你只是简单的执行 broadcast 命令,任何你做的改变都不会生效,你会意识到命令的目录和你正在工作的项目目录是不同的.为了避免这种情况,在你的项目文件夹中运行 npm link???即可,这样会在你执行的命令和目录之间自动建立联系.在这之后,无论你做了任何改动同样也会反映在 broadcast 命令中.

JavaScript 之外

JavaScript 项目之外,有很多类似的 CLI 工具在很多领域都运转良好.如果你在软件开发领域有一些经验,你就会明白 Bash 工具在开发过程中是必不可少的.从部署脚本到备份的定时任务,你可以用 Bash 脚本自动化任何工作.在 Docker, Chef 和 Puppet 成为事实上的基础设施管理标准之前,全靠 Bash 来完成这些工作.虽然 Bash 脚本总是会存在问题.它不能简单的融入到开发工作流中.通常情况,我们会使用各种各样的编程语言,而Bash 极少作为核心开发的一部分.甚至在 Bash 脚本中写一个简单的条件判断都要无穷无尽的调试和查阅文档.

但是,使用 JavaScript 能够让整个过程变得更简单更搞笑.所有工具都是天然跨平台的.如果你想在运行一个原生的 shell 命令,比如 git, mongodb或者 heroku, 使用 Node.jsChild Process 模块非常容易实现.这让我们可以在编写工具的时候充分享受到 JavaScript 的便利.

我希望这个教程对你有帮助,如果有任何问题,可以评论或者联系我.