Nodejs 应用(1)

130 阅读36分钟

笔记整理来自拉钩教育

01 事件循环

Node.js 事件循环

事件循环通俗来说就是一个无限的 while 循环。现在假设你对这个 while 循环什么都不了解,你一定会有以下疑问。

  1. 谁来启动这个循环过程,循环条件是什么?
  2. 循环的是什么任务呢?
  3. 循环的任务是否存在优先级概念?
  4. 什么进程或者线程来执行这个循环?
  5. 无限循环有没有终点?

带着这些问题,我们先来看看 Node.js 官网提供的事件循环原理图。

Node.js 循环原理

image.png

这一流程包含 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 所示。

image.png

在解释上图之前,我们先来解释下两个概念,微任务和宏任务。

微任务:在 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');

根据上面介绍的执行过程,我们来分析下上面代码的执行过程:

  1. 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
  2. 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;
  3. 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;
  4. 再执行宏任务队列,根据宏任务插入先后顺序执行 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。

  1. 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
  2. 接下来执行微任务,输出 poll callback。
  3. 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列。
  4. 最后由于只剩下宏任务了 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 所示。

image.png

以上分类并不能代表所有的服务,但是各个系统都或多或少包含这些服务。有些大型系统可能会比这复杂;有些小型系统可能没有这么多模块系统。

下面我们看下每个模块主要的工作是什么:

  • 网关,处理请求转发和一些通用的逻辑,例如我们常见的 Nginx;

  • 业务网关,处理业务相关的逻辑,比如一些通用的协议转化、通用的鉴权处理,以及其他统一的业务安全处理等;

  • 运营系统,负责我们日常的运营活动或者运营系统;

  • 业务系统,负责我们核心的业务功能的系统;

  • 中台服务,负责一些通用 App 类的服务,比如配置下发、消息系统及用户反馈系统等;

  • 各类基础层,这些就是比较单一的核心后台服务,例如用户模块,这就需要根据不同业务设计不同的核心底层服务;

  • 左侧的数据缓存和数据存储,则是相应的数据类的服务。

在这些分层中,我们需要寻找网络 I/O 较多,但是 CPU 计算较少、业务复杂度高的服务,基于这点我们可以分析出 Node.js 应用在业务网关、中台服务及运营系统几个方面。接下来我们就分别从系统的业务场景及系统特性来分析为什么 Node.js 更合适。

业务网关

我们都了解 Nginx 作为负载均衡转发层,负责负载分发,那么业务网关又是什么呢?

可以这样考虑,比如我们后台管理系统有鉴权模块,以往都是在管理后台服务中增加一个鉴权的类,然后在统一路由处增加鉴权判断。而现在不仅仅是这个管理系统需要使用这个鉴权类,多个管理系统都需要这个鉴权类,这时你会考虑复制这个类到其他项目,又或者设计一个专门的服务来做鉴权,图 2 是一个转变的过程效果图。

image.png

图 2 业务网关的作用对比效果图

从上图我们可以看到,其实每个项目的鉴权都是相似的,没有必要在每个项目中维护一份通用的鉴权服务。因此可以提炼一层叫作业务网关,专门处理业务相关的通用逻辑,包括鉴权模块。

接下来我们就从一个实际的例子 OPEN API 的业务网关来介绍下这类服务场景。

业务场景

OPEN API 一般会有一个统一的 token 鉴权,通过 token 鉴权后还需要判断第三方的 appid 是否有接口权限,其次判断接口是否到达了请求频率上限。为了服务安全,我们也可以做一些降级处理,在服务过载时,可以根据优先级抛弃一些请求,具体可以查看图 3。

image.png

接下来我们从技术层面来看为什么 Node.js 更适合此类应用场景。

服务特性

根据图 2 的场景应用,我们专注看下 Nginx 后面的业务网关处理层,它的业务场景如图 4 所示。

image.png

这 3 个功能都是基于缓存来处理业务逻辑的,大部分都是网络 I/O ,并未涉及 CPU 密集型逻辑,这也是 Node.js 的优势,其次异步驱动的方案能够处理更高的并发。根据第 01 讲的内容,Node.js 的代码核心是不阻塞主线程处理,而这类业务网关都是轻 CPU 运算服务。因此在这类场景的技术选型中,可以考虑使用 Node.js 作为服务端语言。

中台服务

在 Web 或者 App 应用中都存在一些通用服务,以往都是独立接口、独立开发。随着公司应用越来越多,需要将一些通用的业务服务进行集中,这也是中台的概念。而这部分业务场景往往也是网络 I/O 高、并发较大、业务关联性高、数据库读写压力相对较小。下面我们就来分析下这种业务场景。

业务场景

为了避免资源浪费、人力浪费,我们可以使用如图 5 所示的中台服务系统:

image.png

  • 前端配置系统是在服务端根据客户端的版本、设备、地区和语言,下发不同的配置(JSON或者文件包);

  • 反馈系统,即用户可以在任何平台,调用反馈接口,并将反馈内容写入队列,并落地到系统中进行综合分析;

  • 推送系统用于管理消息的推送、用户红点和消息数的拉取,以及消息列表的管理;

  • 系统工具用于处理用户端日志捞取、用户端信息调试上报、性能定位问题分析提取等。

以上是多个中台系统的业务说明,我们再来具体看看每个系统的特性,从特性来分析为什么 Node.js 适合作为服务端语言。

服务特性

在中台系统的设计中,系统着重关注:网络 I/O、并发、通用性及业务复杂度,一般情况下不涉及复杂的 CPU 运算。这里我们以上面列举的系统来做分析,如表 1 所示。

image.png

在上述系统对比中,可以分析出 Node.js 作为中台服务,要求是:

  • 通用性必须好;

  • 低 CPU 计算;

  • 网络 I/O 高或者低都行;

  • 并发高或者低都行。

因为这样的服务在 Node.js 主线程中,可以快速处理各类业务场景,不会存在阻塞的情况,因此这类场景也适合使用 Node.js 作为服务端语言。

其他相关

运营系统

在各类互联网项目中,经常用运营活动来做项目推广,而这类运营系统往往逻辑复杂,同时需要根据业务场景进行多次迭代、不断优化。往往这些活动并发很高,但是可以不涉及底层数据库的读写,而更多的是缓存数据的处理。比如我们常见的一些投票活动、排行榜活动等,如图 6 所示。

image.png

运营系统这块我们会在《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 所示。

image.png

此模式中:

  • M(Model)层处理数据库相关的操作(只有数据库操作时);

  • C(Controller)层处理业务逻辑;

  • V(View)层则是页面显示和交互(本讲不涉及)。

但是在目前服务划分较细的情况下,M 层不仅仅是数据库操作,因此这种架构模式显得有些力不从心,导致开发的数据以及业务逻辑有时候在 M 层,有时候却在 C 层。出现这类情况的核心原因是 C 与 C 之间无法进行复用,如果需要复用则需要放到 M 层,那么业务逻辑就会冗余在 M,代码会显得非常繁杂,如图 2 所示。

image.png

图 2 MVC 模式问题

为了解决以上问题,在经过一些实践后,我在研发过程中提出了一套新的架构模式,当然也有他人提到过(比如 Eggjs 框架中的模式)。这种模式也会应用在本专栏的整个架构体系中,我们暂且叫作 MSVC(Model、Service、View、Controller)。

我们先来看下 MSVC 的架构模式,如图 3 所示。

image.png

将所有数据相关的操作都集中于 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 所示。

image.png

图 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 serverrestful 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 上的):

image.png

图 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 所示。

image.png

图 6 API server 返回信息

接下来我们访问如下地址,并且打开 chrome 的控制台的 network 状态栏。

http://127.0.0.1:5000/v1/test

你将会看到返回的状态码是 404,如图 7 所示,这也是 restful 的规范之一,即正确地使用 http 状态码。

image.png

图 7 异常响应返回

接下来我们请求 restful server 的 API,同样使用浏览器打开如下接口地址:

http://127.0.0.1:4000/v1/contents

你将会看到如图 8 所示的响应结果。

image.png

图 8 contents 响应结果

以上就实现了一个简单 restful 服务的功能,你可以看到代码都堆积在 index.js 中,并且代码逻辑还比较简单,如果稍微复杂一些,这种架构模式根本没法进行团队合作,或者后期维护,因此就需要 MVC 和 MVCS 架构模式来优化这种场景。

接下来我们先来看看使用 MVC 来优化。

进阶实现

没有架构模式虽然也能按照需求满足接口要求,但是代码是不可维护的。而 MVC 已经被实践证明是非常好的架构模式,但是在现阶段也存在一些问题,接下来我们就逐步进行优化,让我们的架构和代码更加优秀。

MVC

既然是 M 和 C,我们就先思考下,上面的 restful server 中哪些是 M 层的逻辑,哪些是 C 层的逻辑。

image.png

以上是所有的逻辑,根据表格,我们首先创建两个目录分别是 modelController:

  • 在 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 所示。

image.png

图 9 index 核心逻辑

  • 第一个红色框内的部分,判断的是路由是否在配置内,不存在则返回 404;

  • 第二个红色框内的部分,加载对应的 Controller 模块;

  • 第三个红色框内的部分,表示判断所调用的方法类型是异步还是同步,如果是异步使用 await 来获取执行结果,如果是同步则直接调用获取返回结果。

注意:这里使用 try catch 的目的是确保调用安全,避免 crash 问题。

接下来我们实现一个 Controller,为了合理性,我们先实现一个基类,然后让每个 Controller 继承这个基类:

  • 在项目根目录下我们创建一个 core 文件夹,并创建一个 Controller.js 作为基类;

  • 然后我们把一些相同的功能放入这个基类,比如 res 和 req 的赋值,以及通用返回处理,还有 url 参数解析等。

我们来看下这部分代码,如图 10 所示。

image.png

图 10 Controller 基类

功能还是比较简单的,只是提炼了一些 Controller 共同的部分。接下来我们再来实现 content.js 这个 Controller,代码如图 11 所示:

image.png

图 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 所示。

image.png

图 12 Model 基类

这个基类首先设置了 db 名称,其次定义了一个 GET 方法来获取表的操作句柄,这部分代码与上面简单 restful 服务的类似。完成基类后,我们再来完善 model 中的 content.js 逻辑。

image.png

图 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 所示。

image.png

图 14 MVC 复用性问题例子

其中左边是存在的矛盾,因为 _filterUserinfo 在 Controller 是私有方法,recommend Controller 调用不到,那么为了复用,我们只能将该方法封装到 content-model 中,并且将数据也集中在 Model 层去。

虽然解决了问题,但是你会发现:

  • Model 层不干净了,它现在既要负责数据处理,又要负责业务逻辑;

  • Controller 层的业务减少了,但是分层不明确了,有些业务放在 Model,有些又在 Controller 层,对于后期代码的维护或者扩展都非常困难了。

为了解决这个问题,有一个新的概念——Service 层,具体如图 15 所示。

image.png

图 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。

image.png 本讲最开始介绍了一些技术知识点,这些是你开始学习本专栏必需巩固的技术,接下来根据实践开发了一个微型的 restful 服务,由于代码的不可维护性以及不可扩展性,我们接下来就应用了 MVC 架构设计模式进行了优化,最后由于 MVC 的缺陷,进而提出了使用 MSVC 来解决 MVC 中 M 和 C 业务界定不清晰的问题。

学完本讲后,你就能自己写一个 restful API 了,并且能够掌握 MVC 和 MSVC 的架构原理,同时能够开发出轻量版本的框架。在实践过程中有任何问题或者心得,都可以在留言区留言。

讲解完我们自身设计的简版框架后,在下一讲要介绍 Node.js 目前业界使用最广的三个框架,并且进行深入对比分析其优缺点。

04 | 3 大主流系统框架:由浅入深分析 Express、Koa 和 Egg.js

上一讲我们没有应用任何框架实现了一个简单后台服务,以及一个简单版本的 MSVC 框架。本讲将介绍一些目前主流框架的设计思想,同时介绍其核心代码部分的实现,为后续使用框架优化我们上一讲实现的 MSVC 框架做一定的准备。

主流框架介绍

目前比较流行的 Node.js 框架有ExpressKOAEgg.js,其次是另外一个正在兴起的与 TypeScript 相关的框架——Nest.js,接下来我们分析三个主流框架之间的关系。

在介绍框架之前,我们先了解一个非常重要的概念——洋葱模型,这是一个在 Node.js 中比较重要的面试考点,掌握这个概念,当前各种框架的原理学习都会驾轻就熟。无论是哪个 Node.js 框架,都是基于中间件来实现的,而中间件(可以理解为一个类或者函数模块)的执行方式就需要依据洋葱模型来介绍。Express 和 KOA 之间的区别也在于洋葱模型的执行方式上。

洋葱模型

洋葱我们都知道,一层包裹着一层,层层递进,但是现在不是看其立体的结构,而是需要将洋葱切开来,从切开的平面来看,如图 1 所示。

image.png

图 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 是最内层

image.png

接下来我们运行如下命令,启动项目。

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 核心代码结构部分:

image.png

图 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 所示:

image.png

图 3 Express app.use 代码实现

当没有传入 path 时,会默认设置 path 为 / ,而 / 则是匹配任何路径,最终都是调用 router.use 将 fn 中间件函数传入到 router 中。

接下来我们看下 router.use 的代码实现。

router/index.js

这个文件在当前目录 router 下的 index.js 中,有一个方法叫作 proto.use,即 application.js 中调用的 router.use 。

image.png

图 4 中间件 push 实现

图 4 中的代码经过一系列处理,最终将中间件函数通过 Layer 封装后放到栈列表中。就完成了中间件的处理,最后我们再来看下用户请求时,是如何在栈列表执行的。

所有请求进来后都会调用 application.js 中的 app.handle 方法,该方法最终调用的是 router/index.js 中的 proto.handle 方法,所以我们主要看下 router.handle 的实现。在这个函数中有一个 next 方法非常关键,用于判断执行下一层中间件的逻辑,它的原理是从栈列表中取出一个 layer 对象,判断是否满足当前匹配,如果满足则执行该中间件函数,如图 5 所示。

image.png

图 5 中间件执行逻辑

接下来我们再看看 layer.handle_request 的代码逻辑,如图 6 所示。

image.png

图 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 的方案所示:

image.png 这一方案有 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 所示的结果,代表运行成功了。

image.png 图 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 所示。

image.png 图 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 模式存在的性能问题。