eggjs 是阿里在 Nodejs 技术上的一大杰作,也是对开源世界的一大贡献。里面包含了很多技术结晶,值得我们学习。
大家都知道,nodejs 本身是单线程的,单线程意味着一个错误抛出未捕获就会导致整个应用挂掉。且单线程只能跑在一个核上,无法有效利用机器资源,对机器是一种浪费。
在 egg 中,为了解决上述问题,引入了 Master - Agent - Worker 机制,有多少个 CPU 启多少个 Worker ,榨干了机器的资源;对于每一个 Worker,挂了之后 Mater 进程会重启一个,保证了应用的健壮行。
他们的关系是:Mater 启动 Agent, Agent 启动后会告诉 Mater ,Master 收到消息后会启动 Worker ,Worker 启动完毕告诉 Master ,这个时候 Master 会处于 Ready 状态。这个实现在 egg-cluster 模块。
可以看到egg处于的是一个中间层的角色,基于koa,不同于koa以middleware为主要生态,egg根据不同的业务需求和场景,加入了plugin,extends等这些功能,可以让开发者摆脱在使用middleware功能时无法控制使用顺序的被动状态,而且还可以增加一些请求无关的一些功能。除此之外,egg还有很多其他优秀的功能。
Master 是主进程,只处理子进程的重启及通信;Agent 是 Master 的子进程,主要处理一些通用的服务,比如公共资源访问、文件监听,这些东西让一个 Agent 来做就好了,不需要每个 Worker 去关心。Worker 只需要处理好应用的逻辑就行。
Agent 是以 child_process fork 出来的,它属于 Master 的子进程。Worker 是 cluster fork 出来的(详见 cfork 模块),因为 Worker 是主要负责干活的,所以需要通过 cluster 内置的负载均衡机制合理的分配给他们。
Master Agent Worker 三者间 IPC 通讯实现是通过 Messenger 对象来实现的,Messenger 是 egg 实现的消息转发机制。另外由于Agent Worker 属于不同的进程,所以 Agent 与 Worker 不能直接通讯,不过它们有同一个“爹” Master ,所以它们的通讯是通过 Master 来中转的。
应用结构规范化是 egg 一个核心思想。约定目录是指约束基于 egg 的上层框架,如 begg 、beidou ,及应用的目录规范。这样做的好处:为 egg 的文件加载机制做准备(config 文件加载、plugin 加载等)。一个基于 egg 的应用应该符合以下目录结构:
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
| ├── router.js
│ ├── controller
│ │ └── home.js
| ├── extend (可选,对 egg 的扩展)
│ | ├── helper.js (可选)
│ | ├── filter.js (可选)
│ | ├── request.js (可选)
│ | ├── response.js (可选)
│ | ├── context.js (可选)
│ | ├── application.js (可选)
│ | └── agent.js (可选)
│ ├── proxy (可选,由 hsf/tr 插件规范,建议统一为 proxy)
│ ├── service (可选)
| ├── public (可选)
| │ ├── favicon.ico
| | └── ...
│ ├── middleware (可选)
│ │ └── response_time.js
│ └── views (可选,由 view 插件规范,建议统一为 views)
| ├── layout.html
│ └── home.html
├── config
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可选)
| ├── config.local.js (可选)
| ├── config.unittest.js (可选)
│ ├── plugin.js
│ └── role.js (可选,以 role 插件举例,插件特殊配置也放在 config 目录下)
└── test
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js
下面通过追踪源码来讲解一下egg究竟是如何运行起来的。
查看egg-init脚手架生成的项目文件,可以看到整个项目文件是没有严格意义上的入口文件的,根据package.json中的script命令,可以看到执行的直接是egg-bin dev的命令。找到egg-bin文件夹中的dev.js,会看到里面会去执行start-cluster文件:
//dev.js构造函数中
this.serverBin = path.join(__dirname, '../start-cluster');
// run成员函数
* run(context) {
//省略
yield this.helper.forkNode(this.serverBin, devArgs, options);
}
移步到start-cluster.js文件,可以看到关键的一行代码:
require(options.framework).startCluster(options);
其中options.framework打印信息为:
/Users/wyf/Project/egg-example/node_modules/egg
找到对应的egg目录中的index.js文件:
exports.startCluster = require(‘egg-cluster’).startCluster;
继续追踪可以看到最后运行的其实就是egg-cluster中的startCluster,并且会fork出agentWorker和appWorks,官方文档对于不同进程的fork顺序以及不同进程之间的IPC有比较清晰的说明,
主要的顺序如下:
Master 启动后先 fork Agent 进程
Agent 初始化成功后,通过 IPC 通道通知 Master
Master 再 fork 多个 App Worker
App Worker 初始化成功,通知 Master
所有的进程初始化成功后,Master 通知 Agent 和 Worker 应用启动成功
通过代码逻辑也可以看出它的顺序:
//在egg-ready状态的时候就会执行进程之间的通信
this.ready(() => {
//省略代码
const action = 'egg-ready';
this.messenger.send({ action, to: 'parent' });
this.messenger.send({ action, to: 'app', data: this.options });
this.messenger.send({ action, to: 'agent', data: this.options });
});
this.on('agent-exit', this.onAgentExit.bind(this));
this.on('agent-start', this.onAgentStart.bind(this));
this.on('app-exit', this.onAppExit.bind(this));
this.on('app-start', this.onAppStart.bind(this));
this.on('reload-worker', this.onReload.bind(this));
// fork app workers after agent started
this.once('agent-start', this.forkAppWorkers.bind(this));
通过上面的代码可以看出,master进程会去监听当前的状态,比如在检测到agent-start的时候才去fork AppWorkers,在当前状态为egg-ready的时候,会去执行如下的进程之间的通信:
master—> parent
master —> agent
master —> app
fork出了appWorker之后,每一个进程就开始干活了,在app_worker.js文件中,可以看到进程启动了服务,具体代码:
// 省略代码
function startServer() {
let server;
if (options.https) {
server = require('https').createServer({
key: fs.readFileSync(options.key),
cert: fs.readFileSync(options.cert),
}, app.callback());
} else {
server = require('http').createServer(app.callback());
}
//省略代码
}
然后就回归到koa中的入口文件干的事情了。
除此之外,每一个appWorker还实例化了一个Application:
const Application = require(options.framework).Application;
const app = new Application(options);
在实例化application(options)时,就会去执行node_modules->egg模块下面loader目录下面的逻辑,也就是agentWorker进程和多个appWorkers进程要去执行的加载逻辑,具体可以看到app_worker_loader.js文件中的load():
load() {
// app > plugin > core
this.loadApplicationExtend();
this.loadRequestExtend();
this.loadResponseExtend();
this.loadContextExtend();
this.loadHelperExtend();
// app > plugin
this.loadCustomApp();
// app > plugin
this.loadService();
// app > plugin > core
this.loadMiddleware();
// app
this.loadController();
// app
this.loadRouter(); // 依赖 controller
}
}
在真正执行业务代码之前,egg会先去干下面一些事情。
egg中内置了如下一系列插件:
加载插件的逻辑是在egg-core里面的plugin.js文件,先看代码:
loadPlugin() {
//省略代码
//把本地插件,egg内置的插件以及app的框架全部集成到allplugin中
this._extendPlugins(this.allPlugins, eggPlugins);
this._extendPlugins(this.allPlugins, appPlugins);
this._extendPlugins(this.allPlugins, customPlugins);
//省略代码
//遍历操作
for (const name in this.allPlugins) {
const plugin = this.allPlugins[name];
//对插件名称进行一些校验
this.mergePluginConfig(plugin);
//省略代码
}
if (plugin.enable) {
//整合所有开启的插件
enabledPluginNames.push(name);
}
}
如上代码(只是贴出了比较关键的地方),这段代码主要是将本地插件、egg中内置的插件以及应用的插件进行了整合。其中this.allPlugins的结果如下:
可以看出,this.allPlugins包含了所有内置的插件以及本地开发者自定义的插件。现货区所有插件的相关信息,然后将所有插件进行遍历,执行this.mergePluginConfig()函数,这个函数主要是对插件名称进行一些校验。之后还对项目中已经开启的插件进行整合。plugin.js文件还做了一些其他事情,比如获取插件路径,读取插件配置等等,这里不一一讲解。
包括插件里面定义的扩展以及开发者自己写的扩展,这也是这里讲的内容。
在对内置对象进行扩展的时候,实质上执行的是extend.js文件,扩展的对象包括如下几个:
loadExtend(name, proto) {
// All extend files
const filepaths = this.getExtendFilePaths(name);
// if use mm.env and serverEnv is not unittest
const isAddUnittest = 'EGG_MOCK_SERVER_ENV' in process.env && this.serverEnv !== 'unittest';
for (let i = 0, l = filepaths.length; i < l; i++) {
const filepath = filepaths[i];
filepaths.push(filepath + `.${this.serverEnv}.js`);
if (isAddUnittest) filepaths.push(filepath + '.unittest.js');
}
const mergeRecord = new Map();
for (let filepath of filepaths) {
filepath = utils.resolveModule(filepath);
if (!filepath) {
continue;
} else if (filepath.endsWith('/index.js')) {
// TODO: remove support at next version
deprecate(`app/extend/${name}/index.js is deprecated, use app/extend/${name}.js instead`);
}
const ext = utils.loadFile(filepath);
//获取内置对象的原有属性
const properties = Object.getOwnPropertyNames(ext)
.concat(Object.getOwnPropertySymbols(ext));
//对属性进行遍历
for (const property of properties) {
if (mergeRecord.has(property)) {
debug('Property: "%s" already exists in "%s",it will be redefined by "%s"',
property, mergeRecord.get(property), filepath);
}
// Copy descriptor
let descriptor = Object.getOwnPropertyDescriptor(ext, property);
let originalDescriptor = Object.getOwnPropertyDescriptor(proto, property);
if (!originalDescriptor) {
// try to get descriptor from originalPrototypes
const originalProto = originalPrototypes[name];
if (originalProto) {
originalDescriptor = Object.getOwnPropertyDescriptor(originalProto, property);
}
}
//省略代码
//将扩展属性进行合并
Object.defineProperty(proto, property, descriptor);
mergeRecord.set(property, filepath);
}
debug('merge %j to %s from %s', Object.keys(ext), name, filepath);
}
},
将filepaths进行打印,如下图:
可以看出,filepaths包含所有的对application扩展的文件路径,这里会首先将所有插件中扩展或者开发者自己自定义的扩展文件的路径获取到,然后进行遍历,并且对内置对象的一些原有属性和扩展属性进行合并,此时对内置对象扩展的一些属性就会添加到内置对象中。所以在执行业务代码的时候,就可以直接通过访问application.属性(或方法)进行调用。
对中间件的加载主要是执行的egg-core中的middleware.js文件,里面的代码思想也是和上面加载内置对象是一样的,也是将插件中的中间件和应用中的中间件路径全部获取到,然后进行遍历。
遍历完成之后执行中间件就和koa一样了,调用co进行包裹遍历。
加载控制器
对控制器的加载主要是执行的egg-core中的controller.js文件
egg的官方文档中,插件的开发这一节提到:
插件没有独立的 router 和 controller
所以在加载controller的时候,主要是load应用里面的controller即可。详见代码;
loadController(opt) {
opt = Object.assign({
caseStyle: 'lower',
directory: path.join(this.options.baseDir, 'app/controller'),
initializer: (obj, opt) => {
if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj)) {
obj = obj(this.app);
}
if (is.promise(obj)) {
const displayPath = path.relative(this.app.baseDir, opt.path);
throw new Error(`${displayPath} cannot be async function`);
}
if (is.class(obj)) {
obj.prototype.pathName = opt.pathName;
obj.prototype.fullPath = opt.path;
return wrapClass(obj);
}
if (is.object(obj)) {
return wrapObject(obj, opt.path);
}
if (is.generatorFunction(obj)) {
return wrapObject({ 'module.exports': obj }, opt.path)['module.exports'];
}
return obj;
},
}, opt);
const controllerBase = opt.directory;
this.loadToApp(controllerBase, 'controller', opt);
this.options.logger.info('[egg:loader] Controller loaded: %s', controllerBase);
},
这里主要是针对controller的类型进行判断(是否是Object,class,promise,generator),然后分别进行处理
加载service的逻辑是egg-core中的service.js,service.js这个文件比较简单,代码如下:
loadService(opt) {
// 载入到 app.serviceClasses
opt = Object.assign({
call: true,
caseStyle: 'lower',
fieldClass: 'serviceClasses',
directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')),
}, opt);
const servicePaths = opt.directory;
this.loadToContext(servicePaths, 'service', opt);
},
首先也是先获取所有插件和应用中声明的service.js文件目录,然后执行this.loadToContext()
loadToContext()定义在egg-loader.js文件中,继续追踪,可以看到在loadToContext()函数中实例化了ContextLoader并执行了load(),其中ContextLoader继承自FileLoader,而且load()是声明在FileLoader类中的。
通过查看load()代码可以发现里面的逻辑也是将属性添加到上下文(ctx)对象中的。也就是说加载context对象是在加载service的时候完成的。
而且值得一提的是:在每次刷新页面重新加载或者有新的请求的时候,都会去执行context_loader.js里面的逻辑,也就是说ctx上下文对象的内容会随着每次请求而发生改变,而且service对象是挂载在ctx对象下面的,对于service的更新,这里有一段代码:
// define ctx.service
Object.defineProperty(app.context, property, {
get() {
// distinguish property cache,
// cache's lifecycle is the same with this context instance
// e.x. ctx.service1 and ctx.service2 have different cache
if (!this[CLASSLOADER]) {
this[CLASSLOADER] = new Map();
}
const classLoader = this[CLASSLOADER];
//先判断是否有使用
let instance = classLoader.get(property);
if (!instance) {
instance = getInstance(target, this);
classLoader.set(property, instance);
}
return instance;
},
});
在更新service的时候,首先会去获取service是否挂载在ctx中,如果没有,则直接返回,否则实例化service,这也就是service模块中的延迟实例化
加载路由的逻辑主要是egg-core中的router.js文件
loadRouter() {
// 加载 router.js
this.loadFile(path.join(this.options.baseDir, 'app/router.js'));
},
可以看出很简单,只是加载应用文件下的router.js文件
直接加载配置文件并提供可配置的方法。
对egg应用信息的设置逻辑是对应的egg-core中的egg-loader.js,里面主要是提供一些方法获取整个app的信息,包括appinfo,name,path等,比较简单,这里不一一列出
然后就会去执行如渲染页面等的逻辑
文件加载机制是 egg 里面非常亮点的地方,其中 plugin 插件无缝集成,更让 egg 实现高可拓展性,为 egg 的繁荣生态打下基础。文件加载机制实现于 egg-loader 模块。
MasterLoader 、 AgentWorkerLoader 、 AppWorkerLoader 都是继承于 @ali/egg-loader 下的 BaseLoader ,而在 BaseLoader 中更是把 antx/config/extend/middleware/plugin/proxy/service 加载方式都集成了。以下是各进程所需要加载的目录文件:
MasterLoader(Mater进程)
loadAntx()
加载antx配置:会加载应用、egg 上层框架、egg 下面的config/antx.*.properties(*根据环境而定)
loadConfig()
加载应用、 egg 上层框架、egg 下面的config/config.*.js(*根据环境而定)
AgentLoader(Agent 进程)
loadPlugin()
会读取app/config/plugin.js 、egg 上层框架/lib/core/config/plugin.js 、egg/lib/core/plugin.js,并根据环境剔除一些未开启的插件。
loadAntx()
除了 masterLoader 加载的那些 antx 外,还加载了插件里的 antx 配置。
loadConfig()
除了 masterLoader 加载的那些 config 外,还加载了插件里的 config 配置。
AppWorkerLoader(Worker进程)
appWorkerLoader 除了加载 AgentLoader 相同的文件之外,还需要加载整个应用逻辑文件:
loadRequest()
loadResponse()
loadContext()
loadHelper()
加载 app/extend 目录下的文件,拓展 Koa 实例。包括 context.js request.js response.js application.js helper.js
loadCustomApp()
加载自定义的 app.js,包含应用下的, egg 上层框架,egg
loadProxy()
主要是加载一些 hsf 相关配置,加载应用及插件下里的 app/proxy 目录下的文件,需要依赖 hsf。
loadService()
加载应用及插件下里的 app/service 目录下的文件。通常是业务逻辑通用的抽象服务,比如 A 接口需要用户信息, B 接口也需要用户信息,那么用户信息就可以抽出来做一个单独的 service 服务。
loadMiddleware()
有关 koa 的中间件。包含应用下的、egg lib/core 及 插件下的 app/middleware 目录文件。
loadController()
加载 app/controller 目录下的文件,里面包含应用对路由的逻辑处理。
loadRouter()
加载 app/router.js,应用路由入口,依赖 controller。
egg 在除了上述内容之外,还为了保证应用健壮性这方面做了不少事情,比如 保证进程的优雅退出,日志记录等。构建与它之上的框架可以根据插件系统非常好的进行扩展,egg 本身的代码设计实现也比较优雅,值得阅读源码学习。