「译」使用 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 上获取到。

相关文章
相关标签/搜索