4
最近正在看一些关于 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 加载状态

straydog
581 声望19 粉丝

搬砖苦工