「譯」使用 Node 構建命令行應用


使用 Node 構建命令行應用

JavaScript 的開發領域內,命令行應用還還沒有得到足夠的關注度。事實上,大部分開發工具都應該提供命令行界面來給像咱們同樣的開發者使用,而且用戶體驗應該與精心建立的 Web 應用程序至關,好比一個漂亮的設計,易用的菜單,清晰的錯誤反饋,加載提示和進度條等。html

目前並無太多的實際教程來指導咱們使用 Node 構建命令行界面,因此本文將是開篇之做,基於一個基本的 hello world 命令應用,逐步構建一個名爲 outside-cli 的應用,它能夠提供當前的天氣並預測將來 10 天任何地方的天氣狀況。node

提示:有很多的庫能夠幫助你構建複雜的命令行應用,例如 oclifyargscommander,可是爲了你更好地理解背後的原理,咱們會保持外部依賴儘量的少。固然,咱們假設你已經擁有了 JavaScriptNode 的基礎知識。ios

入門

與其餘的 JavaScript 項目同樣,最佳實踐即是建立 package.json 和一個空的入口文件,目前還不須要任何依賴,保持簡單。git

package.jsongithub

{
  "name": "outside-cli",
  "version": "1.0.0",
  "license": "MIT",
  "scripts": {},
  "devDependencies": {},
  "dependencies": {}
}
複製代碼

index.jsshell

module.exports = () => {
  console.log('Welcome to the outside!')
}
複製代碼

咱們將使用 bin 文件來運行這個新程序,而且會把 bin 文件添加到系統目錄裏,使其在任何地方均可以被調用。npm

#!/usr/bin/env node
require('../')()
複製代碼

是否是以前從未見過 #!/usr/bin/env node ? 它被稱爲 shebang。它告知系統這不是一個 shell 腳本並指明應該使用不一樣的解釋程序。json

bin 文件須要保持簡單,由於它的本意僅是用來調用主函數,咱們全部的代碼都應當放置在此文件以外,這樣才能夠保證模塊化和可測試,同時也能夠實現將來在其餘的代碼裏被調用。axios

爲了可以直接運行 bin 文件,咱們須要賦予正確的文件權限,若是你是在 UNIX 環境下,你只須要執行 chmod +x bin/outsideWindows 用戶就只能靠本身了,建議使用 Linux 子系統。api

接下來,咱們將添加 bin 文件到 package.json 裏,隨後當咱們全局安裝此包時( npm install -g outside-cli ),bin 文件會被自動添加到系統目錄內。

package.json

{
  "name": "outside-cli",
  "version": "1.0.0",
  "license": "MIT",
  "bin": {
    "outside": "bin/outside"
  },
  "scripts": {},
  "devDependencies": {},
  "dependencies": {}
}
複製代碼

如今咱們輸入 ./bin/outside ,就能夠直接運行了,歡迎消息將會被打印出來,在你的項目根目錄執行 npm link,它將會在系統路徑和你的二進制文件之間創建軟鏈接,這樣 outside 命令即可以在任何地方運行了。

CLI 應用程序由參數和指令構成,參數(或「標誌」)是指前綴爲一個或兩個連字符構成的值(例如 -d--debug--env production ),它對應用來講很是有用。指令是指沒有標誌的其餘全部值。

與指令不一樣,參數並不要求特定的順序,舉個例子,運行 outside today Brooklyn,必須約定第二個指令只能表明地域,使用 -- 則否則,運行 outside today --location Brooklyn,能夠方便地添加更多的選項。

爲了使應用更加實用,咱們須要解析指令和參數,而後轉換爲字面量對象,咱們可使用 process.argv 來手動實現,可是如今咱們要安裝項目的第一個依賴 minimist ,讓它來幫咱們搞定這些事兒。

npm install --save minimist
複製代碼

index.js

const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))
  console.log(args)
}
複製代碼

提示:由於 process.argv 的前兩個參數分別是解釋器和二進制文件名,因此咱們使用 .slice(2) 移除掉前兩個參數,只關心傳遞進來的其餘命令。

如今執行 outside today 將會輸出 { _: ['today'] }。執行 outside today --location "Brooklyn, NY",將會輸出 { _: ['today'], location: 'Brooklyn, NY' }。不過如今咱們不用進一步深挖參數的用法,等到實際使用 location的時候再繼續深刻,目前瞭解的已經足夠咱們實現第一個指令了。

參數語法

能夠經過這篇文章幫助你更好地理解參數語法。基本上,一個參數能夠有一個或者兩個連字符,而後緊跟着是它對應的值,在不填寫時它的值默認爲 true, 單連字符參數還可使用縮寫的格式( -a -b -c 或者 -abc 都對應着 { a: true, b: true, c: true } )。

若是參數值包含特殊字符或者空格,則必須使用引號包裹着。例如 --foo bar 對應着 { : ['baz'], foo: 'bar' }--foo "bar baz" 對應 { foo: 'bar baz' }

分割每一個指令的代碼,在其被調用時再加載至內存是一個最佳實踐,這有助於縮短啓動時間,避免沒必要要的加載。在主指令代碼裏簡單地使用 switch 就能夠實現此實踐了。在這種設置下,咱們須要把每一個指令寫到獨立的文件裏,而且導出一個函數,與此同時,咱們把參數傳遞給每一個指令函數用以在後期使用。

index.js

const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))
  const cmd = args._[0]

  switch (cmd) {
    case 'today':
      require('./cmds/today')(args)
      break
    default:
      console.error(`"${cmd}" is not a valid command!`)
      break
  }
}
複製代碼

cmds/today.js

module.exports = (args) => {
  console.log('today is sunny')
}
複製代碼

如今若是執行 outside today,你會看到輸出 today is sunny,若是執行 outside foobar,會輸出 "foobar" is not a valid command。目前的原型已經很不錯了,接下來咱們須要經過 API 來獲取天氣的真實數據。

有一些命令和參數是咱們但願在每一個命令行應用中都包含的:help--help-h 用來展現幫助清單;--version-v 用來顯示當前應用的版本信息。當指令沒有指定時,咱們也應當默認展現幫助清單。

Minimist 會自動解析參數爲鍵值對,所以運行 outside --version 會使得 args.version 等於 true。那麼在程序裏經過設置 cmd 變量來保存 helpversion 參數的斷定結果,而後在 switch 語句中添加兩個處理語句,就能夠實現上述功能了。

const minimist = require('minimist')

module.exports = () => {
  const args = minimist(process.argv.slice(2))

  let cmd = args._[0] || 'help'

  if (args.version || args.v) {
    cmd = 'version'
  }

  if (args.help || args.h) {
    cmd = 'help'
  }

  switch (cmd) {
    case 'today':
      require('./cmds/today')(args)
      break

    case 'version':
      require('./cmds/version')(args)
      break

    case 'help':
      require('./cmds/help')(args)
      break

    default:
      console.error(`"${cmd}" is not a valid command!`)
      break
  }
}
複製代碼

實現新指令時,格式須要和 today 指令保持一致。

cmds/version.js

const { version } = require('../package.json')

module.exports = (args) => {
  console.log(`v${version}`)
}
複製代碼

cmds/help.js

const menus = {
  main: `
    outside [command] <options>

    today .............. show weather for today
    version ............ show package version
    help ............... show help menu for a command`,

  today: `
    outside today <options>

    --location, -l ..... the location to use`,
}

module.exports = (args) => {
  const subCmd = args._[0] === 'help'
    ? args._[1]
    : args._[0]

  console.log(menus[subCmd] || menus.main)
}
複製代碼

如今若是執行 outside help todayoutside toady -h,你便會看到 today 指令的幫助信息了,執行 outsideoutside -h 亦是如此。

目前的項目設定是使人愉悅的,由於當你須要添加一個新指令時,你只須要建立一個新指令文件,把它添加到 switch 語句中,再設置一個幫助信息即可以了。

cmds/forecast.js

module.exports = (args) => {
  console.log('tomorrow is rainy')
}
複製代碼

index.js

*// ...*
    case 'forecast':
      require('./cmds/forecast')(args)
      break
*// ...*
複製代碼

cmds/help.js

const menus = {
  main: `
    outside [command] <options>

    today .............. show weather for today
    forecast ........... show 10-day weather forecast
    version ............ show package version
    help ............... show help menu for a command`,

  today: `
    outside today <options>

    --location, -l ..... the location to use`,

  forecast: `
    outside forecast <options>

    --location, -l ..... the location to use`,
}

// ...
複製代碼

有些指令執行起來可能須要很長時間。若是你會執行從 API 獲取數據,內容生成,將文件寫入磁盤,或者其餘須要花費超過幾毫秒的程序,那麼便須要向用戶提供一些反饋來代表你的程序仍在響應中。你可使用進度條來展現操做的進度,也能夠直接顯示一個進度指示器。

對當前的應用來講,咱們沒法獲知 API 請求的進度,因此咱們使用一個簡單的 spinner 來表達程序仍在運行中就能夠了。咱們接下來安裝兩個依賴,axios 用於網絡請求,ora 來實現 spinner

npm install --save axios ora
複製代碼

從 API 獲取數據

如今咱們先建立一個使用雅虎天氣 API 來得到某個地域天氣狀況的工具函數。

提示:雅虎 API 使用很是簡潔的 YQL 語法,咱們不須要刻意理解它,直接拷貝使用便可。另外,它也是惟一一個我發現不須要提供 API key 的天氣 API 了。

utils/weather.js

const axios = require('axios')

module.exports = async (location) => {
  const results = await axios({
    method: 'get',
    url: 'https://query.yahooapis.com/v1/public/yql',
    params: {
      format: 'json',
      q: `select item from weather.forecast where woeid in
        (select woeid from geo.places(1) where text="${location}")`,
    },
  })

  return results.data.query.results.channel.item
}
複製代碼

cmds/today.js

const ora = require('ora')
const getWeather = require('../utils/weather')

module.exports = async (args) => {
  const spinner = ora().start()

  try {
    const location = args.location || args.l
    const weather = await getWeather(location)

    spinner.stop()

    console.log(`Current conditions in ${location}:`)
    console.log(`\t${weather.condition.temp}° ${weather.condition.text}`)
  } catch (err) {
    spinner.stop()

    console.error(err)
  }
}
複製代碼

如今當你執行 outside today --location "Brooklyn, NY" 後,你首先會看到一個快速旋轉的 spinner 出如今應用發起請求期間,隨後便會展現天氣信息了。

當請求發生得很快時,咱們是難以看到加載指示的,若是你想人爲地減慢速度,你能夠在請求天氣工具函數前加上這一句:await new Promise(resolve => setTimeout(resolve, 5000))

很是棒!接下來咱們複製下上面的代碼來實現 forecast 指令,而後簡單修改下輸出格式。

cmds/forecast.js

const ora = require('ora')
const getWeather = require('../utils/weather')

module.exports = async (args) => {
  const spinner = ora().start()

  try {
    const location = args.location || args.l
    const weather = await getWeather(location)

    spinner.stop()

    console.log(`Forecast for ${location}:`)
    weather.forecast.forEach(item =>
      console.log(`\t${item.date} - Low: ${item.low}° | High: ${item.high}° | ${item.text}`))
  } catch (err) {
    spinner.stop()

    console.error(err)
  }
}
複製代碼

如今當你執行 outside forecast --location "Brooklyn, NY" 後,你會看到將來 10 天的天氣預測結果了。接下來咱們再錦上添花下,當 location 沒有指定時,使用咱們編寫的一個工具函數來實現自動根據 IP 地址獲取所處位置。

utils/location.js

const axios = require('axios')

module.exports = async () => {
  const results = await axios({
    method: 'get',
    url: 'https://api.ipdata.co',
  })

  const { city, region } = results.data
  return `${city}, ${region}`
}
複製代碼

cmds/today.js & cmds/forecast.js

*// ...*
const getLocation = require('../utils/location')

module.exports = async (args) => {
  *// ...*
    const location = args.location || args.l || await getLocation()
    const weather = await getWeather(location)
  *// ...*
}
複製代碼

如今當你不添加 location 參數執行指令後,你將會看到當前地域對應的天氣信息。

錯誤處理

本篇文章咱們並不會詳細介紹錯誤處理的最佳方案(後面的教程裏會介紹),可是最重要的是要記住使用正確的退出碼。

若是你的命令行應用出現了嚴重錯誤,你應當使用 process.exit(1),終端會感知到程序並未徹底執行,此時即可以經過 CI 程序來對外通知。

接下來咱們建立一個工具函數來實現當運行一個不存在的指令時,程序會拋出正確的退出碼。

utils/error.js

module.exports = (message, exit) => {
  console.error(message)
  exit && process.exit(1)
}
複製代碼

index.js

*// ...*
const error = require('./utils/error')

module.exports = () => {
  *// ...*
    default:
      error(`"${cmd}" is not a valid command!`, true)
      break
  *// ...*
}
複製代碼

收尾

最後一步是將咱們編寫的庫發佈到遠程包管理平臺上,因爲咱們使用 JavaScriptNPM 再合適不過了。如今,咱們須要額外填一些兒信息到 package.json 裏。

{
  "name": "outside-cli",
  "version": "1.0.0",
  "description": "A CLI app that gives you the weather forecast",
  "license": "MIT",
  "homepage": "https://github.com/timberio/outside-cli#readme",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/timberio/outside-cli.git"
  },
  "engines": {
    "node": ">=8"
  },
  "keywords": [
    "weather",
    "forecast",
    "rain"
  ],
  "preferGlobal": true,
  "bin": {
    "outside": "bin/outside"
  },
  "scripts": {},
  "devDependencies": {},
  "dependencies": {
    "axios": "^0.18.0",
    "minimist": "^1.2.0",
    "ora": "^2.0.0"
  }
}
複製代碼
  • 設置 engine 能夠確保使用者擁有一個較新的 Node 版本。由於咱們未經編譯直接使用了 async/await,因此咱們要求 Node 版本 必須在 8.0 及以上。

  • 設置 preferGlobal 將會在安裝時提示使用者本庫最好全局安裝而非做爲局部依賴安裝。

目前就這些內容了,如今你即可以經過 npm publish 發佈至遠端來供他人下載了。若是你想更進一步,發佈到其餘包管理工具(例如 Homebrew )上,你能夠了解下 pkgnexe,它們能夠幫助你把應用打包到一個獨立的二進制文件裏。

總結

本篇文章介紹的代碼目錄結構是 Timber 上全部的命令行應用都遵循的,它有助於保持組織和模塊化。

對於速讀的讀者,咱們也提供了一些本教程的關鍵要點

  • Bin 文件是整個命令行應用的入口,它的職責僅是調用主函數。

  • 指令文件在未執行時不該該被加載到主函數裏。

  • 始終包含 helpversion 指令。

  • 指令文件須要保持簡單,它們的主要職責是調用其餘工具函數,隨後展現信息給用戶。

  • 始終包含一些運行指示給到用戶。

  • 應用退出時應當使用正確的退出碼。

我但願你如今可以更好地瞭解如何使用 Node 建立和組織命令行應用。本文只是開篇之做,隨後咱們會繼續深刻理解如何優化設計,生成 ascii art 和添加色彩等。本文的源碼能夠在 GitHub 上獲取到。

相關文章
相關標籤/搜索