Nodejs应用实战(一)——事件循环、MSVC、Express VS Koa等框架介绍
Nodejs应用实战(一)——事件循环、MSVC、Express VS Koa等框架介绍
最近在学习 “清弦” 大人的《Node.js 应用开发实战》感觉将自己之前的Node基础又串联起来并得到了升华,特此记录,也分享给各位前端小伙伴。
[TOC]
Node.js 一般用来做什么?
- 前端的工程化(即将其作为辅助工具,利用 webpack 提升前端开发效率以及保证开发质量)
- 更加主要的是作为后端服务,利用应用 Node.js 异步事件驱动的特性,因为异步驱动特性,在主线程不被 CPU 密集型所影响时,可以真正发挥出 Node.js 高并发特性,可以作为大部分网络 I/O 较高的后端服务。
附上课程所有代码: github.com/love-flutte…
01 | 事件循环:高性能到底是如何做到的?
事件循环的原理
事件循环原理和浏览器的原理是不同的,Node.js 10+ 版本后虽然在运行结果上与浏览器一致,但是两者在原理上一个是基于浏览器,一个是基于 libev 库。浏览器核心的是宏任务和微任务,而在 Node.js 还有阶段性任务执行阶段。
事件循环通俗来说就是一个无限的 while 循环。
-
谁来启动这个循环过程,循环条件是什么?
-
循环的是什么任务呢?
-
循环的任务是否存在优先级概念?
-
什么进程或者线程来执行这个循环?
-
无限循环有没有终点?
这一流程包含 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');
// ********输出结果如下:********
2
poll callback
1
setImmediate 1
read file success
在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:
- setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms;
- 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数poll 队列中;
- 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate。
因此这也验证了这句话,先执行回调函数,再执行 setImmediate。
(6)close callbacks:执行一些关闭的回调函数,如 socket.on('close', ...)。
以上就是循环原理的 6 个过程,针对上面的点,我们再来解答上面提出的 5 个疑问。
运行起点
从图 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 所示。
图 2 事件循环过程
在解释上图之前,我们先来解释下两个概念,微任务和宏任务。
微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise 也存在优先级,process.nextTick 高于 Promise。
宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列。这也解释了我们前面提到的第 3 个问题,事件循环中的事件类型是存在优先级。
在图 2 的左侧,我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。
- 同步代码。
- 将异步任务插入到微任务队列或者宏任务队列中。
- 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
如果微任务和宏任务都只有一层时,那么看起来是比较简单的,比如下面的例子:
const fs = require('fs');
// 首次事件循环执行
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
nextTick callback
Promise callback
setTimeout
read file success
根据上面介绍的执行过程,我们来分析下上面代码的执行过程:
- 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 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。
但是当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:
const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
console.log('1');
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
if (err) throw err;
console.log('read file sync success');
});
}, 0);
/// 回调将会在新的事件循环之前
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');
// ******** 输出 ********
2
poll callback
1
read file success
read file sync success
在上面代码中,有 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。
Tips: 注意前几个案例setTimeout的时间都是0(实际执行的时候是1 ms),如果换成1000ms的话,执行结果输出顺序就会和readFile调换了。
在上面的例子中,我们来思考一个问题,主线程是否会被阻塞,具体我们来看一个代码例子:
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;
}
}
}
// ******** 输出 ********
2
1
sleep 10s
read file success
我们在 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 个问题,主要还是主线程来循环遍历当前事件。
02 | 应用场景:Node.js 作为后台可以提供哪些服务?
服务分类
我们常听说的服务有 RESTful 和 RPC,但这都是架构设计规范。我们也可以从另外一个角度来分析后台服务,如图 1 所示。
图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 来提供服务。
03 | 如何构建一个简单的 RESTful 服务?
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 层。以上是相关知识点,接下来我们进行架构的实践设计。
进阶实现
没有架构模式虽然也能按照需求满足接口要求,但是代码是不可维护的。而 MVC 已经被实践证明是非常好的架构模式,但是在现阶段也存在一些问题,接下来我们就逐步进行优化,让我们的架构和代码更加优秀。
MVC
既然是 M 和 C,我们就先思考下,上面的 restful server 中哪些是 M 层的逻辑,哪些是 C 层的逻辑。
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 层不明确的问题。接下来我们来实践这部分代码:
04 | 3 大主流系统框架:由浅入深分析 Express、Koa 和 Egg.js
Node.js 框架有Express、KOA 和 Egg.js,其次是另外一个正在兴起的与 TypeScript 相关的框架——Nest.js。
洋葱模型
洋葱我们都知道,一层包裹着一层,层层递进,但是现在不是看其立体的结构,而是需要将洋葱切开来,从切开的平面来看,如图 1 所示。
图 1 洋葱切面图
可以看到进入时穿入了多少层表皮,出去时就必须穿出多少层表皮。先穿入表皮,后穿出表皮,符合我们所说的栈列表,先进后出的原则。
然后再回到 Node.js 框架,洋葱的表皮我们可以思考为中间件:
-
从外向内的过程是一个关键词 next();
-
而从内向外则是每个中间件执行完毕后,进入下一层中间件,一直到最后一层。
Express、Koa都是洋葱模型,但是两者又有不同,我们下面描述,先以 Express为例看下洋葱模型:
// 输出如下:
Example app listening on port 3000!
first
second
third
third end
second end
first end
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');
});
接下来,我们启动服务,并打开浏览器访问如下地址:
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
可以看出,从内向外的是正常的,一层层往里进行调用,从外向内时则发生了一些变化,最主要的原因是异步中间件并没有按照顺序输出执行结果,这就是Express和Koa的主要区别。
下面我们来看下Koa,和 express 代码基本没有什么差异,只是将中间件中的 res、req 参数替换为 ctx ,修改完成以后,我们需要启动服务没结果如下:
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 是未来的一个趋势,然后 Egg.js 是目前 KOA 的最佳实践,因此在一些企业级应用后台服务时,可以使用 Egg.js 框架,如果你需要做一些高性能、高定制化的框架也可以在 KOA 基础上扩展出新的框架。
原理实现
Express
Express 涉及 app 函数、中间件、Router 和视图四个核心部分,这里我们只介绍 app 函数、中间件和 Router 原理,因为视图在后台服务中不是特别关键的部分。
我们先来看一个图,图 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()。