笔记整理来自拉钩教育
01 事件循环
Node.js 事件循环
事件循环通俗来说就是一个无限的 while 循环。现在假设你对这个 while 循环什么都不了解,你一定会有以下疑问。
- 谁来启动这个循环过程,循环条件是什么?
- 循环的是什么任务呢?
- 循环的任务是否存在优先级概念?
- 什么进程或者线程来执行这个循环?
- 无限循环有没有终点?
带着这些问题,我们先来看看 Node.js 官网提供的事件循环原理图。
Node.js 循环原理
这一流程包含 6 个阶段,每个阶段代表的含义如下所示。
1)timers:本阶段执行已经被 setTimeout() 和 setInterval() 调度的回调函数,简单理解就是由这两个函数启动的回调函数。
(2)pending callbacks:本阶段执行某些系统操作(如 TCP 错误类型)的回调函数。
(3)idle、prepare:仅系统内部使用,你只需要知道有这 2 个阶段就可以。
(4)poll:检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行,接下来会详细分析这个过程。
(5)check:setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,,如下代码所示:
const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
console.log('1');
}, 0);
setImmediate( () => {
console.log('setImmediate 1');
});
/// 将会在 poll 阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
console.log('poll callback');
});
// 首次事件循环执行
console.log('2');
在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:
- setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms;
- 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数poll 队列中;
- 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate。
因此这也验证了这句话,先执行回调函数,再执行 setImmediate。
(6)close callbacks:执行一些关闭的回调函数,如 socket.on('close', ...)。
运行起点
从图 1 中我们可以看出事件循环的起点是 timers,如下代码所示:
setTimeout(() => {
console.log('1');
}, 0);
console.log('2')
在代码 setTimeout 中的回调函数就是新一轮事件循环的起点,看到这里有很多同学会提出非常合理的疑问:“为什么会先输出 2 然后输出 1,不是说 timer 的回调函数是运行起点吗?”
这里有一个非常关键点,当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。
总结来说,Node.js 事件循环的发起点有 4 个:
- Node.js 启动后;
- setTimeout 回调函数;
- setInterval 回调函数;
- 也可能是一次 I/O 后的回调函数。
以上就解释了我们上面提到的第 1 个问题。
Node.js 事件循环
在了解谁发起的事件循环后,我们再来回答第 2 个问题,即循环的是什么任务。在上面的核心流程中真正需要关注循环执行的就是 poll 这个过程。在 poll 过程中,主要处理的是异步 I/O 的回调函数,以及其他几乎所有的回调函数,异步 I/O 又分为网络 I/O 和文件 I/O。这是我们常见的代码逻辑部分的异步回调逻辑。
事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢?如图 2 所示。
在解释上图之前,我们先来解释下两个概念,微任务和宏任务。
微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise 也存在优先级,process.nextTick 高于 Promise。
宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列。这也解释了我们前面提到的第 3 个问题,事件循环中的事件类型是存在优先级。
在图 2 的左侧,我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。
- 同步代码。
- 将异步任务插入到微任务队列或者宏任务队列中。
- 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
如果微任务和宏任务都只有一层时,那么看起来是比较简单的,比如下面的例子:
// 首次事件循环执行
console.log('start');
/// 将会在新的事件循环中的阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
setTimeout(() => { // 新的事件循环的起点
console.log('setTimeout');
}, 0);
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
console.log('Promise callback');
});
/// 执行 process.nextTick
process.nextTick(() => {
console.log('nextTick callback');
});
// 首次事件循环执行
console.log('end');
根据上面介绍的执行过程,我们来分析下上面代码的执行过程:
- 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
- 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;
- 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;
- 再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于 setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success。
根据上面的分析,我们可以得到如下的执行结果:
end
nextTick callback
Promise callback
setTimeout
read file success
但是当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:
const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
console.log('1');
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file sync success');
});
}, 0);
/// 回调将会在新的事件循环之前
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
console.log('poll callback');
});
// 首次事件循环执行
console.log('2');
在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve。
- 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
- 接下来执行微任务,输出 poll callback。
- 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列。
- 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success。
根据上面的分析,我们可以得出最后的执行结果,如下所示:
2
poll callback
1
read file success
read file sync success
在上面的例子中,我们来思考一个问题,主线程是否会被阻塞,具体我们来看一个代码例子:
const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
console.log('1');
sleep(10000)
console.log('sleep 10s');
}, 0);
/// 将会在 poll 阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file success');
});
console.log('2');
/// 函数实现,参数 n 单位 毫秒 ;
function sleep ( n ) {
var start = new Date().getTime() ;
while ( true ) {
if ( new Date().getTime() - start > n ) {
// 使用 break 实现;
break;
}
}
}
我们在 setTimeout 中增加了一个阻塞逻辑,这个阻塞逻辑的现象是,只有等待当次事件循环结束后,才会执行 fs.readFile 回调函数。这里会发现 fs.readFile 其实已经处理完了,并且通知回调到了主线程,但是由于主线程在处理回调时被阻塞了,导致无法处理 fs.readFile 的回调。因此可以得出一个结论,主线程会因为回调函数的执行而被阻塞,这也符合图 2 中的执行流程图。
如果把上面代码中 setTimeout 的时间修改为 10 ms,你将会优先看到 fs.readFile 的回调函数,因为 fs.readFile 执行完成了,并且还未启动下一个事件循环,修改的代码如下:
setTimeout(() => { // 新的事件循环的起点
console.log('1');
sleep(10000)
console.log('sleep 10s');
}, 10);
最后我们再来回答第 5 个问题,当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行。
实践分析
了解了整个原理流程,我们再来实践验证下 Node.js 的事件驱动,以及 I/O 到底有什么效果和为什么能提高并发处理能力。我们的实验分别从同步和异步的代码性能分析对比,从而得出两者的差异。
Node.js 不善于处理 CPU 密集型的业务,就会导致性能问题,如果要实现一个耗时 CPU 的计算逻辑,处理方法有 2 种:
- 直接在主业务流程中处理;
- 通过网络异步 I/O 给其他进程处理。
接下来,我们用 2 种方法分别计算从 0 到 1000000000 之间的和,然后对比下各自的效果。
主流程执行
为了效果,我们把两部分计算分开,这样能更好地形成对比,没有异步驱动计算的话,只能同步的去执行两个函数 startCount 和 nextCount,然后将两部分计算结果相加。
const http = require('http');
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
res.write(`${startCount() + nextCount()}`);
res.end();
});
/**
* 从 0 计算到 500000000 的和
*/
function startCount() {
let sum = 0;
for(let i=0; i<500000000; i++){
sum = sum + i;
}
return sum;
}
/**
* 从 500000000 计算到 1000000000 之间的和
*/
function nextCount() {
let sum = 0;
for(let i=500000000; i<1000000000; i++){
sum = sum + i;
}
return sum;
}
/**
*
* 启动服务
*/
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});
接下来使用下面命令启动该服务:
node sync.js
启动成功后,再在另外一个命令行窗口执行如下命令,查看响应时间,运行命令如下:
time curl http://127.0.0.1:4000
运行完成以后可以看到如下的结果:
499999999075959400
real 0m1.100s
user 0m0.004s
sys 0m0.005s
启动第一行是计算结果,第二行是执行时长。经过多次运行,其结果基本相近,都在 1.1s 左右。接下来我们利用 Node.js 异步事件循环的方式来优化这部分计算方式。
异步网络 I/O
异步网络 I/O 对比主流程执行,优化的思想是将上面的两个计算函数 startCount 和 nextCount 分别交给其他两个进程来处理,然后主进程应用异步网络 I/O 的方式来调用执行。
我们先看下主流程逻辑,如下代码所示:
const http = require('http');
const rp = require('request-promise');
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
Promise.all([startCount(), nextCount()]).then((values) => {
let sum = values.reduce(function(prev, curr, idx, arr){
return parseInt(prev) + parseInt(curr);
})
res.write(`${sum}`);
res.end();
})
});
/**
* 从 0 计算到 500000000 的和
*/
async function startCount() {
return await rp.get('http://127.0.0.1:5000');
}
/**
* 从 500000000 计算到 1000000000 之间的和
*/
async function nextCount() {
return await rp.get('http://127.0.0.1:6000');
}
/**
*
* 启动服务
*/
server.listen(4000, () => {
console.log('server start http://127.0.0.1:4000');
});
代码中使用到了 Promise.all 来异步执行两个函数 startCount 和 nextCount,待 2 个异步执行结果返回后再计算求和。其中两个函数 startCount 和 nextCount 中的 rp.get 地址分别是:
复制代码
http://127.0.0.1:5000
http://127.0.0.1:6000
其实是两个新的进程分别计算两个求和的逻辑,具体以 5000 端口的逻辑为例看下,代码如下:
复制代码
const http = require('http');
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
let sum = 0;
for(let i=0; i<500000000; i++){
sum = sum + i;
}
res.write(`${sum}`);
res.end();
});
/**
*
* 启动服务
*/
server.listen(5000, () => {
console.log('server start http://127.0.0.1:5000');
});
接下来我们分别打开三个命令行窗口,使用以下命令分别启动三个服务:
node startServer.js
node nextServer.js
node async.js
启动成功后,再运行如下命令,查看执行时间:
time curl http://127.0.0.1:4000
运行成功后,你可以看到如下结果:
499999999075959400
real 0m0.575s
user 0m0.004s
sys 0m0.005s
结果还是一致的,但是运行时间缩减了一半,大大地提升了执行效率。
响应分析
两个服务的执行时间相差一半,因为异步网络 I/O 充分利用了 Node.js 的异步事件驱动能力,将耗时 CPU 计算逻辑给到其他进程来处理,而无须等待耗时 CPU 计算,可以直接处理其他请求或者其他部分逻辑。第一种同步执行的方式就无法去处理其逻辑,导致性能受到影响。
如果使用压测还可以使对比效果更加明显,我将在第 12 讲为你详细介绍关于压测使用以及分析过程。
单线程/多线程
我相信在面试过程中,面试官经常会问这个问题“Node.js 是单线程的还是多线程的”。
学完上面的内容后,你就可以回答了。
主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化等。
这里也可以解释我们前面提到的第 4 个问题,主要还是主线程来循环遍历当前事件。
总结
本讲主要介绍了 Node.js 事件循环机制和原理,然后通过实践对比了两种情况下的性能耗时,并且说明了异步事件循环驱动的好处。学完本讲以后,你就可以掌握 Node.js 的事件循环原理,也可以掌握如何充分利用 Node.js 的事件循环原理的优势。
你可以自行思考下这个问题:浏览器的事件循环原理和 Node.js 事件循环原理的区别以及联系有哪些点,欢迎你把答案写在评论区。
02 | 应用场景:Node.js 作为后台可以提供哪些服务?
目前 Node.js 最常被用作前端工程化,导致大家误解为 Node.js 只适合作前端工程化工具,而忽视了其作为后端服务的特性。导致很少在后端研发中考虑使用 Node.js,认为没有任何优势,比如适用场景较少、性能较差等。为了消除这种误解,本讲将介绍 Node.js 的特性,以及适合哪些后端应用场景。
服务分类
我们常听说的服务有 RESTful 和 RPC,但这都是架构设计规范。我们也可以从另外一个角度来分析后台服务,如图 1 所示。
以上分类并不能代表所有的服务,但是各个系统都或多或少包含这些服务。有些大型系统可能会比这复杂;有些小型系统可能没有这么多模块系统。
下面我们看下每个模块主要的工作是什么:
-
网关,处理请求转发和一些通用的逻辑,例如我们常见的 Nginx;
-
业务网关,处理业务相关的逻辑,比如一些通用的协议转化、通用的鉴权处理,以及其他统一的业务安全处理等;
-
运营系统,负责我们日常的运营活动或者运营系统;
-
业务系统,负责我们核心的业务功能的系统;
-
中台服务,负责一些通用 App 类的服务,比如配置下发、消息系统及用户反馈系统等;
-
各类基础层,这些就是比较单一的核心后台服务,例如用户模块,这就需要根据不同业务设计不同的核心底层服务;
-
左侧的数据缓存和数据存储,则是相应的数据类的服务。
在这些分层中,我们需要寻找网络 I/O 较多,但是 CPU 计算较少、业务复杂度高的服务,基于这点我们可以分析出 Node.js 应用在业务网关、中台服务及运营系统几个方面。接下来我们就分别从系统的业务场景及系统特性来分析为什么 Node.js 更合适。
业务网关
我们都了解 Nginx 作为负载均衡转发层,负责负载分发,那么业务网关又是什么呢?
可以这样考虑,比如我们后台管理系统有鉴权模块,以往都是在管理后台服务中增加一个鉴权的类,然后在统一路由处增加鉴权判断。而现在不仅仅是这个管理系统需要使用这个鉴权类,多个管理系统都需要这个鉴权类,这时你会考虑复制这个类到其他项目,又或者设计一个专门的服务来做鉴权,图 2 是一个转变的过程效果图。
图 2 业务网关的作用对比效果图
从上图我们可以看到,其实每个项目的鉴权都是相似的,没有必要在每个项目中维护一份通用的鉴权服务。因此可以提炼一层叫作业务网关,专门处理业务相关的通用逻辑,包括鉴权模块。
接下来我们就从一个实际的例子 OPEN API 的业务网关来介绍下这类服务场景。
业务场景
OPEN API 一般会有一个统一的 token 鉴权,通过 token 鉴权后还需要判断第三方的 appid 是否有接口权限,其次判断接口是否到达了请求频率上限。为了服务安全,我们也可以做一些降级处理,在服务过载时,可以根据优先级抛弃一些请求,具体可以查看图 3。
接下来我们从技术层面来看为什么 Node.js 更适合此类应用场景。
服务特性
根据图 2 的场景应用,我们专注看下 Nginx 后面的业务网关处理层,它的业务场景如图 4 所示。
这 3 个功能都是基于缓存来处理业务逻辑的,大部分都是网络 I/O ,并未涉及 CPU 密集型逻辑,这也是 Node.js 的优势,其次异步驱动的方案能够处理更高的并发。根据第 01 讲的内容,Node.js 的代码核心是不阻塞主线程处理,而这类业务网关都是轻 CPU 运算服务。因此在这类场景的技术选型中,可以考虑使用 Node.js 作为服务端语言。
中台服务
在 Web 或者 App 应用中都存在一些通用服务,以往都是独立接口、独立开发。随着公司应用越来越多,需要将一些通用的业务服务进行集中,这也是中台的概念。而这部分业务场景往往也是网络 I/O 高、并发较大、业务关联性高、数据库读写压力相对较小。下面我们就来分析下这种业务场景。
业务场景
为了避免资源浪费、人力浪费,我们可以使用如图 5 所示的中台服务系统:
-
前端配置系统是在服务端根据客户端的版本、设备、地区和语言,下发不同的配置(JSON或者文件包);
-
反馈系统,即用户可以在任何平台,调用反馈接口,并将反馈内容写入队列,并落地到系统中进行综合分析;
-
推送系统用于管理消息的推送、用户红点和消息数的拉取,以及消息列表的管理;
-
系统工具用于处理用户端日志捞取、用户端信息调试上报、性能定位问题分析提取等。
以上是多个中台系统的业务说明,我们再来具体看看每个系统的特性,从特性来分析为什么 Node.js 适合作为服务端语言。
服务特性
在中台系统的设计中,系统着重关注:网络 I/O、并发、通用性及业务复杂度,一般情况下不涉及复杂的 CPU 运算。这里我们以上面列举的系统来做分析,如表 1 所示。
在上述系统对比中,可以分析出 Node.js 作为中台服务,要求是:
-
通用性必须好;
-
低 CPU 计算;
-
网络 I/O 高或者低都行;
-
并发高或者低都行。
因为这样的服务在 Node.js 主线程中,可以快速处理各类业务场景,不会存在阻塞的情况,因此这类场景也适合使用 Node.js 作为服务端语言。
其他相关
运营系统
在各类互联网项目中,经常用运营活动来做项目推广,而这类运营系统往往逻辑复杂,同时需要根据业务场景进行多次迭代、不断优化。往往这些活动并发很高,但是可以不涉及底层数据库的读写,而更多的是缓存数据的处理。比如我们常见的一些投票活动、排行榜活动等,如图 6 所示。
运营系统这块我们会在《18 | 系统的实践设计(下):完成一个通用投票系统》中详细介绍,并且进行这类系统的实践开发。
不适合场景
前一讲介绍了事件循环原理,在原理中突出的是不能阻塞主线程,而一些密集型 CPU 运算的服务则非常不适合使用 Node.js 来处理。比如:
-
图片处理,比如图片的裁剪、图片的缩放,这些非常损耗 CPU 计算,应该用其他进程来处理;
-
大字符串、大数组类处理,当涉及这些数据时,应该考虑如何通过切割来处理,或者在其他进程异步处理;
-
大文件读写处理,有时会使用 Node.js 服务来处理 Excel,但是遇到 Excel 过大时,会导致 Node.js 内存溢出,因为 V8 内存上限是 1.4 G。
可能还有更多场景,这里只是列举了很小的一部分,总之两个关键因素:大内存和CPU 密集,这样的场景都不适合使用 Node.js 来提供服务。
### 总结 本讲中介绍的各类系统,都遵循了我们《01 | 事件循环:高性能到底是如何做到的?》所介绍的 Node.js 事件循环原理,减少或者避免在 Node.js 主线程中被阻塞,或者进行一些 CPU 密集型计算。遵循了这个原理后,就可以拓展出一些业务复杂度高、业务迭代快的功能,或者一些通用性服务。
在学完本讲后,你可以了解 Node.js 适合哪些应用场景,并在实际工作中可以尝试使用或者推荐团队来尝试,有任何心得或者问题,都欢迎在评论区与我交流。
下一讲,我们将介绍一个 Node.js 作为后端服务的例子,到时见。
03 | 如何构建一个简单的 RESTful 服务?
前面几讲都是一些知识点的阐述,本讲将应用前面讲到的知识点,来实现一个简单版本的 RESTful 系统架构,并在此架构上实现一些简单的应用。
基础技术点
在学习本讲时会涉及一些技术知识点:
-
什么是 RESTful 规范;
-
数据库的读写处理过程;
-
目前常用的 MVC 架构模式,以及后续本专栏所应用的一套新的、独创的架构模式——MSVC 架构模式。
RESTful
RESTful(Representational State Transfer)是一种架构的约束条件和规则。在倡导前后端分离后,该架构规范的应用愈加广泛。具体知识点,你可以参考这里进行学习。
由于本讲涉及数据库的操作,本专栏主要使用非关系型数据库——MongoDB,因此这里需要先了解下 MongoDB 的相关操作,以及安装配置方法,你可以参考官网的文档来安装,这里就不细讲。为了使用便利,我们可以直接在官网创建 MongoDB 云服务远程连接,具体请参照官网,以及 API 文档请参考这里。
MVC→MSVC
我们应该都比较熟知 MVC 架构,它在前后端分离中起到了非常重要的作用,我们先来看下传统的 MVC 架构的模式,如图 1 所示。
此模式中:
-
M(Model)层处理数据库相关的操作(只有数据库操作时);
-
C(Controller)层处理业务逻辑;
-
V(View)层则是页面显示和交互(本讲不涉及)。
但是在目前服务划分较细的情况下,M 层不仅仅是数据库操作,因此这种架构模式显得有些力不从心,导致开发的数据以及业务逻辑有时候在 M 层,有时候却在 C 层。出现这类情况的核心原因是 C 与 C 之间无法进行复用,如果需要复用则需要放到 M 层,那么业务逻辑就会冗余在 M,代码会显得非常繁杂,如图 2 所示。
图 2 MVC 模式问题
为了解决以上问题,在经过一些实践后,我在研发过程中提出了一套新的架构模式,当然也有他人提到过(比如 Eggjs 框架中的模式)。这种模式也会应用在本专栏的整个架构体系中,我们暂且叫作 MSVC(Model、Service、View、Controller)。
我们先来看下 MSVC 的架构模式,如图 3 所示。
将所有数据相关的操作都集中于 M 层,而 M 层复用的业务逻辑则转到新的 S 层,C 层则负责核心业务处理,可以调用 M 和 S 层。以上是相关知识点,接下来我们进行架构的实践设计。
系统实践
我们先实现一个简单版本的 RESTful 服务,其次为了能够更清晰地了解 MVC 架构和 MSVC 架构的优缺点,我们也会分别实现两个版本的 RESTful 服务。
我们要实现的是一个获取用户发帖的列表信息 API,该 API 列表的内容包含两部分,一部分是从数据库获取的发帖内容,但是这部分只包含用户 ID,另外一部分则是需要通过 ID 批量拉取用户信息。
我们先来设计 RESTful API,由于是拉取列表内容接口,因此这里设计为一个 GET 接口,根据 RESTful 约束规则设计为:GET /v1/contents;另外还需要设计一个独立的服务用来获取用户信息,将接口设计为:GET /v1/userinfos。
为了更清晰些,我绘制了一个时序图来表示,如图 4 所示。
图 4 例子系统时序图
在图 4 中详细的过程是:
-
用户先调用 /v1/contents API 拉取 restful server 的内容;
-
restful server 会首先去 MongoDB 中获取 contents;
-
拿到 contents 后解析出其中的 userIds;
-
然后再通过 /v1/userinfos API 调用 API server 的服务获取用户信息列表;
-
API server 同样需要和 MongoDB 交互查询到所需要的 userinfos;
-
拿到 userinfos 后通过 addUserinfo 将用户信息整合到 contents 中去;
-
最后将 contents 返回给到调用方。
在不考虑任何架构模式的情况下,我们来实现一个简单版本的 restful 服务,上面分析了需要实现 2 个 server,这里分别叫作 API server 和 restful server。
API server
server 包含 2 个部分:解析请求路径和解析请求参数,在 Node.js 中我们可以用以下代码来解析:
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer(async (req, res) => {
// 获取 get 参数
const pathname = url.parse(req.url).pathname;
paramStr = url.parse(req.url).query,
param = querystring.parse(paramStr);
// 过滤非拉取用户信息请求
if('/v1/userinfos' != pathname) {
return setResInfo(res, false, 'path not found');
}
// 参数校验,没有包含参数时返回错误
if(!param || !param['user_ids']) {
return setResInfo(res, false, 'params error');
}
});
上面代码中使用 Node.js 的 url 模块来获取请求路径和 GET 字符串,拿到 GET 的字符串后还需要使用 Node.js 的 querystring 将字符串解析为参数的 JSON 对象。
参数和请求路径解析成功后,再进行路径的判断和校验,如果不满足我们当前的要求,调用 setResInfo 报错返回相应的数据给到前端。setResInfo 这个函数实现比较简单,使用 res 对象来设置返回的数据,具体你可以前往 GitHub 源码中查看。
路径和参数解析成功后,我们再根据当前参数查询 MongoDB 中的 userinfo 数据,具体代码如下:
const baseMongo = require('./lib/baseMongodb')();
const server = http.createServer(async (req, res) => {
// ...省略上面部分代码
// 从 db 查询数据,并获取,有可能返回空数据
const userInfo = await queryData({'id' : { $in : param['user_ids'].split(',')}});
return setResInfo(res, true, 'success', userInfo);
});
/**
*
* @description db 数据查询
* @param object queryOption
*/
async function queryData(queryOption) {
const client = await baseMongo.getClient();
const collection = client.db("nodejs_cloumn").collection("user");
const queryArr = await collection.find(queryOption).toArray();
return queryArr;
}
这一代码中使用了 baseMongodb 这个自己封装的库,该库主要基于 mongo 的基础库进行了本地封装处理。在 queryData 中通过 mongo 来查询 nodejs_cloumn 库中的 user 表,并带上查询条件,查询语法你可以参考 API 文档。
注意上面代码中,find 查询返回的数据需要使用 toArray 进行转化处理。拿到 MongoDB 查询结果后,再调用 setResInfo 返回查询结果给到前端。
接下来我们继续实现 restful server。
restful server
和 API server 相似,前面 2 个过程是解析请求路径和请求参数,解析成功后,根据时序图先从 MongoDB 中拉取 10 条 content 数据,代码如下:
const server = http.createServer(async (req, res) => {
// 获取 get 参数
const pathname = url.parse(req.url).pathname;
paramStr = url.parse(req.url).query,
param = querystring.parse(paramStr);
// 过滤非拉取用户信息请求
if('/v1/contents' != pathname) {
return setResInfo(res, false, 'path not found', null, '404');
}
// 从 db 查询数据,并获取,有可能返回空数据
let contents = await queryData({}, {limit: 10});
contents = await filterUserinfo(contents);
return setResInfo(res, true, 'success', contents);
});
在 MongoDB 中查询到具体的 contents 后,再调用 filterUserinfo 这个函数将 contents 中的 user_id 转化为 userinfo,具体代码如图 5 所示(为了代码简洁,我使用了截图,源代码请参考 GitHub 上的):
图 5 filterUserinfo 代码实现
在上面代码中的第 52 行是调用 API server 将用户的 userIds 转化为 userinfos,最后在 64 行,将获取的 userinfos 添加到 contents 中。
最后我们打开两个命令行窗口,分别进入到两个 server 下,运行如下命令启动服务。
node index
运行成功后,我们在浏览器中打开如下地址:
http://127.0.0.1:5000/v1/userinfos?user_ids=1001,1002
你将会看到一个 JSON 的返回结构,如图 6 所示。
图 6 API server 返回信息
接下来我们访问如下地址,并且打开 chrome 的控制台的 network 状态栏。
http://127.0.0.1:5000/v1/test
你将会看到返回的状态码是 404,如图 7 所示,这也是 restful 的规范之一,即正确地使用 http 状态码。
图 7 异常响应返回
接下来我们请求 restful server 的 API,同样使用浏览器打开如下接口地址:
http://127.0.0.1:4000/v1/contents
你将会看到如图 8 所示的响应结果。
图 8 contents 响应结果
以上就实现了一个简单 restful 服务的功能,你可以看到代码都堆积在 index.js 中,并且代码逻辑还比较简单,如果稍微复杂一些,这种架构模式根本没法进行团队合作,或者后期维护,因此就需要 MVC 和 MVCS 架构模式来优化这种场景。
接下来我们先来看看使用 MVC 来优化。
进阶实现
没有架构模式虽然也能按照需求满足接口要求,但是代码是不可维护的。而 MVC 已经被实践证明是非常好的架构模式,但是在现阶段也存在一些问题,接下来我们就逐步进行优化,让我们的架构和代码更加优秀。
MVC
既然是 M 和 C,我们就先思考下,上面的 restful server 中哪些是 M 层的逻辑,哪些是 C 层的逻辑。
以上是所有的逻辑,根据表格,我们首先创建两个目录分别是 model 和 Controller:
-
在 model 中创建一个 content.js 用来处理 content model 逻辑;
-
在 Controller 中也创建一个 content.js 用来处理 content 的 Controller 逻辑。
在源代码中有一个 index.js 文件,在没有架构模式时,基本上处理了所有的业务,但是根据当前架构模式,如表 1 所示,只适合处理 url 路径解析、路由判断及转发,因此需要简化原来的逻辑,和第一部分代码一样,我们就不再列举了,主要看路由判断。首先需要根据 restful url 路由配置一份路由转发逻辑,配置如下:
const routerMapping = {
'/v1/contents' : {
'Controller' : 'content',
'method' : 'list'
},
'/v1/test' : {
'Controller' : 'content',
'method' : 'test'
}
};
上面代码的意思是:
-
如果请求路径是 /v1/contents 就转发到 content.js 这个 Controller,并且调用其 list 方法;
-
如果是 /v1/test 则也转发到 content.js 这个 Controller,但调用的是 test 方法。
注意:其中 test 是一个同步方法,list 是一个异步方法。
路由配置完成以后,就需要根据路由配置,将请求路径、转发到处理相应功能的模块或者类、函数中去,代码如图 9 所示。
图 9 index 核心逻辑
-
第一个红色框内的部分,判断的是路由是否在配置内,不存在则返回 404;
-
第二个红色框内的部分,加载对应的 Controller 模块;
-
第三个红色框内的部分,表示判断所调用的方法类型是异步还是同步,如果是异步使用 await 来获取执行结果,如果是同步则直接调用获取返回结果。
注意:这里使用 try catch 的目的是确保调用安全,避免 crash 问题。
接下来我们实现一个 Controller,为了合理性,我们先实现一个基类,然后让每个 Controller 继承这个基类:
-
在项目根目录下我们创建一个 core 文件夹,并创建一个 Controller.js 作为基类;
-
然后我们把一些相同的功能放入这个基类,比如 res 和 req 的赋值,以及通用返回处理,还有 url 参数解析等。
我们来看下这部分代码,如图 10 所示。
图 10 Controller 基类
功能还是比较简单的,只是提炼了一些 Controller 共同的部分。接下来我们再来实现 content.js 这个 Controller,代码如图 11 所示:
图 11 content.js Controller
我们在初次实现时,可以不关注图 11 中的第 2 和 3 行,实现红色框内的代码即可。可以将 list 暂时设置为空,实现完成后,我们在根目录运行以下命令,启动服务。
node index
接下来打开浏览器访问:
http://127.0.0.1:3000/v1/test
你就可以看到响应了一个 JSON 数据,这样就实现了 Controller 部分了。如下代码所示:
{
ret: 0,
message: "good",
data: { }
}
接下来我们再来实现 Model 层部分,和 Controller 类似,我们也需要一个基类来处理 Model 层相似的逻辑,然后其他 Model 来继承这个基类,这部分如图 12 所示。
图 12 Model 基类
这个基类首先设置了 db 名称,其次定义了一个 GET 方法来获取表的操作句柄,这部分代码与上面简单 restful 服务的类似。完成基类后,我们再来完善 model 中的 content.js 逻辑。
图 13 model content.js 代码实现
这部分代码主要方法是 getList,原理和简单 restful server 中的查询类似,在第 11 行通过父类的 GET 方法获取表 content 的操作句柄,再调用 MongoDB 的 find 方法查询 contents。有了 model content 后,我们再回去完善 content.js Controller 中的 list 函数部分逻辑,代码封装的比较简洁,如下所示:
async list() {
let contentList = await new ContentModel().getList();
contentList = await this._filterUserinfo(contentList);
return this.resAPI(true, 'success', contentList);
}
上面代码中的第 4 行,只能在当前 Controller 下实现一个私有方法 _filterUserinfo 来处理用户信息部分,这部分逻辑也和简单 restful 服务的一样。
这样就实现了一个 MVC 的架构,将原来的复杂不可扩展性的代码,转化为可扩展、易维护的代码,这部分核心代码可以参考 GitHub 源码。
MVCS
在上面的代码中存在一个问题,就是 _filterUserinfo 是放在 Controller 来处理,这个方法又会涉及调用 API server 的逻辑,看起来也是数据处理部分,从原理上说这部分不适合放在 Controller。其次在其他 Controller 也需要 _filterUserinfo 时,这时候就比较懵逼了,比如我们现在有另外一个 Controller 叫作 recommend.js,这里面也是拉取推荐的 content,也需要这个 _filterUserinfo 方法,如图 14 所示。
图 14 MVC 复用性问题例子
其中左边是存在的矛盾,因为 _filterUserinfo 在 Controller 是私有方法,recommend Controller 调用不到,那么为了复用,我们只能将该方法封装到 content-model 中,并且将数据也集中在 Model 层去。
虽然解决了问题,但是你会发现:
-
Model 层不干净了,它现在既要负责数据处理,又要负责业务逻辑;
-
Controller 层的业务减少了,但是分层不明确了,有些业务放在 Model,有些又在 Controller 层,对于后期代码的维护或者扩展都非常困难了。
为了解决这个问题,有一个新的概念——Service 层,具体如图 15 所示。
图 15 MSVC 优化效果
-
图中的浅红色框内,就是新架构模式的 M 层;
-
两个绿色框内为 C 层;
-
最上面的浅蓝色框则为 Service 层。
这样就可以复用 _filterUserinfo,并解决 M 与 C 层不明确的问题。接下来我们来实践这部分代码:
-
首先我们需要创建一个文件夹 service 来存放相应的 Service 层代码;
-
然后创建一个 content.js 来表示 content-service 这个模块;
-
再将原来代码中的 _filterUserinfo 逻辑转到 content-service 中去;
-
最后修改 Controller 代码。
如下代码所示:
async list() {
let contentList = await new ContentModel().getList();
contentList = await contentService.filterUserinfo(contentList);
return this.resAPI(true, 'success', contentList);
}
注意代码中的第 4 行,从原来调用本类的方法,修改为调用 contentService 的 filterUserinfo。
本讲最开始介绍了一些技术知识点,这些是你开始学习本专栏必需巩固的技术,接下来根据实践开发了一个微型的 restful 服务,由于代码的不可维护性以及不可扩展性,我们接下来就应用了 MVC 架构设计模式进行了优化,最后由于 MVC 的缺陷,进而提出了使用 MSVC 来解决 MVC 中 M 和 C 业务界定不清晰的问题。
学完本讲后,你就能自己写一个 restful API 了,并且能够掌握 MVC 和 MSVC 的架构原理,同时能够开发出轻量版本的框架。在实践过程中有任何问题或者心得,都可以在留言区留言。
讲解完我们自身设计的简版框架后,在下一讲要介绍 Node.js 目前业界使用最广的三个框架,并且进行深入对比分析其优缺点。
04 | 3 大主流系统框架:由浅入深分析 Express、Koa 和 Egg.js
上一讲我们没有应用任何框架实现了一个简单后台服务,以及一个简单版本的 MSVC 框架。本讲将介绍一些目前主流框架的设计思想,同时介绍其核心代码部分的实现,为后续使用框架优化我们上一讲实现的 MSVC 框架做一定的准备。
主流框架介绍
目前比较流行的 Node.js 框架有Express、KOA 和 Egg.js,其次是另外一个正在兴起的与 TypeScript 相关的框架——Nest.js,接下来我们分析三个主流框架之间的关系。
在介绍框架之前,我们先了解一个非常重要的概念——洋葱模型,这是一个在 Node.js 中比较重要的面试考点,掌握这个概念,当前各种框架的原理学习都会驾轻就熟。无论是哪个 Node.js 框架,都是基于中间件来实现的,而中间件(可以理解为一个类或者函数模块)的执行方式就需要依据洋葱模型来介绍。Express 和 KOA 之间的区别也在于洋葱模型的执行方式上。
洋葱模型
洋葱我们都知道,一层包裹着一层,层层递进,但是现在不是看其立体的结构,而是需要将洋葱切开来,从切开的平面来看,如图 1 所示。
图 1 洋葱切面图
可以看到要从洋葱中心点穿过去,就必须先一层层向内穿入洋葱表皮进入中心点,然后再从中心点一层层向外穿出表皮,这里有个特点:进入时穿入了多少层表皮,出去时就必须穿出多少层表皮。先穿入表皮,后穿出表皮,符合我们所说的栈列表,先进后出的原则。
然后再回到 Node.js 框架,洋葱的表皮我们可以思考为中间件:
-
从外向内的过程是一个关键词 next();
-
而从内向外则是每个中间件执行完毕后,进入下一层中间件,一直到最后一层。
中间件执行
为了理解上面的洋葱模型以及其执行过程,我们用 Express 作为框架例子,来实现一个后台服务。在应用 Express 前,需要做一些准备工作,你按照如下步骤初始化项目即可。
mkdir myapp
cd myapp
npm init
npm install express --save
touch app.js
然后输入以下代码,其中的 app.use 部分的就是 3 个中间件,从上到下代表的是洋葱的从外向内的各个层:1 是最外层,2 是中间层,3 是最内层。
接下来我们运行如下命令,启动项目。
node app.js
启动成功后,打开浏览器,输入如下浏览地址:
http://127.0.0.1:3000/
然后在命令行窗口,你可以看到打印的信息如下:
Example app listening on port 3000!
first
second
third
third end
second end
first end
这就可以很清晰地验证了我们中间件的执行过程:
-
先执行第一个中间件,输出 first;
-
遇到 next() 执行第二个中间件,输出 second;
-
再遇到 next() 执行第三个中间件,输出 third;
-
中间件都执行完毕后,往外一层层剥离,先输出 third end;
-
再输出 second;
-
最后输出 first end。
以上就是中间件的执行过程,不过 Express 和 KOA 在中间件执行过程中还是存在一些差异的。
Express & KOA
Express 框架出来比较久了,它在 Node.js 初期就是一个热度较高、成熟的 Web 框架,并且包括的应用场景非常齐全。同时基于 Express,也诞生了一些场景型的框架,常见的就如上面我们提到的 Nest.js 框架。
随着 Node.js 的不断迭代,出现了以 await/async 为核心的语法糖,Express 原班人马为了实现一个高可用、高性能、更健壮,并且符合当前 Node.js 版本的框架,开发出了 KOA 框架。
那么两者存在哪些方面的差异呢:
-
Express 封装、内置了很多中间件,比如 connect 和 router ,而 KOA 则比较轻量,开发者可以根据自身需求定制框架;
-
Express 是基于 callback 来处理中间件的,而 KOA 则是基于 await/async;
-
在异步执行中间件时,Express 并非严格按照洋葱模型执行中间件,而 KOA 则是严格遵循的。
为了更清晰地对比两者在中间件上的差异,我们对上面那段代码进行修改,其次用 KOA 来重新实现,看下两者的运行差异。
因为两者在中间件为异步函数的时候处理会有不同,因此我们保留原来三个中间件,同时在 2 和 3 之间插入一个新的异步中间件,代码如下:
/**
* 异步中间件
*/
app.use(async (req, res, next) => {
console.log('async');
await next();
await new Promise(
(resolve) =>
setTimeout(
() => {
console.log(`wait 1000 ms end`);
resolve()
},
1000
)
);
console.log('async end');
});
然后将其他中间件修改为 await next() 方式,如下中间件 1 的方式:
/**
* 中间件 1
*/
app.use(async (req, res, next) => {
console.log('first');
await next();
console.log('first end');
});
接下来,我们启动服务:
node app
并打开浏览器访问如下地址:
http://127.0.0.1:3000/
然后再回到打印窗口,你会发现输出如下数据:
Example app listening on port 3000!
first
second
async
third
third end
second end
first end
wait 1000 ms end
async end
可以看出,从内向外的是正常的,一层层往里进行调用,从外向内时则发生了一些变化,最主要的原因是异步中间件并没有按照顺序输出执行结果。
接下来我们看看 KOA 的效果。在应用 KOA 之前,我们需要参照如下命令进行初始化。
mkdir -p koa/myapp-async
cd koa/myapp-async
npm init
npm i koa --save
touch app.js
然后我们打开 app.js 添加如下代码,这部分我们只看中间件 1 和异步中间件即可,其他在 GitHub 源码中,你可以自行查看。
const Koa = require('koa');
const app = new Koa();
/**
* 中间件 1
*/
app.use(async (ctx, next) => {
console.log('first');
await next();
console.log('first end');
});
/**
* 异步中间件
*/
app.use(async (ctx, next) => {
console.log('async');
await next();
await new Promise(
(resolve) =>
setTimeout(
() => {
console.log(`wait 1000 ms end`);
resolve()
},
1000
)
);
console.log('async end');
});
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000, () => console.log(`Example app listening on port 3000!`));
和 express 代码基本没有什么差异,只是将中间件中的 res、req 参数替换为 ctx ,如上面代码的第 6 和 14 行,修改完成以后,我们需要启动服务:
node app
并打开浏览器访问如下地址:
http://127.0.0.1:3000/
然后打开命令行窗口,可以看到如下输出:
Example app listening on port 3000!
first
second
async
third
third end
wait 1000 ms end
async end
second end
first end
你会发现,KOA 严格按照了洋葱模型的执行,从上到下,也就是从洋葱的内部向外部,输出 first、second、async、third;接下来从内向外输出 third end、async end、second end、first end。
因为两者基于的 Node.js 版本不同,所以只是出现的时间点不同而已,并没有孰优孰劣之分。Express 功能较全,发展时间比较长,也经受了不同程度的历练,因此在一些项目上是一个不错的选择。当然你也可以选择 KOA,虽然刚诞生不久,但它是未来的一个趋势。
KOA & Egg.js
上面我们说了 KOA 是一个可定制的框架,开发者可以根据自己的需要,定制各种机制,比如多进程处理、路由处理、上下文 context 的处理、异常处理等,非常灵活。而 Egg.js 就是在 KOA 基础上,做了各种比较成熟的中间件和模块,可以说是在 KOA 框架基础上的最佳实践,用以满足开发者开箱即用的特性。
我们说到 KOA 是未来的一个趋势,然后 Egg.js 是目前 KOA 的最佳实践,因此在一些企业级应用后台服务时,可以使用 Egg.js 框架,如果你需要做一些高性能、高定制化的框架也可以在 KOA 基础上扩展出新的框架。本专栏为了实践教学,我们会在 KOA 基础上将上一讲的框架进行优化和扩展。
原理实现
以上简单介绍了几个框架的知识点,接下来我们再来看下其核心实现原理,这里只介绍底层的两个框架Express和KOA,如果你对 Egg.js 有兴趣的话,可以参照我们的方法进行学习。
Express
Express 涉及 app 函数、中间件、Router 和视图四个核心部分,这里我们只介绍 app 函数、中间件和 Router 原理,因为视图在后台服务中不是特别关键的部分。
我们先来看一个图,图 2 是 Express 核心代码结构部分:
图 2 Express 核心代码
它涉及的源码不多,其中:
-
middleware 是部分中间件模块;
-
router 是 Router 核心代码;
-
appliaction.js 就是我们所说的 app 函数核心处理部分;
-
express.js 是我们 express() 函数的执行模块,实现比较简单,主要是创建 application 对象,将 application 对象返回;
-
request.js 是对 HTTP 请求处理部分;
-
response.js 是对 HTTP 响应处理部分;
-
utils.js 是一些工具函数;
-
view.js 是视图处理部分。
express.js
在 express 整个代码架构中核心是创建 application 对象,那么我们先来看看这部分的核心实现部分。在 Express 中的例子都是下面这样的:
const express = require('express')
const app = express()
const port = 3000
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
app.get('/', (req, res) => res.send('Hello World!'))
其中我们所说的 app ,就是 express() 函数执行的返回,该 express.js 模块中核心代码是一个叫作 createApplication 函数,代码如下:
function createApplication() {
var app = function(req, res, next) {
app.handle(req, res, next);
};
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);
// expose the prototype that will get set on requests
app.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
// expose the prototype that will get set on responses
app.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})
app.init();
return app;
}
代码中最主要的部分是创建了一个 app 函数,并将 application 中的函数继承给 app 。因此 app 包含了 application 中所有的属性和方法,而其中的 app.init() 也是调用了 application.js 中的 app.init 函数。在 application.js 核心代码逻辑中,我们最常用到 app.use 、app.get 以及 app.post 方法,这三个原理都是一样的,我们主要看下 app.use 的代码实现。
application.js
app.use,用于中间件以及路由的处理,是我们常用的一个核心函数。
-
在只传入一个函数参数时,将会匹配所有的请求路径。
-
当传递的是具体的路径时,只有匹配到具体路径才会执行该函数。
如下代码所示:
const express = require('express')
const app = express()
const port = 3000
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
app.use((req, res, next) => {
console.log('first');
next();
console.log('first end');
});
app.use('/a', (req, res, next) => {
console.log('a');
next();
console.log('a end');
});
app.get('/', (req, res) => res.send('Hello World!'))
app.get('/a', (req, res) => res.send('Hello World! a'))
当我们只请求如下端口时,只执行第 6 ~ 10 行的 app.use。
http://127.0.0.1:3000/
而当请求如下端口时,两个中间件都会执行。
http://127.0.0.1:3000/a
再来看下 Express 代码实现,如图 3 所示:
图 3 Express app.use 代码实现
当没有传入 path 时,会默认设置 path 为 / ,而 / 则是匹配任何路径,最终都是调用 router.use 将 fn 中间件函数传入到 router 中。
接下来我们看下 router.use 的代码实现。
router/index.js
这个文件在当前目录 router 下的 index.js 中,有一个方法叫作 proto.use,即 application.js 中调用的 router.use 。
图 4 中间件 push 实现
图 4 中的代码经过一系列处理,最终将中间件函数通过 Layer 封装后放到栈列表中。就完成了中间件的处理,最后我们再来看下用户请求时,是如何在栈列表执行的。
所有请求进来后都会调用 application.js 中的 app.handle 方法,该方法最终调用的是 router/index.js 中的 proto.handle 方法,所以我们主要看下 router.handle 的实现。在这个函数中有一个 next 方法非常关键,用于判断执行下一层中间件的逻辑,它的原理是从栈列表中取出一个 layer 对象,判断是否满足当前匹配,如果满足则执行该中间件函数,如图 5 所示。
图 5 中间件执行逻辑
接下来我们再看看 layer.handle_request 的代码逻辑,如图 6 所示。
图 6 handle_request 代码实现
图 6 中的代码释放了一个很重要的逻辑,就是在代码 try 部分,会执行 fn 函数,而 fn 中的 next 为下一个中间件,因此中间件栈执行代码,过程如下所示:
(()=>{
console.log('a');
(()=>{
console.log('b');
(()=>{
console.log('c');
console.log('d');
})();
console.log('e');
})();
console.log('f');
})();
如果没有异步逻辑,那肯定是 a → b → c → d → e → f 的执行流程,如果这时我们在第二层增加一些异步处理函数时,情况如下代码所示:
(async ()=>{
console.log('a');
(async ()=>{
console.log('b');
(async ()=>{
console.log('c');
console.log('d');
})();
await new Promise((resolve) => setTimeout(() => {console.log(`async end`);resolve()}, 1000));
console.log('e');
})();
console.log('f');
})();
再执行这部分代码时,你会发现整个输出流程就不是原来的模式了,这也印证了 Express 的中间件执行方式并不是完全的洋葱模型。
Express 源码当然不止这些,这里只是介绍了部分核心代码,其他部分建议你按照这种方式自我学习。
KOA
和 Express 相似,我们只看 app 函数、中间件和 Router 三个部分的核心代码实现。在 app.use 中的逻辑非常相似,唯一的区别是,在 KOA 中使用的是 await/async 语法,因此需要判断中间件是否为异步方法,如果是则使用 koa-convert 将其转化为 Promise 方法,代码如下:
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
最终都是将中间件函数放入中间件的一个数组中。接下来我们再看下 KOA 是如何执行中间件的代码逻辑的,其核心是 koa-compose 模块中的这部分代码:
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
在代码中首先获取第一层级的中间件,也就是数组 middleware 的第一个元素,这里不同点在于使用了 Promise.resolve 来执行中间件,根据上面的代码我们可以假设 KOA 代码逻辑是这样的:
new Promise(async (resolve, reject) => {
console.log('a')
await new Promise(async (resolve, reject) => {
console.log('b');
await new Promise((resolve, reject) => {
console.log('c');
resolve();
}).then(async () => {
await new Promise((resolve) => setTimeout(() => {console.log(`async end`);resolve()}, 1000));
console.log('d');
});
resolve();
}).then(() => {
console.log('e')
})
resolve();
}).then(() => {
console.log('f')
})
可以看到所有 next() 后面的代码逻辑都包裹在 next() 中间件的 then 逻辑中,这样就可以确保上一个异步函数执行完成后才会执行到 then 的逻辑,也就保证了洋葱模型的先进后出原则,这点是 KOA 和 Express 的本质区别。这里要注意,如果需要确保中间件的执行顺序,必须使用 await next()。
Express 和 KOA 的源代码还有很多,这里就不一一分析了,其他的部分你可以自行学习,在学习中,可以进一步提升自己的编码能力,同时改变部分编码陋习。在此过程中有任何问题,都欢迎留言与我交流。
总结
本讲先介绍了洋葱模型,其次根据洋葱模型详细分析了 Express 和 KOA 框架的区别和联系,最后介绍了两个核心框架的 app 函数、中间件和 Router 三个部分的核心代码实现。学完本讲,你需要掌握洋葱模型,特别是在面试环节中要着重介绍;能够讲解清楚在 Express 与 KOA 中的洋葱模型差异;以及掌握 Express 和 KOA 中的核心代码实现部分原理;其次了解 Egg.js 框架。
下一讲,我们将会介绍 Node.js 多进程方案,在介绍该方案时,我们还会应用 KOA 框架来优化我们第 03 讲的基础框架,使其成为一个比较通用的框架。
05 | 多进程解决方案:cluster 模式以及 PM2 工具的原理介绍
前几讲我们都使用了一种非常简单暴力的方式(node app.js)启动 Node.js 服务器,而在线上我们要考虑使用多核 CPU,充分利用服务器资源,这里就用到多进程解决方案,所以本讲介绍 PM2 的原理以及如何应用一个 cluster 模式启动 Node.js 服务。
单线程问题
在《01 | 事件循环:高性能到底是如何做到的?》中我们分析了 Node.js 主线程是单线程的,如果我们使用 node app.js 方式运行,就启动了一个进程,只能在一个 CPU 中进行运算,无法应用服务器的多核 CPU,因此我们需要寻求一些解决方案。你能想到的解决方案肯定是多进程分发策略,即主进程接收所有请求,然后通过一定的负载均衡策略分发到不同的 Node.js 子进程中。如图 1 的方案所示:
这一方案有 2 个不同的实现:
- 主进程监听一个端口,子进程不监听端口,通过主进程分发请求到子进程;
- 主进程和子进程分别监听不同端口,通过主进程分发请求到子进程。
在 Node.js 中的 cluster 模式使用的是第一个实现。
cluster 模式
cluster 模式其实就是我们上面图 1 所介绍的模式,一个主进程和多个子进程,从而形成一个集群的概念。我们先来看看 cluster 模式的应用例子。
应用
我们先实现一个简单的 app.js,代码如下:
const http = require('http');
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
res.write(`hello world, start with cluster ${process.pid}`);
res.end();
});
/**
*
* 启动服务
*/
server.listen(3000, () => {
console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);
这是最简单的一个 Node.js 服务,接下来我们应用 cluster 模式来包装这个服务,代码如下:
const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) {
for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
cluster.fork();
}
} else {
require('./app.js');
}
首先判断是否为主进程:
- 如果是则使用 cluster.fork 创建子进程;
- 如果不是则为子进程 require 具体的 app.js。
然后运行下面命令启动服务。
复制代码
$ node cluster.js
启动成功后,再打开另外一个命令行窗口,多次运行以下命令:
复制代码
curl "http://127.0.0.1:3000/"
你可以看到如下输出:
复制代码
hello world, start with cluster 4543
hello world, start with cluster 4542
hello world, start with cluster 4543
hello world, start with cluster 4542
后面的进程 ID 是比较有规律的随机数,有时候输出 4543,有时候输出 4542,4543 和 4542 就是我们 fork 出来的两个子进程,接下来我们看下为什么是这样的。
原理
首先我们需要搞清楚两个问题:
- Node.js 的 cluster 是如何做到多个进程监听一个端口的;
- Node.js 是如何进行负载均衡请求分发的。
多进程端口问题
在 cluster 模式中存在 master 和 worker 的概念,master 就是主进程,worker 则是子进程,因此这里我们需要看下 master 进程和 worker 进程的创建方式。如下代码所示:
复制代码
const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) {
for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
cluster.fork();
}
} else {
require('./app.js');
}
这段代码中,第一次 require 的 cluster 对象就默认是一个 master,这里的判断逻辑在源码中,如下代码所示:
复制代码
'use strict';
const childOrPrimary = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'primary';
module.exports = require(`internal/cluster/${childOrPrimary}`);
通过进程环境变量设置来判断:
- 如果没有设置则为 master 进程;
- 如果有设置则为子进程。
因此第一次调用 cluster 模块是 master 进程,而后都是子进程。
主进程和子进程 require 文件不同:
- 前者是 internal/cluster/primary;
- 后者是 internal/cluster/child。
我们先来看下 master 进程的创建过程,这部分代码在这里。
可以看到 cluster.fork,一开始就会调用 setupPrimary 方法,创建主进程,由于该方法是通过 cluster.fork 调用,因此会调用多次,但是该模块有个全局变量 initialized 用来区分是否为首次,如果是首次则创建,否则则跳过,如下代码:
复制代码
if (initialized === true)
return process.nextTick(setupSettingsNT, settings);
initialized = true;
接下来继续看 cluster.fork 方法,源码如下:
复制代码
cluster.fork = function(env) {
cluster.setupPrimary();
const id = ++ids;
const workerProcess = createWorkerProcess(id, env);
const worker = new Worker({
id: id,
process: workerProcess
});
worker.on('message', function(message, handle) {
cluster.emit('message', this, message, handle);
});
在上面代码中第 2 行就是创建主进程,第 4 行就是创建 worker 子进程,在这个 createWorkerProcess 方法中,最终是使用 child_process 来创建子进程的。在初始化代码中,我们调用了两次 cluster.fork 方法,因此会创建 2 个子进程,在创建后又会调用我们项目根目录下的 cluster.js 启动一个新实例,这时候由于 cluster.isMaster 是 false,因此会 require 到 internal/cluster/child 这个方法。
由于是 worker 进程,因此代码会 require ('./app.js') 模块,在该模块中会监听具体的端口,代码如下:
复制代码
/**
*
* 启动服务
*/
server.listen(3000, () => {
console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);
这里的 server.listen 方法很重要,这部分源代码在这里,其中的 server.listen 会调用该模块中的 listenInCluster 方法,该方法中有一个关键信息,如下代码所示:
复制代码
if (cluster.isPrimary || exclusive) {
// Will create a new handle
// _listen2 sets up the listened handle, it is still named like this
// to avoid breaking code that wraps this method
server._listen2(address, port, addressType, backlog, fd, flags);
return;
}
const serverQuery = {
address: address,
port: port,
addressType: addressType,
fd: fd,
flags,
};
// Get the primary's server handle, and listen on it
cluster._getServer(server, serverQuery, listenOnPrimaryHandle);
上面代码中的第 6 行,判断为主进程,就是真实的监听端口启动服务,而如果非主进程则调用 cluster._getServer 方法,也就是 internal/cluster/child 中的 cluster._getServer 方法。
接下来我们看下这部分代码:
复制代码
obj.once('listening', () => {
cluster.worker.state = 'listening';
const address = obj.address();
message.act = 'listening';
message.port = (address && address.port) || options.port;
send(message);
});
这一代码通过 send 方法,如果监听到 listening 发送一个消息给到主进程,主进程也有一个同样的 listening 事件,监听到该事件后将子进程通过 EventEmitter 绑定在主进程上,这样就完成了主子进程之间的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常听到的 IPC 通信方式。
负载均衡原理
既然 Node.js cluster 模块使用的是主子进程方式,那么它是如何进行负载均衡处理的呢,这里就会涉及 Node.js cluster 模块中的两个模块。
- round_robin_handle.js(非 Windows 平台应用模式),这是一个轮询处理模式,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池子中,这里要注意的就是如果绑定过就会复用该子进程,如果没有则会重新判断,这里可以通过上面的 app.js 代码来测试,用浏览器去访问,你会发现每次调用的子进程 ID 都会不变。
- shared_handle.js( Windows 平台应用模式),通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听、处理请求。
以上就是 cluster 的原理,总结一下就是 cluster 模块应用 child_process 来创建子进程,子进程通过复写掉 cluster._getServer 方法,从而在 server.listen 来保证只有主进程监听端口,主子进程通过 IPC 进行通信,其次主进程根据平台或者协议不同,应用两种不同模块(round_robin_handle.js 和 shared_handle.js)进行请求分发给子进程处理。接下来我们看一下 cluster 的成熟的应用工具 PM2 的应用和原理。
PM2 原理
PM2 是守护进程管理器,可以帮助你管理和保持应用程序在线。PM2 入门非常简单,它是一个简单直观的 CLI 工具,可以通过 NPM 安装,接下来我们看下一些简单的用法。
应用
你可以使用如下命令进行 NPM 或者 Yarn 的安装:
复制代码
$ npm install pm2@latest -g
# or
$ yarn global add pm2
安装成功后,可以使用如下命令查看是否安装成功以及当前的版本:
复制代码
$ pm2 --version
接下来我们使用 PM2 启动一个简单的 Node.js 项目,进入本讲代码的项目根目录,然后运行下面命令:
复制代码
$ pm2 start app.js
运行后,再执行如下命令:
复制代码
$ pm2 list
可以看到如图 2 所示的结果,代表运行成功了。
图 2 pm2 list 运行结果
PM2 启动时可以带一些配置化参数,具体参数列表你可以参考官方文档。在开发中我总结出了一套最佳的实践,如以下配置所示:
复制代码
module.exports = {
apps : [{
name: "nodejs-column", // 启动进程名
script: "./app.js", // 启动文件
instances: 2, // 启动进程数
exec_mode: 'cluster', // 多进程多实例
env_development: {
NODE_ENV: "development",
watch: true, // 开发环境使用 true,其他必须设置为 false
},
env_testing: {
NODE_ENV: "testing",
watch: false, // 开发环境使用 true,其他必须设置为 false
},
env_production: {
NODE_ENV: "production",
watch: false, // 开发环境使用 true,其他必须设置为 false
},
log_date_format: 'YYYY-MM-DD HH:mm Z',
error_file: '~/data/err.log', // 错误日志文件,必须设置在项目外的目录,这里为了测试
out_file: '~/data/info.log', // 流水日志,包括 console.log 日志,必须设置在项目外的目录,这里为了测试
max_restarts: 10,
}]
}
在上面的配置中要特别注意 error_file 和 out_file,这里的日志目录在项目初始化时要创建好,如果不提前创建好会导致线上运行失败,特别是无权限创建目录时。其次如果存在环境差异的配置时,可以放置在不同的环境下,最终可以使用下面三种方式来启动项目,分别对应不同环境。
复制代码
$ pm2 start pm2.config.js --env development
$ pm2 start pm2.config.js --env testing
$ pm2 start pm2.config.js --env production
原理
接下来我们来看下是如何实现的,由于整个项目是比较复杂庞大的,这里我们主要关注进程创建管理的原理。
首先我们来看下进程创建的方式,整体的流程如图 3 所示。
图 3 PM2 源码多进程创建方式
这一方式涉及五个模块文件。
- CLI(lib/binaries/CLI.js)处理命令行输入,如我们运行的命令:
复制代码
pm2 start pm2.config.js --env development
- API(lib/API.js)对外暴露的各种命令行调用方法,比如上面的 start 命令对应的 API->start 方法。
- Client (lib/Client.js)可以理解为命令行接收端,负责创建守护进程 Daemon,并与 Daemon(lib/Daemon.js)保持 RPC 连接。
- God (lib/God.js)主要负责进程的创建和管理,主要是通过 Daemon 调用,Client 所有调用都是通过 RPC 调用 Daemon,然后 Daemon 调用 God 中的方法。
- 最终在 God 中调用 ClusterMode(lib/God/ClusterMode.js)模块,在 ClusterMode 中调用 Node.js 的 cluster.fork 创建子进程。
图 3 中首先通过命令行解析调用 API,API 中的方法基本上是与 CLI 中的命令行一一对应的,API 中的 start 方法会根据传入参数判断是否是调用的方法,一般情况下使用的都是一个 JSON 配置文件,因此调用 API 中的私有方法 _startJson。
接下来就开始在 Client 模块中流转了,在 _startJson 中会调用 executeRemote 方法,该方法会先判断 PM2 的守护进程 Daemon 是否启动,如果没有启动会先调用 Daemon 模块中的方法启动守护进程 RPC 服务,启动成功后再通知 Client 并建立 RPC 通信连接。
成功建立连接后,Client 会发送启动 Node.js 子进程的命令 prepare,该命令传递 Daemon,Daemon 中有一份对应的命令的执行方法,该命令最终会调用 God 中的 prepare 方法。
在 God 中最终会调用 God 文件夹下的 ClusterMode 模块,应用 Node.js 的 cluster.fork 创建子进程,这样就完成了整个启动过程。
综上所述,PM2 通过命令行,使用 RPC 建立 Client 与 Daemon 进程之间的通信,通过 RPC 通信方式,调用 God,从而应用 Node.js 的 cluster.fork 创建子进程的。以上是启动的流程,对于其他命令指令,比如 stop、restart 等,也是一样的通信流转过程,你参照上面的流程分析就可以了,如果遇到任何问题,都可以在留言区与我交流。
以上的分析你需要参考PM2 的 GitHub 源码。
总结
本讲主要介绍了 Node.js 中的 cluster 模块,并深入介绍了其核心原理,其次介绍了目前比较常用的多进程管理工具 PM2 的应用和原理。学完本讲后,需要掌握 Node.js cluster 原理,并且掌握 PM2 的实现原理。
接下来我们将开始讲解一些关于 Node.js 性能相关的知识,为后续的高性能服务做一定的准备,其次也在为后续性能优化打下一定的技术基础。
下一讲会讲解,目前我们在使用的 Node.js cluster 模式存在的性能问题。