使用 Node 创建真实世界的 CLI 应用程序
本文最初发布于 Timber.io。欢迎了解我们,试用我们的产品,或阅读我们的其他文章。我们是一个由开发者为开发者打造的云端日志平台。减少调试时间,将更多时间用于交付。
命令行是 JavaScript 开发领域中尚未得到足够重视的用户界面。事实上,大多数开发工具都应该有一个 CLI,以便像我们这样的技术宅使用,并且用户体验应该与精心打造的 Web 应用媲美。这包括美观的设计、实用的菜单、清晰的错误信息和输出、加载指示器和进度条等等。
关于使用 Node 构建命令行界面,市面上很少有实际的教程,所以这是系列教程的第一篇,它将超越基本的“hello world”命令行应用。我们将创建一个名为 的应用outside-cli
,它可以显示任意地点的当前天气和未来 10 天的天气预报。
注意:市面上有不少库可以帮助创建复杂的 CLI,例如oclif、yargs和commander,但为了方便演示,我们将尽量减少依赖项,以便您更好地理解底层工作原理。本教程假设您具备 JavaScript 和 Node.js 的基本使用知识。
设置项目
与所有 JavaScript 项目一样,创建 package.json 和入口文件是启动项目的最佳方式。我们可以尽量简化——目前还不需要任何依赖项。
包.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 文件,我们需要赋予它正确的文件系统权限。如果您使用的是 UNIX 系统,这很简单,只需运行 即可chmod +x bin/outside
。如果您使用的是 Windows 系统,建议您使用 Linux 子系统。
接下来,我们将二进制文件添加到 package.json 文件中。当用户将我们的包作为全局变量 ( ) 安装时,它将自动添加到用户的系统路径中npm install -g outside-cli
。
包.json
{
"name": "outside-cli",
"version": "1.0.0",
"license": "MIT",
"bin": {
"outside": "bin/outside"
},
"scripts": {},
"devDependencies": {},
"dependencies": {}
}
现在我们可以通过运行 直接调用我们的 bin 文件./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)
}
注意:我们之所以删除前两个参数,.slice(2)
是因为第一个参数始终是解释器,后跟被解释的文件名。我们只关心其后的参数。
现在运行outside today
应该会输出{ _: ['today'] }
。如果运行outside today --location "Brooklyn, NY"
,它应该输出{ _: ['today'], location: 'Brooklyn, NY' }
。稍后我们实际使用位置时会更深入地讨论参数,但现在这足以设置我们的第一个命令。
参数语法
为了更好地理解参数语法的工作原理,您可以阅读此文。基本上,标志位可以是单连字符或双连字符,并且会获取命令中紧跟的值,如果命令中没有值则为 true。单连字符标志位也可以组合起来,形成简写布尔值(-a -b -c
或者-abc
会返回{ a: true, b: true, c: true }
.)。
重要的是要记住,如果值包含特殊字符或空格,则必须用引号引起来。运行--foo bar baz
会返回{ _: ['baz'], foo: 'bar' }
,但运行--foo "bar baz"
又会返回{ foo: 'bar baz' }
。
运行命令
最好将每个命令的代码拆分开来,只在调用时才加载到内存中。这样可以加快启动速度,并避免加载不必要的模块。minimist 提供的 main 命令上有一个 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
,你会看到“今天是晴天”的消息;如果你运行outside foobar
,它会告诉你“foobar”不是一个有效的命令。显然,我们仍然需要查询天气 API 来获取真实数据,但这是一个很好的开始。
预期命令
每个 CLI 都应该包含以下几个命令和参数:help
、--help
和-h
,它们显然应该显示帮助菜单; 、和version
,它们应该输出当前的应用程序版本。如果没有指定任何命令,我们也应该默认使用主帮助菜单。--version
-v
在我们当前的设置中,只需在 switch 语句中添加两个 case 条件、为cmd
变量添加一个默认值,并为 help 和 version 参数标志实现一些 if 语句,即可轻松实现此操作。Minimist 会自动将参数解析为键/值对,因此运行后outside --version
将使args.version
equal 语句为真。
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 today
或outside today -h
,您应该会看到该命令的帮助菜单today
。运行outside
或outside -h
应该会显示主帮助菜单。
添加另一个命令
这个项目设置非常棒,因为如果您需要添加一个新命令,您需要做的就是在cmds
文件夹中创建一个新文件,将其添加到 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 请求的进度,因此我们将使用一个基本的旋转器来显示正在发生的事情。为我们的网络请求和旋转器安装另外两个依赖项:
$ npm install --save axios ora
现在让我们创建一个实用程序,它将向 Yahoo 天气 API 发出请求,以获取某个位置的当前状况和预报。
注意:Yahoo API 使用“YQL”语法,有点奇怪——不要试图理解它,只需复制粘贴即可。这是我能找到的唯一一个不需要 API 密钥的天气 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"
,您将看到在发出请求时快速旋转的窗口,然后显示当前的天气状况。
由于请求速度过快,加载指示器可能难以看清。如果您想手动减慢加载速度以便查看,可以在天气实用程序函数的开头添加以下代码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)
}
}
现在运行 ,你就可以看到未来 10 天的天气预报了outside forecast --location "Brooklyn, NY"
。看起来不错!让我们再添加一个实用程序,如果命令中没有指定位置,它会自动根据 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)
// ...
}
现在,如果您只是在没有任何位置的情况下跑步outside forecast
,您将看到当前位置的预测。
错误和退出代码
我没有详细介绍如何最好地处理错误(这将在后续教程中介绍),但最重要的是要记住使用正确的退出代码。如果您的 CLI 出现严重错误,您应该使用 退出process.exit(1)
。这会让终端知道程序没有正常退出——例如,CI 服务会通知您。让我们创建一个快速实用程序来执行此操作,以便在运行不存在的命令时获取正确的退出代码。
实用程序/错误.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
// ...
}
发布到 NPM
将我们的库发布到公共库的最后一步是将其发布到包管理器。由于我们的应用是用 JavaScript 编写的,因此发布到 NPM 是合理的。让我们进一步完善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 install --save
npm install --global
就这样!现在就可以运行了npm publish
,你的应用就可以下载了。如果你想更进一步,在其他包管理器(比如 Homebrew)上发布,可以试试pkg或nexe,它们可以帮助你将应用打包成一个独立的二进制文件。
总结
在Timber ,我们所有的 CLI 应用都遵循这种结构,它有助于保持系统的条理性和模块化。对于只浏览过本教程的人来说,这里有一些关键要点:
- Bin 文件是任何 CLI 应用程序的入口点,并且应该只调用主函数
- 除非需要,否则不应要求命令文件
- 始终包含
help
和version
命令 - 保持命令文件精简——它们的主要目的是调用函数并显示用户消息
- 始终显示某种活动指示器
- 使用正确的错误代码退出
我希望你现在对如何在 Node.js 中创建和组织 CLI 应用有了更好的理解。这是系列教程的第一部分,稍后我们将更深入地讲解如何添加设计、ASCII 字符和颜色、接受用户输入、编写集成测试等等,请稍后再回来学习。你可以在 GitHub 上查看我们今天编写的所有源代码。
文章来源:https://dev.to/timber/creating-a-real-world-cli-app-with-node-5am5