当前位置: 首页 > 工具软件 > Midway > 使用案例 >

Midway 外部版启动过程分析

庞元青
2023-12-01

Midway 是一个 Egg.js 的拓展框架,他提供了更多 ts 以及依赖注入方面的支持。今天我们来看一下 Midway 的启动过程。

Index

  • before start
  • midway-bin: CLI startup
  • midway: cluster startup
  • midway-web: Application/Agent startup
  • example for the flow
  • conclusion

Before Start

midway 的代码所在地是 https://github.com/midwayjs/midway 下。是一个汇总的 mono 仓库。你可以方便的在这一个 git 仓库里找到 midway 的全部拓展代码。不过一些原本 egg 的代码依旧是需要去到 https://github.com/eggjs/ 下阅读。

为了帮助我们l了解整个 midway 的启动,你可以使用 midway-init 这个手脚架工具来初始化一个空的项目,具体步骤是:

# 安装手脚架工具
npm install -g midway-init

# 初始化项目
mkdir midway-test
cd midway-test
midway-init .

或者可以直接下载使用 midway-example 中的空项目, link, 随后执行:

# 安装依赖
npm i

## 启动测试项目
npm run dev

当你看到 midway started on http://127.0.0.1:7001 的字样时,就意味着空项目已经启动好。

有了 example 之后,我们就通过这个项目以及 node_modules 中完整的 midway 和 egg 依赖来研究整个启动的过程。

midway-bin: CLI startup

midway-bin 主要是面向 CLI 命令行的启动处理。当你开始通过 npm run dev 来启动 midway 的时候,就已经是通过 NODE_ENV=local midway-bin dev --ts 的方式调用了 midway-bin 来启动 midway 应用。

当你在执行 npm scripts 时,npm 会帮你在 node_modules 中查找通过 package.json 中的 bin 属性配置好的可执行命令。而 midway-bin 这个命令是在 node_modules/midway-bin/package.json 中的 bin 字段定义的:

{
  "name": "midway-bin",
  // ...
  "bin": {
    "midway-bin": "bin/midway-bin.js",
    "mocha": "bin/mocha.js"
  },
}

也就是说 midway-bin 这个命令其实调用的是 node_modules/midway-bin/bin/midway-bin.js 这个脚本来执行的。这里就是启动命令的一个入口。打开这个文件会发现如下代码:

#!/usr/bin/env node

'use strict';

const Command = require('../').MidwayBin;
new Command().start();

根据这个代码的语音,我们可以知道,这里是有一个 Command 类会自动解析命令传入的参数和环境变量(如 dev 和 --ts 这样的命令和 flag),继续去看 '../' 的内容:

// node_modules/midway-bin/index.js

'use strict';

// 继承 egg-bin 的 Command 类
const Command = require('egg-bin/lib/command');

class MidwayBin extends Command {
  constructor(rawArgv) {
    // 调用父类的初始化
    super(rawArgv);

    // 设置单纯执行 midway-bin 时返回的命令提示
    this.usage = 'Usage: egg-bin [command] [options]';

    // 加载用户在 midway-bin/lib/cmd 下定义的命令
    this.load(path.join(__dirname, 'lib/cmd'));
  }
}

exports.MidwayBin = MidwayBin;
// ...

// dev 命令的逻辑
exports.DevCommand = require('./lib/cmd/dev');

// ...

发现这里导出了刚刚 new Command().start(); 的 Command 类(MidwayBin),并且在这个类的 constructor 中加载用户在 midway-bin 下定义的命令。按照面向对象的逻辑,我们只需要关心 midway-bin 下的 dev 命令实现(当然如果你感兴趣也可以顺着继承链去看 egg-bin -> common-bin 的构造函数内的初始化过程)。

我们来到 midway-bin/lib/cmd/dev.js

'use strict';

class DevCommand extends require('egg-bin/lib/cmd/dev') {
  constructor(rawArgv) {
    // 调用父类的初始化
    super(rawArgv);

    // 设置执行 midway-bin dev 时返回的命令提示
    this.usage = 'Usage: midway-bin dev [dir] [options]';

    // 设置默认参数 (端口) 为 7001
    this.defaultPort = process.env.PORT || 7001;
  }

  * run(context) {
    // 设置默认的 midway 进程启动参数 (Arguments Vector)
    context.argv.framework = 'midway';
    
    // 运行父类 egg-bin 的 dev 启动
    yield super.run(context);
  }
}

module.exports = DevCommand;

通过代码注释,我们可以知道通过 midway-bin dev 启动时,与原本的 egg-bin 启动一个项目唯一的区别就是 midway-bin 设置了一下默认端口,以及启动的框架参数为 'midway'。最后还是调用的 egg-bin 内的 dev 命令的 run 方法来走的。

其中 egg-bin 的启动逻辑,简单来说就两步:

  • ① 整理启动参数

    • 解析 CLI flag:如 --port=2333 --cluster=8 等 flag 解析
    • 解析环境变量:如 NODE_ENV=local
    • 获取当前目录(process.cwd())用作默认的项目 baseDir
    • 通过当前目录读取 package.json 信息(如 egg 字段)
    • 判断 typescript 环境等设置需要 require 的模块参数
  • ② 根据启动参数创建 egg(这里是midway) 进程

    • 此处直接调用 common-bin/lib/helper 下的 #forkNode 方法。这个方法是一个通用的传递参数启动子进程的方法。通过 egg-bin 下 dev 的构造函数中拼装的 start-cluster 脚本来启动子进程。

综上,具体情况是:

  1. npm run dev
  2. NODE_ENV=local midway-bin dev --ts
  3. midway-bin/bin/midway-bin.js
  4. midway-bin/index.js
  5. 父类初始化 egg-bin -> common-bin
  6. 调用 midway-bin 重写的 dev 命令
  7. midway-bin/lib/cmd/dev.js 设置 port 和 framework 参数
  8. egg-bin/lib/cmd/dev.js 整理启动参数
  9. egg-bin -> common-bin #forkNode
  10. egg-bin/lib/start-cluster.js (在子进程中 require application 并 start)

到这里完成 midway-bin 的全部工作。

midway: cluster startup

midway 这个在启动流程和所做的事情等同于 egg-cluster 这个包。主要是区别处理 Application 和 Agent 启动之前的逻辑,然后分别启动这两个部分。

在进入 midway 模块前,我们需要接着上方 midway-bin 的最后一步,来看一下 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);

其中的 options.framework 就是前文提到过的在 midway-bin/lib/cmd/dev.js 中设置写死的参数,也就是 'midway',所以这里实际上调用的就是 node_modules/midway/dist/index.js 中的 startCluster 方法,注意在 midway 的 package.json 中配置了 main: 'dist/index', 所以 require ('midway') 拿到的是 midway/dist/index。不过 midway 这个库是用 ts 写的,所以我们直接来看 ts 代码:

// ...
// export * 导出各项定义:'injection', 'midway-core', 'midway-web', 'egg'

const Master = require('../cluster/master');

// ...

/**
 * 应用启动的方法
 */
export function startCluster(options, callback) {
  // options 就是 midway-bin 过程中整理的启动一个 midway 所需的所有参数
  new Master(options).ready(callback);
}

接下来我们来看这个 new Master 的逻辑:

const EggMaster = require('egg-cluster/lib/master');
const path = require('path');
const formatOptions = require('./utils').formatOptions;

class Master extends EggMaster {

  constructor(options) {
    // TypeScript 默认支持的参数判断
    options = formatOptions(options);
    super(options);

    // 输出 egg 格式的版本日志
    this.log('[master] egg version %s, egg-core version %s',
      require('egg/package').version,
      require('egg-core/package').version);
  }

  // 设置 Agent 的 fork 入口
  getAgentWorkerFile() {
    return path.join(__dirname, 'agent_worker.js');
  }

  // 设置 Application 的 fork 入口
  getAppWorkerFile() {
    return path.join(__dirname, 'app_worker.js');
  }
}

module.exports = Master;

此处继承了 egg-cluster 的 master 类(管理 app 和 agent 启动),在构造函数的过程中加上了 TypeScript 的支持参数,然后重写了 getAgentWorkerFilegetAppWorkerFile,让 egg-cluster 在通过子进程 fork 启动 app 和 agent 的时候分别通过 midway/cluster/agent_work.jsmidway/cluster/app_worker.js 这两个本地的脚本入口启动。

// midway/cluster/app_worker.js

'use strict';

const utils = require('./utils');
const options = JSON.parse(process.argv[2]);
utils.registerTypescriptEnvironment(options);
require('egg-cluster/lib/app_worker');

app_worker 为例,实际上这两个 midway 下的入口只做了一件事,就是根据 TypeScript 支持检查的参数来决定是否默认帮用户注册 TypeScript 的运行环境。之后就继续走 egg-cluster 下原本的 app_worker 的入口逻辑。

而打开 egg-clsuter/lib/app_worker.js,这个启动脚本:

'use strict';

// 检查 options 中是否有 require 字段
// 有的话 for 循环挨个 require
// ...

// ...
// 初始化 logger

// 此处 options.framework 就是 'midway'
const Application = require(options.framework).Application;

// 启动 Application
const app = new Application(options);

// ...
// 初始化好之后 callback
app.ready(startServer);

// 超时检查处理
// ...

// Application 初始化好之后做一些检查或者监听
function startServer(err) {
  // 看启动是否
    // 报错
    // 超时
    // 监听 on('error') 处理
  // ...
}

// 如果出现异常,则优化 exit 进程
gracefulExit({
  logger: consoleLogger,
  label: 'app_worker',
  beforeExit: () => app.close(),
});

看起来内容很多,但实际上我们需要关系的只有 2 句,一个是 require 获取 Application,另外一个是 new Application

其中 require 获取 Application,这里面的 options.framework 就是 'midway',所以约等于 require('midway').Application,我们可以找到 node_modules/midway/src/index.ts 看到开头有这么一段:

export * from 'injection';
export * from 'midway-core';
export * from 'midway-web';
export {
  Context,
  IContextLocals,
  EggEnvType,
  IEggPluginItem,
  EggPlugin,
  PowerPartial,
  EggAppConfig,
  FileStream,
  IApplicationLocals,
  EggApplication,
  EggAppInfo,
  EggHttpClient,
  EggContextHttpClient,
  Request,
  Response,
  ContextView,
  LoggerLevel,
  Router,
} from 'egg';

// ...

也就是说 require('midway').Application 其实拿到的是从 'midway-web' 中 export 出来的 Application。也就说从这里就进入了 midway-web 的逻辑。

midway-web: Application/Agent startup

midway-web/src/index.ts 里面 export 了很多内容,其实 Application 和 Agent 也在这里 export 出来。

export {AgentWorkerLoader, AppWorkerLoader} from './loader/loader';
export {Application, Agent} from './midway';
export {BaseController} from './baseController';
export * from './decorators';
export {MidwayWebLoader} from './loader/webLoader';
export * from './constants';
export {loading} from './loading';
接着我们就可以到 midwa-web/src/midway.ts 中来看 Application 的代码(agent 类似所以省略),简单看一下代码,随后会有专门的讲解:

import { Agent, Application } from 'egg';
import { AgentWorkerLoader, AppWorkerLoader } from './loader/loader';
// ...

class MidwayApplication extends (Application as {
  new(...x)
}) {

  // 使用 midway-web 下的 loader 来加载各项资源
  get [Symbol.for('egg#loader')]() {
    return AppWorkerLoader;
  }

  // ...

  /*
   * 通过 midway-web 自定义的 loader 来获取当前的目录
   * 这个可以解决代码编写在 src/ 目录下的执行问题
   */
  get baseDir(): string {
    return this.loader.baseDir;
  }
  get appDir(): string {
    return this.loader.appDir;
  }

  /*
   * 通过 midway-web 自定义的 loader 加载出
   * midway 自定义的 plugin 上下文, application 上下文
   */
  getPluginContext() {
    return (this.loader as AppWorkerLoader).pluginContext;
  }
  getApplicationContext() {
    return (this.loader as AppWorkerLoader).applicationContext;
  }
  generateController(controllerMapping: string) {
    return (this.loader as AppWorkerLoader).generateController(controllerMapping);
  }
  get applicationContext() {
    return this.loader.applicationContext;
  }
  get pluginContext() {
    return this.loader.pluginContext;
  }

}

class MidwayAgent extends (Agent as {
  new(...x)
}) {
  // 省略...
}

export {
  MidwayApplication as Application,
  MidwayAgent as Agent
};

主要的来说,MidwayApplication 所做的事情是继承 egg 的 Application,然后替换了原本 egg 的 loader,使用 midway 自己的 loader。

被继承的 egg 的 Application 中,默认的一些初始化结束后,就会走到 midway-web 的 loader 中开始加载各种资源:

// midway-web/src/loader/loader.ts
import {MidwayWebLoader} from './webLoader';
// ...

const APP_NAME = 'app';

export class AppWorkerLoader extends MidwayWebLoader {

  /**
   * intercept plugin when it set value to app
   */
  loadCustomApp() {
    this.interceptLoadCustomApplication(APP_NAME);
  }

  /**
   * Load all directories in convention
   */
  load() {
    // app > plugin > core
    this.loadApplicationExtend();
    this.loadRequestExtend();
    this.loadResponseExtend();
    this.loadContextExtend();
    this.loadHelperExtend();

    this.loadApplicationContext();
    // app > plugin
    this.loadCustomApp();
    // app > plugin
    this.loadService();
    // app > plugin > core
    this.loadMiddleware();

    this.app.beforeStart(async () => {
      await this.refreshContext();
      // get controller
      await this.loadController();
      // app
      this.loadRouter(); // 依赖 controller
    });
  }

}

export class AgentWorkerLoader extends MidwayWebLoader {
  // ...
}

midway-web/loader/loader.ts 也是 midway 拓展 egg 的一个核心文件。在 AppWorkerLoader 重写的 load 方法中,按照顺序加载了 extend、egg 的 service、midway 的各种 IoC 容器、middleware 以及 Midway 的 controller/router 等等。

loadXXXExtend

加载 Application、Request 等拓展,复用 egg-core 的逻辑。多继承 AppWorkerLoader -> MidwayWebLoader -> MidwayLoader -> EggLoader -> egg-core/lib/loader/mixin/extend()。

loadApplicationContext

loadApplicationContext 方法继承关系:AppWorkerLoader -> MidwayWebLoader -> MidwayLoader。

其中被 load 的 ApplicationContext 就是 IoC 容器的一个具体实例,用于存储应用级的,单例的对象。另外ApplicationContext 是 MidwayContainer 的实例,MidwayContainer 继承自 injection (依赖注入库)

在这一步中,如果用户没有配置关闭自扫描的话,会扫描用户 src 目录下的所有代码。如果发现 @controller, @router 类型的装饰修饰的 class 等都会被预先加载定义到 IoC 容器中。

loadCustomApp

通过 this.interceptLoadCustomApplication 设置 this.app 的 setter,让用户引入的插件要往 this.app挂插件的时候直接挂在 IoC 容器上。

loadService

复用 egg 的 service 逻辑。

loadMiddleware

复用 egg 的 middleware 加载逻辑。

refreshContext

刷新 applicationContext 和 pluginContext 等 IoC 容器。并且从 applicationContext 上取到预先解析的 controllerIds。然后循环通过 applicationContext.getAsync(id) 挨个获取 controller 的实例。

loadController

挨个 controller 获取通过 @controller('/user') 等装饰器的方式注册的一些控制器信息来初始化各个 controller,然后对多个 controller 进行排序最后直接传递给 app.use。

loadRouter

src/router.ts 文件加载,直接复用 egg 的逻辑。

example 代码对照流程

看完了主流程之后,我们在回过头看看看一开始使用 midway-init 脚手架生成的空项目。其中的 src 目录结构如下:

src
├── app
│   ├── controller
│   │   ├── home.ts
│   │   └── user.ts
│   └── public
│       └── README.md
├── config
│   ├── config.default.ts
│   └── plugin.ts
├── interface.ts
└── lib
    └── service
        └── user.ts

在 midway 启动的时候,midway 自定义的 loader 会返回一个基于 src (测试环境) 或者 dist (生产环境) 的 baseDir 目录。同时在 Application 中初始化到 load 阶段中的 loadApplicationContext 时。

// midway-core/src/loader.ts
// ...

export class MidwayLoader extends EggLoader {

  protected pluginLoaded = false;
  applicationContext;
  pluginContext;
  baseDir;
  appDir;
  options;

  constructor(options: MidwayLoaderOptions) {
    super(options);
    this.pluginContext = new MidwayContainer();
  }

  // ...

  protected loadApplicationContext() {
    // 从 src/config/config.default.ts 获取配置
    const containerConfig = this.config.container || this.app.options.container || {};

    // 实例化 ApplicationContext 
    this.applicationContext = new MidwayContainer(this.baseDir);
    // 实例化 requestContext
    const requestContext = new MidwayRequestContainer(this.applicationContext);

    // 注册一些实例到 applicationContext 上
    // ...

    // 如果没有关闭自扫描 (autoLoad) 则进行自扫描
    if (!containerConfig.disableAutoLoad) {
      // 判断默认扫的目录, 默认 'src/'
      const defaultLoadDir = this.isTsMode ? [this.baseDir] : ['app', 'lib'];
      // 按照扫描 export 出来的 class 统计到上下文
      this.applicationContext.load({
        loadDir: (containerConfig.loadDir || defaultLoadDir).map(dir => {
          return this.buildLoadDir(dir);
        }),
        pattern: containerConfig.pattern,
        ignore: containerConfig.ignore
      });
    }

    // 注册 config, plugin, logger 的 handler for container
    // ...
  }

  // ...
}

在 this.applicationContext.load 过程中,会有 globby 获取到 controller 下的 home.ts、user.ts 以及 lib/service/user.ts 等文件,取其 export 的 class 存在 applicationContext 中。另外还有 ControllerPaser 来解析识别文件是否是 controller,是的话就会 push 到 applicationContext.controllerIds 数组中。

所以用户在 src/app/controller/home.ts 中的代码:

import { controller, get, provide } from 'midway';

@provide()
@controller('/')
export class HomeController {

  @get('/')
  async index(ctx) {
    ctx.body = `Welcome to midwayjs!`;
  }
}

就是此时被扫到 HomeController 这个定义已经存储在 applicationContext 中,controllerIds 中也存储了该 controller。

随后在 this.refreshContext() 的过程中,执行了 this.preloadControllerFromXml() 即预加载 controller:

// midway-web/src/loader/webLoader.ts

export class MidwayWebLoader extends MidwayLoader {
  // ...
  async preloadControllerFromXml() {
    // 获取控制器 id 数组
    const ids = this.applicationContext.controllersIds;
    // for 循环遍历 id
    if (Array.isArray(ids) && ids.length > 0) {
      for (const id of ids) {
        // 异步获取 controller 实例
        const controllers = await this.applicationContext.getAsync(id);
        const app = this.app;
        if (Array.isArray(controllers.list)) {
          controllers.list.forEach(c => {
            // 初始化 egg 的 router
            const newRouter = new router_1.EggRouter({
              sensitive: true,
            }, app);

            // 将 controller 方法 expose 到具体的 router
            c.expose(newRouter);

            // 绑定对应 controller 的 router 到 app
            app.use(newRouter.middleware());
          });
        }
      }
    }
  }
  // ...
}

随后回到 midway-web/src/loader.ts 中,继续执行下一步 this.loadController():

// midway-web/src/loader/webLoader.ts

export class MidwayWebLoader extends MidwayLoader {
  // ...
  async loadController(opt: { directory? } = {}): Promise<void> {
    // 设置 controller 所在的基础目录
    const appDir = path.join(this.options.baseDir, 'app/controller');
    // 加载目录下的所有文件
    const results = loading(this.getFileExtension('**/*'), {
      loadDirs: opt.directory || appDir,
      call: false,
    });

    // 遍历每个文件
    for (const exports of results) {
      /* 如果是 export default class */
      if (is.class(exports)) {
        await this.preInitController(exports);
      } else {
      /* 如果是 export 多个 class */
        for (const m in exports) {
          const module = exports[m];
          if (is.class(module)) {
            await this.preInitController(module);
          }
        }
      }
    }

    // must sort by priority
    // 按照优先级排序

    // 调用 egg 的 controller 加载
    super.loadController(opt);
  }

  /**
   * 获取使用 @controller 装饰器注解的信息
   * 根据提取的信息来注册 router
   */
  private async preInitController(module): Promise<void> {
    // ...
  }
  // ...
}

完成以上步骤就可以通过 curl localhost:7001/ 来调用该 controller:

import { controller, get, provide } from 'midway';

@provide()
@controller('/')
export class HomeController {

  @get('/')
  async index(ctx) {
    ctx.body = `Welcome to midwayjs!`;
  }
}

HomeController 的 index 方法。返回收到 'Welcom to midwayjs!'

小结

midway-bin 作为继承自 egg-bin 的 midway 手脚架,在启动的过程中, 主要是设置了 midway 的默认端口和框架名。

midway 这个模块主要是作为启动入口并在 app 和 agent 启动的时候注册 typescript 环境,同时将 midway-web,egg,injection 等多个模块的定义在此统一导出。

最后的 midway-web 部分,在继承的 Application 和 Agent 类上并没有做太多的改动,主要是指定了替换了 egg 原本的 loadder,使用 midway 自己提供的 loader。并在 loader 的过程中添加很多 midway 特有的 feature,如 applicationContext、pluginContext、requestContext 等 IoC 容器。

而用户自己的 home.ts 这个路由,则是在自扫描阶段被解析到 controller 并且暂存 class 定义。随后在加载 controller 的环节中,通过自扫描的数据来反向(可以不需要使用曾经 Egg.js 中 router.js 这样的定义文件声明)查找到 controller 和 router,并初始化好使用 app.use 装载,从而是的 '/' 可以被请求。

本例中,主要是一个 Hello world 式的 example 的启动流程,更多 midway 的优秀用例会在后面慢慢补充希望大家支持。

 类似资料: