烹飪一道美味的 CLI

寫在最前,其實真想寫一寫食譜來着,苦於烹飪能力有限,所以標題就是個謊言,哈哈^_~

今天咱們就來聊一聊命令行工具(即 CLI:command-line interface,以下都會以 CLI 來代替冗長的命令行工具名詞 )的開發。

閱讀完本文,你會對從頭到尾開發一個 CLI 有一個較全面的認識。

你也可以收藏下這篇文章,當你想開發一個 CLI 時,回來翻一翻,總會找到你想要的。

丹尼爾:花生可樂準備好了,坐等開始。

好勒,這就開始,Let's go! <( ̄︶ ̄)↗[GO!]

> 邁出第一步:初始化項目

創建一個空項目目錄(接下來都是以 cook-cli 來作例子的,所以這裏我們命名爲 cook-cli),然後在該目錄下敲打命令進行初始化,過程如下:

$ mkdir cook-cli
$ cd cook-cli
$ npm init --yes

通過 npm init 命令,會將該目錄初始化爲一個 Node.js 項目,它會在 cook-cli 目錄下生成 package.json 文件。

--yes 會自動回答初始化過程中提問的所有問題,你可以試着將該參數去掉,自己一個一個問題進行回答。

> 主線打通:CLI 骨架代碼

項目已初始完畢,接下來我們添加骨架代碼,讓 CLI 飛一會。

  • 實現者

我們創建 src/index.js 文件,它負責實現 CLI 的功能邏輯,是實際幹活的。代碼如下:

export function cli(args) {
    console.log('I like cooking');
}
  • 代言者

接着創建 bin/cook 文件,它是 CLI 的可執行入口文件,是 CLI 在可執行環境中的代言者。代碼如下:

#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src').cli(process.argv);

細心的你會發現這裏用到了 esm 這個模塊,它的作用是讓我們可以在 js 源代碼中直接使用 ECMAScript modules 規範加載模塊,即直接使用 importexport。上面 src/index.js 的代碼中能直接寫 export 得益於該模塊。

(請在項目根目錄運行 npm i esm 來安裝該模塊)

  • 官宣

我們有代言者,但必須對外宣傳才行。所以在 package.json 中增加 bin 的聲明,對外宣佈代言者的存在。如下:

{
  ...
  "bin": {
    "cook": "./bin/cook"
  },
  ...
}

> 時刻彩排:本地運行和調試

在 CLI 面世之前,本地開發調試是必不可少的,所以便捷的調試途徑非常必要。

丹尼爾:開發 Web 應用,我可以通過瀏覽器來調試功能。那 CLI 昨弄呢?

CLI 最終是在終端運行的,所以我們要先把它註冊爲本地命令行。方法非常簡單,在項目根目錄運行以下命令即可:

$ npm link

該命令會在本地環境註冊一個 cook CLI,並將其執行邏輯代碼鏈接到你的項目目錄,所以你每次修改保存後即立即生效。

試着運行以下命令:

$ cook

丹尼爾:Nice!但我還有個問題,我想要在 vscode 中設置斷點來調試,這樣有時候會更容易排查問題

你說得沒錯。方法也是很簡單的,在 vscode 加入以下配置即可,路徑爲:調試 > 添加配置。根據實際要調試的命令參數,修改 args 的值即可。

{
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Cook",
            "program": "${workspaceFolder}/bin/cook",
            "args": ["hello"] // Fill in the parameters you want to debug
        }
    ]
}

> 意圖識別:入參解析

插個小插曲:雖然你們在工作中可能經常接觸到各種 CLI,但這裏還是有必要對 CLI 涉及到的一些術語作簡短的介紹:

  • 命令(command)和子命令(subcommand)
# cook 即爲命令
cook

# start 即爲 cook 的 子命令
cook start
  • 命令選項(options)
# -V 爲簡寫模式(short flag)的選項(注意:只能一個字母,多個字母代表多個選項)
cook -V

# --version 爲全寫模式(long name)的選項
cook --version
  • 命令參數(argument)
# source.js 和 target.js 都爲 cp 命令的參數
cp source.js target.js
其實,子命令也是命令的參數

Ok,從以上的介紹來看,我們要實現一個 CLI,對入參(包括 subcommand, options, argument)的解析是逃不掉的,那我們就直面它們吧。

commander:嘿,兄弟,別怕,有我呢!

是的,兄弟,有你真好。接下來我們通過使用 commander 這個模塊來解析入參,過程和示例如下:

  • 模塊安裝
npm i commander
  • src/index.js 示例
......
import program from 'commander';

export function cli(args) {
    program.parse(args);
}

一句搞定,就是這麼乾脆利落。

丹尼爾:入參呢?怎麼用呢?

在接下來的例子中,我們就會用到這些解析完的入參對象。所以,請先稍安勿躁。

> 不能沒有你:版本和幫助

版本和幫助信息是一個 CLI 必須提供的部分,不然就顯得太不專業了。我們就來看下如何實現吧。

修改 src/index.js ,代碼如下:

import program from 'commander';
import pkg from '../package.json';

export function cli(args) {
    program.version(pkg.version, '-V, --version').usage('<command> [options]');
    
    program.parse(args);
}

通過 program.versionusage 的鏈式調用就搞定了,還是那麼的冷酷。

試着運行以下命令:

$ cook -V

$ cook -h

> 添加大將:新增子命令

現在我們開始豐富 CLI 的功能,從增加一個子命令 start 開始。

它擁有一個參數 food 和 一個選項 --fruit,代碼如下:

......
export function cli(args) {
  .....

  program
    .command('start <food>')
    .option('-f, --fruit <name>', 'Fruit to be added')
    .description('Start cooking food')
    .action(function(food, option) {
      console.log(`run start command`);
      console.log(`argument: ${food}`);
      console.log(`option: fruit = ${option.fruit}`);
    });

  program.parse(args);
}

上面例子演示瞭如何獲取解析後的入參,在 action 中你可以取到你想要的一切,你想做什麼,完全由你做主。

嘗試運行子命令:

$ cook start pizza -f apple

> 尋求外援:調用外部命令

有些時候,我們需要在 CLI 中去調用外部命令,如 npm 之類的。

execa:該我上場表演了。┏ (^ω^)=☞
  • 模塊安裝
npm i execa
  • src/index.js 示例
......
import execa from 'execa';

export function cli(args) {
  .....

  program
    .command('npm-version')
    .description('Display npm version')
    .action(async function() {
      const { stdout } = await execa('npm -v');
      console.log('Npm version:', stdout);
    });

  program.parse(args);
}

以上通過 execa 來調用外部命令 npm -v。來,打印一下 npm 的版本號吧:

$ cook npm-version

> 促進交流:提供人機交互

有些時候我們希望 CLI 能通過一問一答的方式與用戶互動,用戶通過輸入或選擇的方式來提供我們想要的信息。

一陣大風吹過,Inquirer.js 踏着七彩雲飛奔而來。
  • 模塊安裝
npm i inquirer

最常見的場景是:文本輸入,是否選項,複選,單選。例子如下:

  • src/index.js 示例
......
import inquirer from 'inquirer';

export function cli(args) {
  ......

  program
    .command('ask')
    .description('Ask some questions')
    .action(async function(option) {
      const answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'name',
          message: 'What is your name?'
        },
        {
          type: 'confirm',
          name: 'isAdult',
          message: 'Are you over 18 years old?'
        },
        {
          type: 'checkbox',
          name: 'favoriteFrameworks',
          choices: ['Vue', 'React', 'Angular'],
          message: 'What are you favorite frameworks?'
        },
        {
          type: 'list',
          name: 'favoriteLanguage',
          choices: ['Chinese', 'English', 'Japanese'],
          message: 'What is you favorite language?'
        }
      ]);
      console.log('your answers:', answers);
    });

  program.parse(args);
}

代碼淺顯,直接上效果圖吧:

> 減少焦慮:等待提醒

人機交互體驗很重要,如果不能馬上完成的工作,就需要及時反饋用戶當前工作的進度,這樣可以減少用戶的等待焦慮感。

oralistr 肩並着肩,邁着整齊的步伐,迎面而來。

首先上場的是 ora

  • 模塊安裝
npm i ora
  • src/index.js 示例
......
import ora from 'ora';

export function cli(args) {

  ......

  program
    .command('wait')
    .description('Wait 5 secords')
    .action(async function(option) {
      const spinner = ora('Waiting 5 seconds').start();
      let count = 5;
      
      await new Promise(resolve => {
        let interval = setInterval(() => {
          if (count <= 0) {
            clearInterval(interval);
            spinner.stop();
            resolve();
          } else {
            count--;
            spinner.text = `Waiting ${count} seconds`;
          }
        }, 1000);
      });
    });

  program.parse(args);
}

話不多說,直接上圖:

listr 隨後而來。

  • 模塊安裝
npm i listr
  • src/index.js 示例
......
import Listr from 'listr';

export function cli(args) {
  ......

  program
    .command('steps')
    .description('some steps')
    .action(async function(option) {
      const tasks = new Listr([
        {
          title: 'Run step 1',
          task: () =>
            new Promise(resolve => {
              setTimeout(() => resolve('1 Done'), 1000);
            })
        },
        {
          title: 'Run step 2',
          task: () =>
            new Promise((resolve) => {
              setTimeout(() => resolve('2 Done'), 1000);
            })
        },
        {
          title: 'Run step 3',
          task: () =>
            new Promise((resolve, reject) => {
              setTimeout(() => reject(new Error('Oh, my god')), 1000);
            })
        }
      ]);

      await tasks.run().catch(err => {
        console.error(err);
      });
    });

  program.parse(args);
}

依然話不多說,依然直接上圖:

> 加點色彩:讓生活不再單調

chalk:我是文藝青年,我爲藝術而活,這該非我莫屬了。<( ̄ˇ ̄)/

  • 模塊安裝
npm i chalk
  • src/index.js 示例
.....
import chalk from 'chalk';


export function cli(args) {

  console.log(chalk.yellow('I like cooking'));
  
  .....
  
}

有了色彩的 CLI,是不是讓你心情更加愉悅:

> 門面裝飾:加個邊框

boxen:這個是我的拿手好戲,看我的!<(ˉ^ˉ)>
  • 模塊安裝
npm i boxen
  • src/index.js 示例
......
import boxen from 'boxen';

export function cli(args) {

  console.log(boxen(chalk.yellow('I like cooking'), { padding: 1 }));
  
  ......
}  

嗯,看上去專業一些了:

> 公佈成果:可以發表了

如果你是以 scope 方式發佈,例如 @daniel-dx/cook-cli。那麼在 package.json 中增加以下配置可以讓你順利發佈(當然,如果你是 npm 的付費會員,那這個配置是可以省的)

{
  "publishConfig": {
    "access": "public"
  },
}

臨門一腳,發射:

npm publish

OK,已經對全世界發佈了你的 CLI 了,現在你可以到 https://www.npmjs.com/ 去查詢下你發佈的 CLI 了。

> 溫馨提醒:該升級了

update-notifier:終於到我了,我等到花兒已謝了。 X﹏X
  • 模塊安裝
npm i update-notifier
  • src/index.js 示例
......

import updateNotifier from 'update-notifier';

import pkg from '../package.json';

export function cli(args) {
  checkVersion();
  
  ......
}

function checkVersion() {
  const notifier = updateNotifier({ pkg, updateCheckInterval: 0 });

  if (notifier.update) {
    notifier.notify();
  }
}

爲了本地調試,我們將本地的 CLI 降一個版本,把 package.jsonversion 修改爲 0.0.9,然後運行 cook 查看效果:

o( ̄︶ ̄)o 完美!


以上詳細地介紹了開發一個 CLI 的一些必備或常用的步驟。

當然,如果你只想快速開發一個CLI,就像一些領導經常說的:不要跟我說過程,我只要結果。那完全可以使用如 oclif 這些專爲開發 CLI 而生的框架,開箱即用。

而我們作爲程序員,對於解決方案的來龍去脈,前世今生的瞭解,還是需要爲些付出些時間和精力的,這樣可以讓我們更踏實,走得更遠。

好了,今天就聊到這了,再見我的朋友們!

差點忘了,附上示例的源碼:https://github.com/daniel-dx/...

┏(^0^)┛ ByeBye!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章