從 egg-bin 聊到 command line interface Tool

weixin_33766168發表於2019-02-13
最近正在看一些關於 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 載入狀態

相關文章