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

阿里雲前端發表於2018-09-09

使用 Node 構建命令列應用

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

JavaScript 的開發領域內,命令列應用還尚未獲得足夠的關注度。事實上,大部分開發工具都應該提供命令列介面來給像我們一樣的開發者使用,並且使用者體驗應該與精心建立的 Web 應用程式相當,比如一個漂亮的設計,易用的選單,清晰的錯誤反饋,載入提示和進度條等。

目前並沒有太多的實際教程來指導我們使用 Node 構建命令列介面,所以本文將是開篇之作,基於一個基本的 hello world 命令應用,逐步構建一個名為 outside-cli 的應用,它可以提供當前的天氣並預測未來 10 天任何地方的天氣情況。

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

提示:有不少的庫可以幫助你構建複雜的命令列應用,例如 oclifyargscommander,但是為了你更好地理解背後的原理,我們會保持外部依賴儘可能的少。當然,我們假設你已經擁有了 JavaScriptNode 的基礎知識。

入門

與其他的 JavaScript 專案一樣,最佳實踐便是建立 package.json 和一個空的入口檔案,目前還不需要任何依賴,保持簡單。

package.json

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

index.js

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

我們將使用 bin 檔案來執行這個新程式,並且會把 bin 檔案新增到系統目錄裡,使其在任何地方都可以被呼叫。

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

是不是之前從未見過 #!/usr/bin/env node ? 它被稱為 shebang。它告知系統這不是一個 shell 指令碼並指明應該使用不同的解釋程式。

bin 檔案需要保持簡單,因為它的本意僅是用來呼叫主函式,我們所有的程式碼都應當放置在此檔案之外,這樣才可以保證模組化和可測試,同時也可以實現未來在其他的程式碼裡被呼叫。

為了能夠直接執行 bin 檔案,我們需要賦予正確的檔案許可權,如果你是在 UNIX 環境下,你只需要執行 chmod +x bin/outsideWindows 使用者就只能靠自己了,建議使用 Linux 子系統。

接下來,我們將新增 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 亦是如此。

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

目前的專案設定是令人愉悅的,因為當你需要新增一個新指令時,你只需要建立一個新指令檔案,把它新增到 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))

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

非常棒!接下來我們複製下上面的程式碼來實現 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 引數執行指令後,你將會看到當前地域對應的天氣資訊。

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

錯誤處理

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

如果你的命令列應用出現了嚴重錯誤,你應當使用 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 上獲取到。

相關文章