深入 Egg.js:项目启动流程
最近有个小项目,在调研使用哪个 Node.js
后端框架,考虑到完善程度以及社区活跃性,最终我选择了 Egg.js 。带着好奇心,我粗略看了一下 Egg.js
的源代码,在此分享一下。考虑到涉及的东西比较多,准备分开几篇文章来写:项目启动流程、处理请求和优雅重启。
启动脚本
启动脚本有三种方式:开发环境使用 egg-bin
启动、生产环境使用 egg-script
启动、自定义启动。
开发环境使用 egg-bin 启动
dev
环境: 处理传入的参数,比如文件位置、监听端口、worker
数量等,然后startCluster
。debug
环境:继承dev
环境处理参数的逻辑,并且给启动参数加上--inspect
参数(此参数用与启动master
进程,后续的agent
与worker
由master
进程设置相应参数启动),启动一个inspector proxy
代理,这样调试的时候直接与代理交互就行,不用关心worker
的数量、端口以及重启等问题。注意此时代理并不知道真正的worker
的调试端口,所以只是监听事件,等worker
启动后发消息过来时,便自动代理 worker 的调试端口。然后startCluster
。
生产环境使用 egg-script 启动
生产环境比开发环境需要注意更多的东西。 egg-script
有 start
和 stop
两个命令,顾名思义,start
命令是来启动。我们主要关注 start
命令。首先,依然是处理传入的参数,开发环境中日志默认输出到控制台,生产环境的日志会默认输出到文件。
然后如果指定的 deamon
作为守护进程启动的话,需要做特殊处理:
// see https://github.com/eggjs/egg-scripts/blob/82f4125245ddde5378401d04404b89fb27c86765/lib/cmd/start.js#L142-L166
if (isDaemon) {
const [ stdout, stderr ] = yield [ getRotatelog(argv.stdout), getRotatelog(argv.stderr) ];
options.stdio = [ 'ignore', stdout, stderr, 'ipc' ];
options.detached = true;
const child = this.child = spawn('node', eggArgs, options);
this.isReady = false;
child.on('message', msg => {
/* istanbul ignore else */
if (msg && msg.action === 'egg-ready') {
this.isReady = true;
this.logger.info('%s started on %s', this.frameworkName, msg.data.address);
child.unref();
child.disconnect();
process.exit(0);
}
});
// check start status
yield this.checkStatus(argv);
} else {
// signal event had been handler at common-bin helper
this.helper.spawn('node', eggArgs, options);
}
注意这里使用的是 child_process
模块的 spawn
创建子进程,而不是 fork
。为了成为守护进程,分别设置了 stdio
与 detach
参数,然后在子进程 ready
后,断开与父进程的 ipc
通道,断开引用,退出父进程。
父进程在启动子进程后,还会不停地查询子进程是否 ready
,如果超过特定时间子进程还没 ready
,或者错误日志被写入了内容,说明其中出了问题,报错。
// see https://github.com/eggjs/egg-scripts/blob/82f4125245ddde5378401d04404b89fb27c86765/lib/cmd/start.js#L186-L231
* checkStatus({ stderr, timeout, 'ignore-stderr': ignoreStdErr }) {
let count = 0;
let hasError = false;
let isSuccess = true;
timeout = timeout / 1000;
while (!this.isReady) {
try {
const stat = yield fs.stat(stderr);
if (stat && stat.size > 0) {
hasError = true;
break;
}
} catch (_) {
// nothing
}
if (count >= timeout) {
this.logger.error('Start failed, %ds timeout', timeout);
isSuccess = false;
break;
}
yield sleep(1000);
this.logger.log('Wait Start: %d...', ++count);
}
if (hasError) {
try {
const [ stdout ] = yield exec('tail -n 100 ' + stderr);
this.logger.error('Got error when startup: ');
this.logger.error(stdout);
} catch (_) {
// nothing
}
isSuccess = ignoreStdErr;
this.logger.error('Start got error, see %s', stderr);
this.logger.error('Or use `--ignore-stderr` to ignore stderr at startup.');
}
if (!isSuccess) {
this.child.kill('SIGTERM');
yield sleep(1000);
process.exit(1);
}
}
子进程中调用 startCluster
启动。
自定义启动
Egg.js
是一个灵活的框架,你可以通过下面的代码这样自定义启动,不过如果你不知道自己在干嘛的话,还是老实地使用 egg-script
启动吧 :)。
require('egg').startCluster(options);
egg-cluster 启动
以上启动脚本调用 startCluster
方法后,egg-cluster
模块便接管了接下来的事项。
启动 Master 进程
启动脚本的进程我们叫它 parent
进程,parent
进程启动的子进程是 master
进程(如果你使用自定义启动,则没有 parent
进程,自己就是 master
进程)。关系图如下,具体参见 Egg 文档:
+--------+
| Parent |
+--------+
^
|
|
|
v
+--------+ +-------+
| Master |<-------->| Agent |
+--------+ +-------+
^ ^ ^
/ | \
/ | \
/ | \
v v v
+----------+ +----------+ +----------+
| Worker 1 | | Worker 2 | | Worker 3 |
+----------+ +----------+ +----------+
parent
进程在 master
进程成功启动后,即完成使命,光荣退出(deamon
模式下是这样,其它模式下不会,比如 debug
环境下继续维护远程调试的代理)。
master
进程负责管理接下来启动的 app
与 agent
进程,并且是通信的中心枢纽。master
进程继承了 EventEmitter
类,他本身主要通过监听事件与发出事件来完成任务,这样就没有难看的 callback hell
。
启动 Agent 进程
master
进程首先启动 agent
进程。agent
进程是用来干嘛的呢?我第一次看到这个的时候我也很困惑,按理说有 master
和 worker
进程不就好了吗,agent
进程似乎是多余的。当然我还是太年轻,大家可以去看看 Egg 文档 关于 agent
进程的设计:
Agent 好比是 Master 给其他 Worker 请的一个『秘书』,它不对外提供服务,只给 App Worker 打工,专门处理一些公共事务。
这里确实能让你感受到 Egg.js 为企业级框架和应用而生
这句话的道理,Egg.js
团队在业务中遇到的坑,都总结做出方案贡献到了框架里。
agent
进程启动后,发送 agent-start
事件给 master
,master
进程监听了这个事件,并且在处理函数中启动 worker
进程。
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/master.js#L98-L133
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));
// ...
detectPort((err, port) => {
/* istanbul ignore if */
if (err) {
err.name = 'ClusterPortConflictError';
err.message = '[master] try get free port error, ' + err.message;
this.logger.error(err);
process.exit(1);
}
this.options.clusterPort = port;
this.forkAgentWorker();
});
启动 Worker 进程
egg-cluster
使用 cfork 来启动 worker
进程,cfork
会自动做出错时的 refork
操作。值得注意的是,在 worker
进程没启动成功之前,自动 refork
逻辑会关闭掉,直到 worker
进程都 ready
后才打开,这样可以避免 worker
进程启动时发生错误无限自动重启的问题(PM2也有相关参数避免这个问题)。
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/master.js#L229-L262
forkAppWorkers() {
this.appStartTime = Date.now();
this.isAllAppWorkerStarted = false;
this.startSuccessCount = 0;
const args = [ JSON.stringify(this.options) ];
this.log('[master] start appWorker with args %j', args);
cfork({
exec: appWorkerFile,
args,
silent: false,
count: this.options.workers,
// don't refork in local env
refork: this.isProduction,
});
let debugPort = process.debugPort;
cluster.on('fork', worker => {
worker.disableRefork = true;
this.workerManager.setWorker(worker);
worker.on('message', msg => {
if (typeof msg === 'string') msg = { action: msg, data: msg };
msg.from = 'app';
this.messenger.send(msg);
});
this.log('[master] app_worker#%s:%s start, state: %s, current workers: %j',
worker.id, worker.process.pid, worker.state, Object.keys(cluster.workers));
// send debug message, due to `brk` scence, send here instead of app_worker.js
if (this.options.isDebug) {
debugPort++;
this.messenger.send({ to: 'parent', from: 'app', action: 'debug', data: { debugPort, pid: worker.process.pid } });
}
});
// ...
}
worker
进程启动后,会发送 app-start
事件给 master
,调用 onAppStart
处理函数。onAppStart
主要做了两件事
- 统计成功启动的
worker
数量; - 如果设置了
sticky
选项(一般在使用websocket
时需要设置),做不同的处理。
如果不需要 sticky
模式,用 nodejs
的 cluster
模块启动 worker
,每个 worker
可以监听到同一个端口,处理请求。当然这只是表象,实际上是 cluster
模块为你监听了那个端口,然后用某个调度算法(比如 round-robin
)把请求分配给 worker
处理。
而 sticky
模式则不是这样。为什么要 sticky
模式呢? HTTP
协议是无状态的,每个请求进入不同的 worker
处理的话,结果应该是一致(如果你的 session
不是依赖本地缓存的话)。而 websocket
则是有状态,连接是全双工的,如果 client A
本来连接了 worker B
,而下次又由 worker C
处理的话,就会出问题。sticky
模式正是要解决这个问题,它会把 client A
的请求始终分配给 worker B
。
上代码看看sticky
模式是怎么实现的:
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/master.js#L153-L168
startMasterSocketServer(cb) {
// Create the outside facing server listening on our port.
require('net').createServer({ pauseOnConnect: true }, connection => {
// We received a connection and need to pass it to the appropriate
// worker. Get the worker for this connection's source IP and pass
// it the connection.
/* istanbul ignore next */
if (!connection.remoteAddress) {
connection.close();
} else {
const worker = this.stickyWorker(connection.remoteAddress);
worker.send('sticky-session:connection', connection);
}
}).listen(this[REALPORT], cb);
}
master
进程监听了 REALPORT
,当请求进来时,它会通过消息把 connection
传递给 worker
处理。怎么确定通知哪个 worker
处理请求呢?继续看代码:
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/master.js#L170-L183
stickyWorker(ip) {
const workerNumbers = this.options.workers;
const ws = this.workerManager.listWorkerIds();
let s = '';
for (let i = 0; i < ip.length; i++) {
if (!isNaN(ip[i])) {
s += ip[i];
}
}
s = Number(s);
const pid = ws[s % workerNumbers];
return this.workerManager.getWorker(pid);
}
上面的源代码可以看出,master
进程是通过请求中客户端的 ip
来确定该请求由哪个 worker
处理。
再看 worker
所做的事情,区别处理了 http
与 https
两种情况:
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/app_worker.js#L36-L45
let server;
if (options.https) {
const httpsOptions = Object.assign({}, options.https, {
key: fs.readFileSync(options.https.key),
cert: fs.readFileSync(options.https.cert),
});
server = require('https').createServer(httpsOptions, app.callback());
} else {
server = require('http').createServer(app.callback());
}
然后区别处理了 sticky
模式与否:
// see https://github.com/eggjs/egg-cluster/blob/b3c0e39d82197cd898f04a0101897e87b574161f/lib/app_worker.js#L55-L81
if (options.sticky) {
server.listen(0, '127.0.0.1');
// Listen to messages sent from the master. Ignore everything else.
process.on('message', (message, connection) => {
if (message !== 'sticky-session:connection') {
return;
}
// Emulate a connection event on the server by emitting the
// event with the connection the master sent us.
server.emit('connection', connection);
connection.resume();
});
} else {
if (listenConfig.path) {
server.listen(listenConfig.path);
} else {
if (typeof port !== 'number') {
consoleLogger.error('[app_worker] port should be number, but got %s(%s)', port, typeof port);
process.exit(1);
}
const args = [ port ];
if (listenConfig.hostname) args.push(listenConfig.hostname);
debug('listen options %s', args);
server.listen(...args);
}
}
正如前文所述,非 sticky
模式下,worker
直接监听到同一个端口;而 sticky
模式下,worker
监听随机端口,当然这些随机端口不会有请求进来,worker
其实在等待 master
发消息过来,然后自己主动触发 connection
事件,这样就能假装有请求进来,然后走正常逻辑。
至此,worker
启动完成,接下来是 app
的初始化。
APP 初始化
加载文件
Egg 文档 也有相应说明。
- 加载
plugin
; - 加载
config
; - 加载
extend
; - 加载
app.js
、agent.js
; - 加载
service
; - 加载
middleware
; - 加载
controller
; - 加载
router
;
生命周期
Egg提供了应用启动(
beforeStart
), 启动完成(ready
), 关闭(beforeClose
)这三个生命周期方法。
init master process
⬇
init agent worker process
⬇
loader.load | beforeStart
⬇
await agent worker ready
⬇
call ready callback
⬇
init app worker processes
⬇
loader.load | beforeStart
⬇
await app workers ready
⬇
call ready callback
⬇
send egg-ready to master,
agent,app workers
总结
简要地介绍了一下 Egg.js
的启动流程,涉及的源代码有点多,可能有些地方讲得不准确,欢迎指正。接下来应该还会写一篇分析 Egg.js
处理请求的流程的文章。
参考:
(完)