每天一个npm包:egg-bin

671 阅读4分钟

前面,我们学习了common-bin这个包。接下来呢,我们开始了解egg-bin

那从哪里开始呢?我们使用egg初始化项目,在scripts中,我们发现其启动命令是这样的。

 "dev": "EGG_SERVER_ENV=local egg-bin dev",

所有我们从这个脚本开始,看egg-bin dev做了什么。

egg-bin

egg-binegg框架的配套开发工具一环,继承于command-bin(更底层、通用的抽象)。

源码解析

package.json

首先我们看下package.json中包的描述信息。

  "main": "index.js",
  "bin": {
    "egg-bin": "bin/egg-bin.js",
    "mocha": "bin/mocha.js",
    "ets": "bin/ets.js",
    "c8": "bin/c8.js"
  },

bin/egg-bin.js

这里我们暂时只关注egg-bin,顺藤摸瓜,找到bin/egg-bin.js

#!/usr/bin/env node

'use strict';

const Command = require('..');

// 先实例化、后start
new Command().start();

index.js

Command来自入口文件index.js,代码如下:

'use strict';

const path = require('path');
const Command = require('./lib/command');

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

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

module.exports = exports = EggBin;
exports.Command = Command;
exports.CovCommand = require('./lib/cmd/cov');
exports.DevCommand = require('./lib/cmd/dev');
exports.TestCommand = require('./lib/cmd/test');
exports.DebugCommand = require('./lib/cmd/debug');
exports.PkgfilesCommand = require('./lib/cmd/pkgfiles');

逻辑:

  1. EggBin作为对外暴露的上层类会被初始化。
    • 初始化usage说明。
    • 加载lib/cmd下文件,并且以文件名注册子命令。
  2. 初始化Command,来自/lib/command

我们其实可以看下注册子命令后其实就长这个样子。

egg-bin

你可以看到Usage: egg-bin [command] [options]都是可以对得上。

lib/command.js

'use strict';

const path = require('path');
const fs = require('fs');
const BaseCommand = require('common-bin');

class Command extends BaseCommand {
  constructor(rawArgv) {
    super(rawArgv);
    this.parserOptions = {
      execArgv: true,
      removeAlias: true,
    };

    // common-bin setter, don't care about override at sub class
    // https://github.com/node-modules/common-bin/blob/master/lib/command.js#L158
    this.options = {
      typescript: {
        description: 'whether enable typescript support, will load tscompiler on startup',
        type: 'boolean',
        alias: 'ts',
        default: undefined,
      },

      declarations: {
        description: 'whether create dts, will load `egg-ts-helper/register`',
        type: 'boolean',
        alias: 'dts',
        default: undefined,
      },

      tscompiler: {
        description: 'ts compiler, like ts-node、ts-eager、esbuild-register etc.',
        type: 'string',
        alias: 'tsc',
        default: undefined,
      },
    };
  }

  /**
   * default error handler
   * @param {Error} err - err obj
   */
  errorHandler(err) {
    console.error(err);
    process.nextTick(() => process.exit(1));
  }

  get context() {
    const context = super.context;
    const { argv, debugPort, execArgvObj, cwd, env } = context;

    // compatible
    if (debugPort) context.debug = debugPort;

    // remove unuse args
    argv.$0 = undefined;

    // read package.json
    // 挂载应用程序的根路径,默认为 process.cwd()。
    let baseDir = argv.baseDir || cwd;
    
    if (!path.isAbsolute(baseDir)) baseDir = path.join(cwd, baseDir);
    const pkgFile = path.join(baseDir, 'package.json');
    const pkgInfo = fs.existsSync(pkgFile) ? require(pkgFile) : null;
    const eggInfo = (pkgInfo && pkgInfo.egg) || {};
    // 默认空数组,从来存储从应用requre的模块
    execArgvObj.require = execArgvObj.require || [];

    // read `egg.typescript` from package.json if not pass argv
    if (argv.typescript === undefined && typeof eggInfo.typescript === 'boolean') {
      argv.typescript = eggInfo.typescript;
    }

    // 如果命令行没有传递,就从package.json 读取 `egg.declarations`
    if (argv.declarations === undefined && typeof eggInfo.declarations === 'boolean') {
      argv.declarations = eggInfo.declarations;
    }

    // 如果命令行没有传递,就从package.json 读取 `egg.tscompiler`
    // 比如 tscompiler: '/Users/xxx/Documents/github-source/egg-bin/node_modules/ts-node/register/index.js',
    // try to load from `cwd` while tscompipler has value or app has ts-node deps
    if (argv.tscompiler === undefined && !eggInfo.tscompiler) {
      const useAppTsNode = pkgInfo && (
        (pkgInfo.dependencies && pkgInfo.dependencies['ts-node']) ||
        (pkgInfo.devDependencies && pkgInfo.devDependencies['ts-node'])
      );
      // 优先从你的应用中读取ts-node 
      argv.tscompiler = require.resolve('ts-node/register', useAppTsNode ? { paths: [ cwd ] } : undefined);
    } else {
      argv.tscompiler = argv.tscompiler || eggInfo.tscompiler;
      argv.tscompiler = require.resolve(argv.tscompiler, { paths: [ cwd ] });
    }

    // read `egg.require` from package.json
    if (eggInfo.require && Array.isArray(eggInfo.require)) {
      execArgvObj.require = execArgvObj.require.concat(eggInfo.require);
    }

    // load ts-node
    if (argv.typescript) {
      execArgvObj.require.push(argv.tscompiler);

      // tell egg loader to load ts file
      env.EGG_TYPESCRIPT = 'true';

      // load files from tsconfig on startup
      env.TS_NODE_FILES = process.env.TS_NODE_FILES || 'true';
    }

    // load egg-ts-helper
    if (argv.declarations) {
      execArgvObj.require.push(require.resolve('egg-ts-helper/register'));
    }

    return context;
  }
}

module.exports = Command;

这里实现逻辑如下:

  1. constructor内按需自定义this.parserOptions,覆盖基类,定义好yargs options。也就是
Options:
  --typescript, --ts     whether enable typescript support, will load tscompiler on startup                    [boolean]
  --declarations, --dts  whether create dts, will load `egg-ts-helper/register`                                [boolean]
  --tscompiler, --tsc    ts compiler, like ts-node、ts-eager、esbuild-register etc.  
  1. 实现自己的错误处理errorHandler
// 允许所有同步方法执行完之后,下一个事件循环开始之前执行
process.nextTick(() => process.exit(1));
  1. 实现自己的context
  • 从基类获取context
const context = super.context;
const { argv, debugPort, execArgvObj, cwd, env } = context;

// 实际如下:
{
argv: {
  _: [],
  typescript: undefined,
  ts: undefined,
  declarations: undefined,
  dts: undefined,
  tscompiler: undefined,
  tsc: undefined,
  '$0': 'egg-bin',
  help: undefined,
  h: undefined,
  version: undefined,
  v: undefined
},
cwd: '/Users/aedanxu/Documents/github-source/egg-bin',
env: {},
rawArgv: [],
execArgvObj: {}
  • 根据baseDir读取你项目的package.jsonegg的配置,处理一些逻辑,具体见上述代码中注释。因此你可以在你的项目packgae.json进行配置,开启-- option
"egg": {
  "declarations": true
},
  • 存储从package.json中读取的egg.require,比如tscompiler(来自你应用的ts-node/require).

lib/cmd/dev.js

现在我们开始看这个egg-bin dev子命名的主要逻辑了。

  constructor(rawArgv) {
   super(rawArgv);
   this.usage = 'Usage: egg-bin dev [dir] [options]';

   this.defaultPort = 7001;
   this.serverBin = path.join(__dirname, '../start-cluster');

   this.options = {
     baseDir: {
       description: 'directory of application, default to `process.cwd()`',
       type: 'string',
     },
     workers: {
       description: 'numbers of app workers, default to 1 at local mode',
       type: 'number',
       alias: [ 'c', 'cluster' ],
       default: 1,
     },
     port: {
       description: 'listening port, default to 7001',
       type: 'number',
       alias: 'p',
     },
     framework: {
       description: 'specify framework that can be absolute path or npm package',
       type: 'string',
     },
     require: {
       description: 'will add to execArgv --require',
       type: 'array',
       alias: 'r',
     },
   };
 }

 get description() {
   return 'Start server at local dev mode';
 }

构造器的逻辑,我们通过执行egg-bin dev -help就大致看的出来了。

egg-bin dev -help

唯一我们需要关注的有

// 默认启动的端口
this.defaultPort = 7001;
// 存储serverBin脚本的执行路径
this.serverBin = path.join(__dirname, '../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);

start()

各个类初始化流程都走完之后,回到我们的start()

// bin/egg-bin.js

new Command().start();

start()其实来自基类command-bin的实现,里面大致就是完成yargs初始化、注册子命令等。详细可以看之前文章,最重要的是会重新执行子命令的run方法。

run()

  * run(context) {
    const devArgs = yield this.formatArgs(context);
    // egg相关环境变量
    const env = {
      NODE_ENV: 'development',
      EGG_MASTER_CLOSE_TIMEOUT: 1000,
    };
    const options = {
      // 命令行option相关参数
      execArgv: context.execArgv,
      env: Object.assign(env, context.env),
    };
    debug('%s %j %j, %j', this.serverBin, devArgs, options.execArgv, options.env.NODE_ENV);
    const task = this.helper.forkNode(this.serverBin, devArgs, options);
    this.proc = task.proc;
    yield task;
  }
  
    * formatArgs(context) {
    const { cwd, argv } = context;
    /* istanbul ignore next */
    argv.baseDir = argv.baseDir || cwd;
    /* istanbul ignore next */
    if (!path.isAbsolute(argv.baseDir)) argv.baseDir = path.join(cwd, argv.baseDir);

    argv.port = argv.port || argv.p;
    argv.framework = utils.getFrameworkPath({
      framework: argv.framework,
      baseDir: argv.baseDir,
    });

    // remove unused properties
    argv.cluster = undefined;
    argv.c = undefined;
    argv.p = undefined;
    argv._ = undefined;
    argv.$0 = undefined;

    // auto detect available port
    if (!argv.port) {
      debug('detect available port');
      const port = yield detect(this.defaultPort);
      if (port !== this.defaultPort) {
        argv.port = port;
        console.warn(`[egg-bin] server port ${this.defaultPort} is in use, now using port ${port}\n`);
      }
      debug(`use available port ${port}`);
    }
    return [ JSON.stringify(argv) ];
  }

这里的实现逻辑:

  1. formatArgs():格式化、组装egg startCluster需要的参数args, 并最后其更改为json字符串。

比如

{
  _: undefined,
  typescript: undefined,
  ts: undefined,
  declarations: true,
  dts: undefined,
  tscompiler: '/Users/xx/Documents/github-source/egg-bin/node_modules/ts-node/register/index.js',
  tsc: undefined,
  workers: 1,
  c: undefined,
  cluster: undefined,
  '$0': undefined,
  help: undefined,
  h: undefined,
  version: undefined,
  v: undefined,
  p: undefined,
  r: undefined,
  // 你的egg应用根目录
  baseDir: '/Users/xxx/Documents/workplace/xxx-datatalk',
  // 应用端口
  port: undefined,
  // 你的应用egg module 
  framework: '/Users/xxx/Documents/workplace/xxx-datatalk/node_modules/egg'
}
  1. run():最重要逻辑,拿到组装好的参数,执行child_process.fork()(基于promise封装了)。

  2. forkNode()


const childs = new Set();
let hadHook = false;
function gracefull(proc) {
  // save child ref
  childs.add(proc);

  // only hook once
  /* istanbul ignore else */
  if (!hadHook) {
    hadHook = true;
    let signal;
    // 监听一些事件信号, 可参考https://man7.org/linux/man-pages/man7/signal.7.html
    [ 'SIGINT', 'SIGQUIT', 'SIGTERM' ].forEach(event => {
      process.once(event, () => {
        signal = event;
        process.exit(0);
      });
    });

    process.once('exit', () => {
      // had test at my-helper.test.js, but coffee can't collect coverage info.
      for (const child of childs) {
        debug('kill child %s with %s', child.pid, signal);
        child.kill(signal);
      }
    });
  }
}

exports.forkNode = (modulePath, args = [], options = {}) => {
  options.stdio = options.stdio || 'inherit';
  debug('Run fork `%s %s %s`', process.execPath, modulePath, args.join(' '));
  const proc = cp.fork(modulePath, args, options);
  // 对fork出来的进程绑定一些一次性的事件监听
  gracefull(proc);

  // 基于promise的封装。
  const promise = 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();
      }
    });
  });
  // 挂载到promise.proc
  promise.proc = proc;
  return promise;
};

该方法其实指的就是child_process.fork

所以前面的逻辑你发现大多数都是为了这个api组装所需要的参数。比如modulePath就是前文提到的/Users/xxx/Documents/github-source/egg-bin/lib/start-cluster文件。

所以最终引出了下一章的主题:

require(options.framework).startCluster(options);

egg框架的startCluster()里面做了什么呢?

最后推广下我的公众号: