翻譯計劃 – 用 node.js 開發一個可互動的命令列應用

Icarus發表於2019-03-01

譯者:Icarus
原文連結:How To Develop An Interactive Command Line Application Using Node.js

近幾年, Node.js 在軟體開發的一致性上助力很大.無論是前端開發,服務端指令碼,跨平臺桌面/移動端應用或是物聯網應用,Node.js 都可以幫你完成.由於 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 name Last name Email
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

現在,讓我們寫一個程式來讀取 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 的便利.

我希望這個教程對你有幫助,如果有任何問題,可以評論或者聯絡我.

相關文章