從 egg-bin 聊到 command line interface Tool

最近正在看一些關於 egg 方面的東西,其中對於 egg 的運行方式是基於 egg-bin 來處理的,正好可以藉此機會通過 egg-bin 來了解 egg 的運行過程以及 egg-bin 在其他場景下的作用。而 egg-bin 是基於 common-bin(封裝的 cli 開發工具)開發的,其中對於 node cli 工具的開發方式也頗有啓發,一併進行下相關方面的學習。

關於 node 的命令行程序已經屢見不鮮了,譬如經常使用到的 npmwebpackcreate-react-appyarn 等等,雖然都作爲輔助工具使用,但對於各種使用場景都可以說不可或缺,也大大提高了開發中的效率。衆所周知其實我們在這些程序中跑的每個指令不過就是一個封裝好功能的腳本罷了,其原理其實沒有什麼好提的,但如果想要開發一個也已應用於指定場景的 cli 工具還是有一些方面需要注意的,本文選用了egg-bin 來進行具體分析,其中 egg-bin 是一個便捷開發者在本地開發、調試、測試 egg 的命令行開發工具,集成了本地調試、單元測試和代碼覆蓋率等功能,最後會指出一些在開發 cli 工具的一些常用操作。

概覽

egg-bin 基於抽象命令行工具 common-bin ,一個抽象封裝了諸如 yargs、co 模塊,並提供對於 async/generator 特性的支持,內置了 helper、subcommand 等實用功能,也算是五臟俱全了,憑藉這些封裝可以以及對於 cli 文件結構的約定,可以大大簡化一個 node 工具的開發流程。

  1. 基於 common-bin (在 yargs 上抽象封裝的 node 命令行工具,支持 async/generator 特性)
  2. 包含 CovCommand 代碼覆蓋率命令、DebugCommand 本地調試命令、DevCommand 本地開發命令、PkgfilesCommand package.json 文件編輯、TestCommand 測試命令

其文件結構如下:

├── bin
│   └── egg-bin.js
├── lib
│   ├── cmd
│   │   ├── cov.js
│   │   ├── debug.js
│   │   ├── dev.js
│   │   ├── pkgfiles.js
│   │   └── test.js
│   ├── command.js
│   ├── mocha-clean.js
│   └── start-cluster
├── index.js
└── package.json

在入口 index.js 文件中構造了 EggBin 對象,並將 cmd 文件夾下的命令自動掛載到實例對象下面

class EggBin extends Command {
  constructor(rawArgv) {
    super(rawArgv);
    this.usage = 'Usage: egg-bin [command] [options]';

    // load directory
    this.load(path.join(__dirname, 'lib/cmd'));
  }
}

接着通過執行 Command-binstart() 方法,完成構造函數的內容,實際上則是啓動 yargs 實例進程,並檢查 load 的子命令,將所有命令統一生成一個 map 集合,並在 yargs 上註冊,先看構造階段都做了些什麼事:

  • load 子命令配置文件,自動註冊所有該文件夾下的子命令
load(fullPath) {
    // load entire directory
    const files = fs.readdirSync(fullPath);
    const names = [];
    for (const file of files) {
      if (path.extname(file) === '.js') {
        const name = path.basename(file).replace(/\.js$/, '');
        names.push(name);
        this.add(name, path.join(fullPath, file));
      }
    }
  }

找到的所有 files 有 'autod.js', 'cov.js', 'debug.js', 'dev.js', 'pkgfiles.js', 'test.js', 通過遍歷所有的 files ,並進行 addCommand 操作,

add(name, target) {
    assert(name, `${name} is required`);
    if (!(target.prototype instanceof CommonBin)) {
      assert(fs.existsSync(target) && fs.statSync(target).isFile(), `${target} is not a file.`);
      target = require(target);
      assert(target.prototype instanceof CommonBin,
        'command class should be sub class of common-bin');
    }
    this[COMMANDS].set(name, target);
  }

最後可以看到生成了實例化後的 Command 集合

在完成了構造階段的所有工作後,纔開始執行 start() 內的內容,start()裏面主要是使用co包了一個generator函數,並且在generator函數中執行了this[DISPATCH],實際上的工作都是在這其中完成的。

* [DISPATCH]() {
    // 執行 yargs 中的方法
    this.yargs
      .completion()
      .help()
      .version()
      .wrap(120)
      .alias('h', 'help')
      .alias('v', 'version')
      .group([ 'help', 'version' ], 'Global Options:');

    // 檢查是否存在該子命令, 存在遞歸判斷是否存在子命令
    if (this[COMMANDS].has(commandName)) {
      const Command = this[COMMANDS].get(commandName);
      const rawArgv = this.rawArgv.slice();
      rawArgv.splice(rawArgv.indexOf(commandName), 1);
      const command = new Command(rawArgv);
      yield command[DISPATCH]();
      return;
    }

    // 不存在指令, 則默認顯示所有命令幫助信息
    for (const [ name, Command ] of this[COMMANDS].entries()) {
      this.yargs.command(name, Command.prototype.description || '');
    }

    const context = this.context;

    // print completion for bash
    if (context.argv.AUTO_COMPLETIONS) {
      // slice to remove `--AUTO_COMPLETIONS=` which we append
      this.yargs.getCompletion(this.rawArgv.slice(1), completions => {
        // console.log('%s', completions)
        completions.forEach(x => console.log(x));
      });
    } else {
      // handle by self
      yield this.helper.callFn(this.run, [ context ], this);
    }
  }

首先會去執行yargs中一些方法,這裏common-bin只是保留了yargs中一些對自己有用的方法,比如completion()、wrap()、alias()等. 接着會對獲取到的命令進行校驗,如果存在this[COMMAND]對象中就遞歸判斷是否存在子命令。在當前例子中也就是去執行DevCommand, 而由於DevCommand最終也是繼承於common-bin的,然後執行 yield command[DISPATCH](); 接着開始遞歸執行this[DISPATCH]了,直到所有的子命令遞歸完畢,纔會去使用helper(common-bin中支持異步的關鍵所在)類繼續執行指定 command 文件中的* run()函數 ,執行腳本操作( 自動注入了 context 實例對象 { cwd, env, argv, rawArgv } 包含了當前操作路徑、操作環境信息、處理前後的參數)。

主要功能概覽

DEV 多 cluster 服務的啓動過程

首先我們打開 DEBUG 信息並啓動一個 port 爲 7003,cluster 數爲3個的 egg 服務, 看啓用服務的實際執行路徑:

$ DEBUG=egg-bin ./node_modules/.bin/egg-bin dev -p 7003 -c 3

->
egg-bin detect available port +0ms
  egg-bin use available port 7001 +18ms
  egg-bin /Users/nickj/Desktop/Project/node/egg/egg-example/node_modules/egg-bin/lib/start-cluster ["{\"baseDir\":\"/Users/nickj/Desktop/Project/node/egg/egg-example\",\"workers\":1,\"framework\":\"/Users/nickj/Desktop/Project/node/egg/egg-example/node_modules/egg\"}"] [], "development" +1ms

注意到實際是執行 egg/bin/lib/start-cluster 腳本啓動服務的。

通過 $ pstree -p 82541 查看啓動服務佔用的實際進程:

可以看到 egg-bin 已經順利通過 egg-cluster 啓動了一個 agent 進程和 三個 app_worker 子進程,通過結果我們也藉此機會看看 egg-cluster 內部做了什麼,以及 egg 運行時都做了什麼。

  • egg-bin/lib/cmd/dev.js dev bin 發起點
yield this.helper.forkNode(this.serverBin, devArgs, options);
    -> this.serverBin = path.join(__dirname, '../start-cluster');

  • egg-bin/lib/start-cluster
#!/usr/bin/env node

'use strict';

const debug = require('debug')('egg-bin:start-cluster');
const options = JSON.parse(process.argv[2]);
debug('start cluster options: %j', options);
require(options.framework).startCluster(options);

startCluster 啓動傳入 baseDir 和 framework,Master 進程啓動


這裏我們用跟着代碼執行的順序,一步步來看服務啓動內部的具體執行,已簡化。

egg-cluster/index.js

/**
 * start egg app
 * @method Egg#startCluster
 * @param {Object} options {@link Master}
 * @param {Function} callback start success callback
 */
exports.startCluster = function(options, callback) {
  new Master(options).ready(callback);
};

Master 會先 fork Agent Worker 守護進程

-> Master 得到 Agent Worker 啓動成功的消息(IPC),使用 cluster fork 多個 App Worker 進程

  • App Worker 有多個進程,所以這幾個進程是並行啓動的,但執行邏輯是一致的
  • 單個 App Worker 和 Agent 類似,通過 framework 找到框架目錄,實例化該框架的 Application 類
  • Application 找到 AppWorkerLoader,開始進行加載,順序也是類似的,會異步等待,完成後通知 Master 啓動完成

  • egg-cluster/lib/master.js
// Master 會先 fork Agent Worker 守護進程
detectPort((err, port) => {
 ...
 this.options.clusterPort = port;
 this.forkAgentWorker();
});

-> ./lib/agent_worker.js
agent.ready(err => {
  // don't send started message to master when start error
  if (err) return;

  agent.removeListener('error', startErrorHandler);
  process.send({ action: 'agent-start', to: 'master' });
});

// Master 得到 Agent Worker 啓動成功的消息,使用 cluster fork App Worker 進程
this.once('agent-start', this.forkAppWorkers.bind(this));

-> (forkAppWorkers)
cfork({
     exec: this.getAppWorkerFile(),
     args,
     silent: false,
     count: this.options.workers,
     // don't refork in local env
     refork: this.isProduction,
});

-> (getAppWorkerFile())
getAppWorkerFile() {
    return path.join(__dirname, 'app_worker.js');
}
  • egg-cluster/lib/app_worker.js
app.ready(startServer);

->
function startServer(err) {
  ...

  let server;
  if (options.https) {
    const httpsOptions = Object.assign({}, options.https, {
      key: fs.readFileSync(options.https.key),
      cert: fs.readFileSync(options.https.cert),
    });
    server = require('https').createServer(httpsOptions, app.callback());
  } else {
    server = require('http').createServer(app.callback());
  }
  // emit `server` event in app
  app.emit('server', server);

  // sticky 模式:Master 負責統一監聽對外端口,然後根據用戶 ip 轉發到固定的 Worker 子進程上,每個 Worker 自己啓動了一個新的本地服務
  if (options.sticky) {
    server.listen(0, '127.0.0.1');
    // Listen to messages sent from the master. Ignore everything else.
    process.on('message', (message, connection) => {
      if (message !== 'sticky-session:connection') {
        return;
      }

      // Emulate a connection event on the server by emitting the
      // event with the connection the master sent us.
      server.emit('connection', connection);
      connection.resume();
    });
  } else { // 非 sticky 模式:每個 Worker 都統一啓動服務監聽外部端口
    if (listenConfig.path) {
      server.listen(listenConfig.path);
    } else {
      if (typeof port !== 'number') {
        consoleLogger.error('[app_worker] port should be number, but got %s(%s)', port, typeof port);
        exitProcess();
        return;
      }
      const args = [ port ];
      if (listenConfig.hostname) args.push(listenConfig.hostname);
      debug('listen options %s', args);
      server.listen(...args);
    }
  }
}

其中在每個 worker 中還實例化了 Application, 這裏也算是 egg 服務啓動時的實際入口配置文件了,
在實例化 application(options) 時,agent_worker 和多個 app_worker 進程就會執行 egg 模塊下面的 load 邏輯,依次加載我們應用中 Plugin 插件、 extends 擴展內置對象、app 實例、service 服務層、中間件、controller 控制層、router 路由等,具體加載過程就不深入了。

const Application = require(options.framework).Application;
const app = new Application(options);

啓動相關聯節點

this.on('agent-start', this.onAgentStart.bind(this));
    -> this.logger.info('[master] agent_worker#%s:%s started (%sms)',
      this.agentWorker.id, this.agentWorker.pid, Date.now() - this.agentStartTime);
      
this.ready(() => {
    this.logger.info('[master] %s started on %s (%sms)%s',
        frameworkPkg.name, this[APP_ADDRESS], Date.now() - startTime, stickyMsg);
}

Master 等待多個 App Worker 的成功消息後啓動完成,能對外提供服務。

Debug

DebugCommand繼承於 DevCommand,所以同樣會啓動 egg 服務,並通過實例化 InspectorProxy 進行 debug 操作。

  * run(context) {
    const proxyPort = context.argv.proxy;
    context.argv.proxy = undefined;

    const eggArgs = yield this.formatArgs(context);
    
    ...

    // start egg
    const child = cp.fork(this.serverBin, eggArgs, options);

    // start debug proxy
    const proxy = new InspectorProxy({ port: proxyPort });
    // proxy to new worker
    child.on('message', msg => {
      if (msg && msg.action === 'debug' && msg.from === 'app') {
        const { debugPort, pid } = msg.data;
        debug(`recieve new worker#${pid} debugPort: ${debugPort}`);
        proxy.start({ debugPort }).then(() => {
          console.log(chalk.yellow(`Debug Proxy online, now you could attach to ${proxyPort} without worry about reload.`));
          if (newDebugger) console.log(chalk.yellow(`DevTools → ${proxy.url}`));
        });
      }
    });

    child.on('exit', () => proxy.end());
  }

關於 inspectProxy 主要任務就是會持續的監聽調試進程上返回的 json 文件信息,監聽間隔時間爲 1000 ms。

watchingInspect(delay = 0) {
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      urllib
        .request(`http://127.0.0.1:${this.debugPort}/json`, {
          dataType: 'json',
        })
        .then(({ data }) => {
          this.attach(data && data[0]);
        })
        .catch(e => {
          this.detach(e);
        });
    }, delay);
  }

  attach(data) {
    if (!this.attached) {
      this.log(`${this.debugPort} opened`);
      debug(`attached ${this.debugPort}: %O`, data);
    }

    this.attached = true;
    this.emit('attached', (this.inspectInfo = data));
    this.watchingInspect(1000);
  }

egg-bin 會智能選擇調試協議,在 8.x 之後版本使用 Inspector Protocol 協議,低版本使用 Legacy Protocol.

Test

這個命令會自動執行 test 目錄下的以 .test.js 結尾的文件,通過 mocha 跑編寫的測試用例, egg-bin 會自動將內置的 Mocha、co-mocha、power-assert,nyc 等模塊組合引入到測試腳本中,可以讓我們聚焦精力在編寫測試代碼上,而不是糾結選擇那些測試周邊工具和模塊。

* run(context) {
    const opt = {
      env: Object.assign({
        NODE_ENV: 'test',
      }, context.env),
      execArgv: context.execArgv,
    };
    const mochaFile = require.resolve('mocha/bin/_mocha');
    const testArgs = yield this.formatTestArgs(context);
    if (!testArgs) return;
    yield this.helper.forkNode(mochaFile, testArgs, opt);
  }

其中主要邏輯在 formatTestArgs 其中,會通過指令接收的條件動態將測試需要使用的庫 push 到 requireArr 中:

formatTestArgs({ argv, debug }) {
    //省略

    // collect require
    let requireArr = testArgv.require || testArgv.r || [];
    /* istanbul ignore next */
    if (!Array.isArray(requireArr)) requireArr = [ requireArr ];

    // 清理 mocha 測試堆棧跟蹤,堆棧跟蹤充斥着各種幀, 你不想看到的, 像是從模塊和 mocha 內部代碼
    if (!testArgv.fullTrace) requireArr.unshift(require.resolve('../mocha-clean'));
 
    // 增加 mocha 對於 generator 的支持
    requireArr.push(require.resolve('co-mocha'));

    // 斷言庫
    if (requireArr.includes('intelli-espower-loader')) {
      console.warn('[egg-bin] don\'t need to manually require `intelli-espower-loader` anymore');
    } else {
      requireArr.push(require.resolve('intelli-espower-loader'));
    }

    testArgv.require = requireArr;

    // collect test files
    let files = testArgv._.slice();
    if (!files.length) {
      files = [ process.env.TESTS || 'test/**/*.test.js' ];
    }
    // 加載egg項目中除掉node_modules和fixtures裏面的test文件
    files = globby.sync(files.concat('!test/**/{fixtures, node_modules}/**/*.test.js'));

    // auto add setup file as the first test file 進行測試前的初始化工作
    const setupFile = path.join(process.cwd(), 'test/.setup.js');
    if (fs.existsSync(setupFile)) {
      files.unshift(setupFile);
    }
    testArgv._ = files;

    // remove alias
    testArgv.$0 = undefined;
    testArgv.r = undefined;
    testArgv.t = undefined;
    testArgv.g = undefined;

    return this.helper.unparseArgv(testArgv);
  }

Cov

CovCommand 命令繼承於 TestCommand, 用來測試代碼的測試覆蓋率,內置了 nyc 來支持單元測試自動生成代碼覆蓋率報告。

* run(context) {
    const { cwd, argv, execArgv, env } = context;
    if (argv.prerequire) {
      env.EGG_BIN_PREREQUIRE = 'true';
    }
    delete argv.prerequire;

    // ignore coverage
    if (argv.x) {
      if (Array.isArray(argv.x)) {
        for (const exclude of argv.x) {
          this.addExclude(exclude);
        }
      } else {
        this.addExclude(argv.x);
      }
      argv.x = undefined;
    }
    const excludes = (process.env.COV_EXCLUDES && process.env.COV_EXCLUDES.split(',')) || [];
    for (const exclude of excludes) {
      this.addExclude(exclude);
    }

    const nycCli = require.resolve('nyc/bin/nyc.js');
    const coverageDir = path.join(cwd, 'coverage');
    yield rimraf(coverageDir);
    const outputDir = path.join(cwd, 'node_modules/.nyc_output');
    yield rimraf(outputDir);

    const opt = {
      cwd,
      execArgv,
      env: Object.assign({
        NODE_ENV: 'test',
        EGG_TYPESCRIPT: context.argv.typescript,
      }, env),
    };

    // save coverage-xxxx.json to $PWD/coverage
    const covArgs = yield this.getCovArgs(context);
    if (!covArgs) return;
    debug('covArgs: %j', covArgs);
    yield this.helper.forkNode(nycCli, covArgs, opt);
  }

命令行常用操作

最後再總結一些常見的命令行開發操作,主要爲獲取用戶輸入的參數,文件路徑判斷,以及 fork 子進程執行命令等,比如如果要實現如下的的非常簡單命令行功能。

$ cli <command> <options>           # 結構
$ cli  --name  "CLI"         # 示例
  • 全局化應用指令

在 npm 包中,我們可以通過 -g 指定咋全局安裝一個模塊,以 unix 環境爲例,實際上就是將模塊中指定在 package.json 中的 bin 內的腳本又在 usr/local/bin 創建了一份並與全局中 usr/local/lib/node_modules/<pkgName>/bin/index.js 之間創建了一個連接,這樣我們可以在全局任何位置下調用指定的 npm 包,具體方式只需要在 package.json 中定義將在可執行名稱和目標執行文件 ,比如:

// package.json
"bin": {
  "cli": "index.js"
}

npm 中將 bin 指令與 node_modules 創建連接的相關代碼:

var me = folder || npm.prefix
var target = path.resolve(npm.globalDir, d.name)
symlink(me, target, false, true, function (er) {
 if (er) return cb(er)
 log.verbose('link', 'build target', target)
 // also install missing dependencies.
 npm.commands.install(me, [], function (er) {
   if (er) return cb(er)
   // build the global stuff.  Don't run *any* scripts, because
   // install command already will have done that.
   build([target], true, build._noLC, true, function (er) {
     if (er) return cb(er)
     resultPrinter(path.basename(me), me, target, cb)
   })
 })
})

只需要使用 #!/usr/bin/env node 告訴npm 該 js 文件是一個 node.js 可執行文件,Linux會自動使用node來運行該腳本,在本地下我們可以在根目錄下執行 $ npm link,將模塊安裝在全局並生成連接:

#!/usr/bin/env node

// index.js
var argv = require('yargs')
  .option('name', {
    alias: 'n',
    demand: true,
    default: 'tom',
    describe: 'your name',
    type: 'string'
  })
  .help('h')
  .usage('Usage: hello [options]')
  .example('hello -n tom', 'say hello to Tom')
  .argv;

console.log(`say hello to ${argv.name}`);
$ cli -n Nick  
    -> say hello to Nick

獲取命令行參數

node 中原生支持的 process.argv 表示執行的腳本時同時傳入的參數數組。而如果需要指定參數名或 alias,則需要通過第三方庫實現,我們以 common-bin 封裝的 yargs 進行分析。

通過 argv._ 可以獲取到所有的非 options 的參數。所有 options 參數則掛載在 argv 對象下面。

當然強大還有一些強大第三方處理交互的包可以讓我們處理更多不同的參數處理,提供了諸如選擇器、autoComplate 輸入、表單輸入以及輸入的校驗等等,賦予 cli 工具更多的可能。

比如 enquirer 中的 autoComplete Promot

推薦,node cli 用戶交互庫

子進程

有時候我們需要在程序中調用其他的 shell 命令,可以通過node 原生的 child_process 衍生子進程去執行,比如 common-bin 的應用方式,包括兩種一個是 forkNode, 一個是 spawn ,主要區別就是前者將會衍生子進程執行路徑指定文件,後者則是一個 shell 命令。

const cp = require('child_process');
exports.forkNode = (modulePath, args = [], options = {}) => {
  options.stdio = options.stdio || 'inherit';
  const proc = cp.fork(modulePath, args, options);
  gracefull(proc);
  return new Promise((resolve, reject) => {
    proc.once('exit', code => {
      childs.delete(proc);
      if (code !== 0) {
        const err = new Error(modulePath + ' ' + args + ' exit with code ' + code);
        err.code = code;
        reject(err);
      } else {
        resolve();
      }
    });
  });
};

exports.spawn = (cmd, args = [], options = {}) => {
  options.stdio = options.stdio || 'inherit';
 
  return new Promise((resolve, reject) => {
    const proc = cp.spawn(cmd, args, options);
    gracefull(proc);
    proc.once('error', err => {
      /* istanbul ignore next */
      reject(err);
    });
    proc.once('exit', code => {
      childs.delete(proc);

      if (code !== 0) {
        return reject(new Error(`spawn ${cmd} ${args.join(' ')} fail, exit code: ${code}`));
      }
      resolve();
    });
  });
};

child_process.fork(): 衍生一個新的 Node.js 進程,並通過 IPC 通訊通道來調用指定的模塊,該通道允許父進程與子進程之間相互發送信息。

一些文件操作

當我們使用CLI工具時,我們常常還需要一些對文件進行操作,需要注意的就是對於 cli 內部模塊路徑以及cli 被調用的路徑的區分:

  • 獲得 cli 內部文件所在路徑 __dirname

獲取 cli 內部文件所在路徑,以處理 cli 內部文件操作。

  • 獲得當前 cli 工具的調用路徑 process.cwd()

獲取當前 cli 工具被調用的路徑,已處理一些對外的附加文件操作。

常見的做法是將 cwd 作爲可選參數,默認指定當前位置爲工作目錄,所以我們可以從任何路徑調用我們的 cli 工具,並將其設置爲當前的工作目錄。

const { join, resolve } = require('path')

const cwd = resolve(yargs.argv.cwd || process.cwd())
process.chdir(cwd);

yargs
  .help()
  .options({ cwd: { desc: 'Change the current working directory' } })
  .demand(1)
  .argv

參考

常用第三方包

  • osenv 方便的獲取不同系統的環境和目錄配置
  • figlet 命令行炫酷的Logo生成器
  • meow 命令行幫助命令封裝
  • inquire 強大的用戶交互
  • chalk 讓命令行的output帶有顏色
  • shelljs Node.js執行shell命令
  • clui 進度條
  • ora 加載狀態
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章