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 脚本来启动子进程。
综上,具体情况是:
- npm run dev
- NODE_ENV=local midway-bin dev --ts
- midway-bin/bin/midway-bin.js
- midway-bin/index.js
- 父类初始化 egg-bin -> common-bin
- 调用 midway-bin 重写的 dev 命令
- midway-bin/lib/cmd/dev.js 设置 port 和 framework 参数
- egg-bin/lib/cmd/dev.js 整理启动参数
- egg-bin -> common-bin #forkNode
- 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 的支持参数,然后重写了 getAgentWorkerFile
、getAppWorkerFile
,让 egg-cluster 在通过子进程 fork 启动 app 和 agent 的时候分别通过 midway/cluster/agent_work.js
和 midway/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 的优秀用例会在后面慢慢补充希望大家支持。