前言

本次解读主要以阅读代码的逻辑编写,而非直接归总性的编写。虽然可能会在阅读时产生疑惑,但是胜在推敲理解的过程。
**

初始准备

  1. $ mkdir egg-example && cd egg-example
  2. $ npm init egg --type=simple
  3. $ npm i

目录结构

  1. .
  2. ├── README.md
  3. ├── app
  4. ├── controller
  5. └── home.js
  6. └── router.js
  7. ├── config
  8. ├── config.default.js
  9. └── plugin.js
  10. ├── package.json
  11. └── test
  12. └── app
  13. └── controller
  14. └── home.test.js

版本

  • node version v13.10.1
  • egg version 2.26.0
  • egg-bin version 4.14.1

项目启动

  1. "scripts": {
  2. "dev": "egg-bin dev",
  3. "debug": "egg-bin debug",
  4. }
  1. npm run dev
  2. // 相当于
  3. ./node_modules/egg-bin/bin/egg-bin.js dev

源码分析

node_modules/egg-bin/bin/egg-bin.js文件

  1. #!/usr/bin/env node
  2. 'use strict';
  3. const Command = require('..');
  4. new Command().start();

找到node_modules/egg-bin/index.js进行分析,之后的路径不做说明都在node_modules/egg-bin

  1. 'use strict';
  2. const path = require('path');
  3. const Command = require('./lib/command');
  4. class EggBin extends Command {
  5. constructor(rawArgv) {
  6. super(rawArgv);
  7. this.usage = 'Usage: egg-bin [command] [options]';
  8. // load directory
  9. // 将当前目录下的lib/cmd文件夹路劲传入load,具体做了什么找到load函数后再做分析
  10. this.load(path.join(__dirname, 'lib/cmd'));
  11. }
  12. }
  13. // 导出EggBin对象
  14. module.exports = exports = EggBin;
  15. // 将以下几个类都挂载在EggBin对象下
  16. exports.Command = Command;
  17. exports.CovCommand = require('./lib/cmd/cov');
  18. exports.DevCommand = require('./lib/cmd/dev');
  19. exports.TestCommand = require('./lib/cmd/test');
  20. exports.DebugCommand = require('./lib/cmd/debug');
  21. exports.PkgfilesCommand = require('./lib/cmd/pkgfiles');

EggBin继承于./lib/command的Command类;
找到./lib/command.js

  1. const BaseCommand = require('common-bin');
  2. class Command extends BaseCommand {
  3. constructor(rawArgv) {
  4. super(rawArgv);
  5. this.parserOptions = {
  6. execArgv: true,
  7. removeAlias: true,
  8. };
  9. // common-bin setter, don't care about override at sub class
  10. // https://github.com/node-modules/common-bin/blob/master/lib/command.js#L158
  11. this.options = {
  12. // ...
  13. };
  14. }
  15. /**
  16. * default error handler
  17. * @param {Error} err - err obj
  18. */
  19. errorHandler(err) {
  20. console.error(err);
  21. process.nextTick(() => process.exit(1));
  22. }
  23. get context() {
  24. // ...
  25. }
  26. }

从以上代码可以看出Command又继承于common-bin 模块
./node_modules/common-bin/lib 下找到command.js

  1. class CommonBin {
  2. constructor(rawArgv) {
  3. /**
  4. * original argument
  5. * @type {Array}
  6. * 获取到启动后的命令参数,例如dev、port
  7. */
  8. this.rawArgv = rawArgv || process.argv.slice(2);
  9. debug('[%s] origin argument `%s`', this.constructor.name, this.rawArgv.join(' '));
  10. /**
  11. * yargs
  12. * @type {Object}
  13. * 使用yargs解析命令行参数
  14. */
  15. this.yargs = yargs(this.rawArgv);
  16. /**
  17. * helper function
  18. * @type {Object}
  19. */
  20. this.helper = helper;
  21. /**
  22. * parserOptions
  23. * @type {Object}
  24. * @property {Boolean} execArgv - whether extract `execArgv` to `context.execArgv`
  25. * @property {Boolean} removeAlias - whether remove alias key from `argv`
  26. * @property {Boolean} removeCamelCase - whether remove camel case key from `argv`
  27. */
  28. this.parserOptions = {
  29. execArgv: false,
  30. removeAlias: false,
  31. removeCamelCase: false,
  32. };
  33. // <commandName, Command>
  34. this[COMMANDS] = new Map();
  35. }
  36. // ...
  37. }

yargs 模块能够解决如何处理命令行参数,具体教程参考Node.js 命令行程序开发教程
构造函数处理了内容为:

  1. 获取启动命令行参数
  2. 使用yargs模块解析命令行参数
  3. 将helper挂载在this.helper
  4. 使用Symbol('Command#commands')建立了一个变量为空Map

回到之前看到的this.load(path.join(__dirname, 'lib/cmd'));
load函数继承于当前CommonBin类,具体如下,需要明白load具体干了什么。

  1. load(fullPath) {
  2. // fullPath:/个人/路径/就不显示了/egg-example/node_modules/_egg-bin@4.14.1@egg-bin/lib/cmd
  3. // 判断文件夹是否存在并且是不是一个系统目录
  4. assert(fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(),
  5. `${fullPath} should exist and be a directory`);
  6. // load entire directory
  7. // 这里的
  8. const files = fs.readdirSync(fullPath);
  9. const names = [];
  10. for (const file of files) {
  11. if (path.extname(file) === '.js') {
  12. /**
  13. * 读取到的文件如下
  14. * autod.js
  15. * cov.js
  16. * debug.js
  17. * dev.js
  18. * pkgfiles.js
  19. * test.js
  20. */
  21. const name = path.basename(file).replace(/\.js$/, '');
  22. names.push(name);
  23. // 拿到文件名后文件路径之后,执行了add方法
  24. this.add(name, path.join(fullPath, file));
  25. }
  26. }
  27. debug('[%s] loaded command `%s` from directory `%s`',
  28. this.constructor.name, names, fullPath);
  29. }

以上代码体会:
1.用assert断言来判断,在写egg插件的时候也用到过,但是确实有编程习惯遗留,之前基本上都用的是if判断。
2.for循环读文件算是很熟悉了,无论是orm读model挂载还是koa2里的route准备都差不多。
体会(废话)太多了,代码读到所有文件和文件名之后执行了add方法,继续看add方法;

  1. add(name, target) {
  2. assert(name, `${name} is required`);
  3. if (!(target.prototype instanceof CommonBin)) {
  4. assert(fs.existsSync(target) && fs.statSync(target).isFile(), `${target} is not a file.`);
  5. debug('[%s] add command `%s` from `%s`', this.constructor.name, name, target);
  6. target = require(target);
  7. // try to require es module
  8. if (target && target.__esModule && target.default) {
  9. target = target.default;
  10. }
  11. assert(target.prototype instanceof CommonBin,
  12. 'command class should be sub class of common-bin');
  13. }
  14. this[COMMANDS].set(name, target);
  15. }

此处的最终操作是将传过来的文件放入构造函数中建立的空Map中,不过多了一层判断,如果target直接是一个继承于CommonBin的模块传进去的话可以直接赋值处理,如果是路径的话先require作为模块之后赋值处理。处理完后,this[COMMANDS]的值如下

  1. this[COMMANDS]的值Map(6) {
  2. 'autod' => [Function: AutodCommand],
  3. 'cov' => [Function: CovCommand],
  4. 'debug' => [Function: DebugCommand],
  5. 'dev' => [Function: DevCommand],
  6. 'pkgfiles' => [Function: PkgfilesCommand],
  7. 'test' => [Function: TestCommand]
  8. }

此时将lib/cmd 下的所有类通过键值对的形式都挂载了this[COMMANDS]下。

到这里load方法初始化已经结束,当我看到这里的时候也是很疑惑的,就像我文章开头写的一样,看源码的过程有时候会带着疑惑,这种疑惑让人焦虑,不过不着急,还有一个start方法还没看,在项目路径node_modules/egg-bin/bin/egg-bin.js下有一行new Command().start(); ,接下来再看start方法,不过在start方法之前,先做之前的内容做一个汇总。

类的继承关系汇总

截屏2020-05-04 14.24.00.png
Command —- 继承自 common-bin的基础命令对象
DevCommand —- 本地开发命令对象(egg-bin dev)
DebugCommand —- 调试命令对象(egg-bin debug)
CovCommand —- 代码覆盖率命令对象(egg-bin cov)
TestCommand —- 测试命令对象(egg-bin test)
PkgfilesCommand —- 包文件对象(egg-bin pkgfiles)
AutodCommand —- 项目依赖和版本对象(egg-bin autod)

start
new Command().start();

  1. start() {
  2. co(function* () {
  3. // replace `--get-yargs-completions` to our KEY, so yargs will not block our DISPATCH
  4. const index = this.rawArgv.indexOf('--get-yargs-completions');
  5. if (index !== -1) {
  6. // bash will request as `--get-yargs-completions my-git remote add`, so need to remove 2
  7. this.rawArgv.splice(index, 2, `--AUTO_COMPLETIONS=${this.rawArgv.join(',')}`);
  8. }
  9. yield this[DISPATCH]();
  10. }.bind(this)).catch(this.errorHandler.bind(this));
  11. }

start函数使用了co模块自动执行generator函数,重点在于yield this[DISPATCH]();再来看* [DISPATCH]函数。
补充co模块原理:co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。

  1. * [DISPATCH]() {
  2. // define --help and --version by default
  3. this.yargs
  4. // .reset()
  5. .completion()
  6. .help()
  7. .version()
  8. .wrap(120)
  9. .alias('h', 'help')
  10. .alias('v', 'version')
  11. .group([ 'help', 'version' ], 'Global Options:');
  12. // get parsed argument without handling helper and version
  13. const parsed = yield this[PARSE](this.rawArgv);
  14. const commandName = parsed._[0];
  15. if (parsed.version && this.version) {
  16. console.log(this.version);
  17. return;
  18. }
  19. // if sub command exist
  20. if (this[COMMANDS].has(commandName)) {
  21. const Command = this[COMMANDS].get(commandName);
  22. const rawArgv = this.rawArgv.slice();
  23. rawArgv.splice(rawArgv.indexOf(commandName), 1);
  24. debug('[%s] dispatch to subcommand `%s` -> `%s` with %j', this.constructor.name, commandName, Command.name, rawArgv);
  25. const command = this.getSubCommandInstance(Command, rawArgv);
  26. yield command[DISPATCH]();
  27. return;
  28. }
  29. // register command for printing
  30. for (const [ name, Command ] of this[COMMANDS].entries()) {
  31. this.yargs.command(name, Command.prototype.description || '');
  32. }
  33. debug('[%s] exec run command', this.constructor.name);
  34. const context = this.context;
  35. // print completion for bash
  36. if (context.argv.AUTO_COMPLETIONS) {
  37. // slice to remove `--AUTO_COMPLETIONS=` which we append
  38. this.yargs.getCompletion(this.rawArgv.slice(1), completions => {
  39. // console.log('%s', completions)
  40. completions.forEach(x => console.log(x));
  41. });
  42. } else {
  43. // handle by self
  44. yield this.helper.callFn(this.run, [ context ], this);
  45. }
  46. }

首先先执行yargs的一些自带的方法,然后取到运行参数,例如:npm run dev的时候或者egg-bin dev的时候,会取到parsed._=['dev'],具体的用法参考上面给出的yargs教程链接。所以commandName = dev
取到commandName之后,用Map的has方法判断是否在Map对象中有该key,如果有的话,获取Map存的类对象,然后把commandName这个对实例化没用的参数删除,再通过getSubCommandInstance方法实例化该类。
实例化的方法再进行递归执行[DISPATCH]函数,才会去使用helper(common-bin中支持异步的关键所在)类继续执行每个command文件中的* run()函数。

还是以npm run dev为例,通过helpercallFn执行了DevCommand* run
文件路径:./lib/cmd/dev.js

  1. * run(context) {
  2. const devArgs = yield this.formatArgs(context);
  3. const env = {
  4. NODE_ENV: 'development',
  5. EGG_MASTER_CLOSE_TIMEOUT: 1000,
  6. };
  7. const options = {
  8. execArgv: context.execArgv,
  9. env: Object.assign(env, context.env),
  10. };
  11. debug('%s %j %j, %j', this.serverBin, devArgs, options.execArgv, options.env.NODE_ENV);
  12. yield this.helper.forkNode(this.serverBin, devArgs, options);
  13. }

formatArgs主要修改一些执行的参数,例如端口号未传入的时候使用7001之类的。最终执行this.helper.forkNode
传入的参数:

  1. this.serverBin=path.join(__dirname, '../start-cluster'); start-cluster为一个可执行脚本
  2. devArgsformatArgs方法格式化过的内容
  3. options经过组合的一些列参数,太多了就不一一列出

接下来再看this.helper.forkNode

  1. exports.forkNode = (modulePath, args = [], options = {}) => {
  2. options.stdio = options.stdio || 'inherit';
  3. debug('Run fork `%s %s %s`', process.execPath, modulePath, args.join(' '));
  4. const proc = cp.fork(modulePath, args, options);
  5. gracefull(proc);
  6. return new Promise((resolve, reject) => {
  7. proc.once('exit', code => {
  8. childs.delete(proc);
  9. if (code !== 0) {
  10. const err = new Error(modulePath + ' ' + args + ' exit with code ' + code);
  11. err.code = code;
  12. reject(err);
  13. } else {
  14. resolve();
  15. }
  16. });
  17. });
  18. };

forkNode做的就是通过子进程启动模块,具体用法参考child_process - 子进程
可以通过ps aux | grep start-cluster找到该进程
文章的结尾看看start-cluster脚本。

  1. #!/usr/bin/env node
  2. 'use strict';
  3. const debug = require('debug')('egg-bin:start-cluster');
  4. const options = JSON.parse(process.argv[2]);
  5. debug('start cluster options: %j', options);
  6. // options.framework = /个人/路径/就不显示了/egg-example/node_modules/egg
  7. require(options.framework).startCluster(options);

后续启动的就是egg里的startCluster方法了,此后面的内容,下一章再做解读。
**

整体流程图

截屏2020-05-04 16.44.45.png

总结

EggBin中的构造函数loader各个command类对象,挂载到this[COMMANDS] 下,start方法后面的相应对象实例化,到最后的启动startCluster,实现了调试,单元测试和代码覆盖率等功能。一句话来概况的话,egg-bin就是一个非常强的本地开发工具。