Nodejs应用实战(一)——事件循环、MSVC、Express VS Koa等框架介绍

吐槽君 分类:javascript

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 循环。

  1. 谁来启动这个循环过程,循环条件是什么?

  2. 循环的是什么任务呢?

  3. 循环的任务是否存在优先级概念?

  4. 什么进程或者线程来执行这个循环?

  5. 无限循环有没有终点?

image (1).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');

// ********输出结果如下:********
    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 所示。

image (2).png

图 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
 

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

  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。

但是当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:

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。

  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。

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

Drawing 0.png

图1 后台服务分类

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

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

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

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

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

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

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

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

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

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

业务网关

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

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

Drawing 1.png

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

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

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

业务场景

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

Drawing 3.png

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

服务特性

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

Drawing 5.png

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

中台服务

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

业务场景

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

Drawing 7.png

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

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

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

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

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

服务特性

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

Drawing 8.png

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

  • 通用性必须好;

  • -低 CPU 计算;

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

  • 并发高或者低都行。

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

其他相关

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

Drawing 10.png

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

图片 2.png

此模式中:

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

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

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

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

图片 4.png

图 2 MVC 模式问题

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

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

图片 6.png

将所有数据相关的操作都集中于 M 层,而 M 层复用的业务逻辑则转到新的 S 层,C 层则负责核心业务处理,可以调用 M 和 S 层。以上是相关知识点,接下来我们进行架构的实践设计。

进阶实现

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

MVC

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

图片 12.png

MVCS

在上面的代码中存在一个问题,就是 _filterUserinfo 是放在 Controller 来处理,这个方法又会涉及调用 API server 的逻辑,看起来也是数据处理部分,从原理上说这部分不适合放在 Controller。其次在其他 Controller 也需要 _filterUserinfo 时,这时候就比较懵逼了,比如我们现在有另外一个 Controller 叫作 recommend.js,这里面也是拉取推荐的 content,也需要这个 _filterUserinfo 方法,如图 14 所示。

图片 18.png

图 14 MVC 复用性问题例子

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

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

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

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

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

图片 19.png

图 15 MSVC 优化效果

图中的浅红色框内,就是新架构模式的 M 层;

两个绿色框内为 C 层;

最上面的浅蓝色框则为 Service 层。

这样就可以复用 _filterUserinfo,并解决 M 与 C 层不明确的问题。接下来我们来实践这部分代码:

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

Node.js 框架有Express、KOA 和 Egg.js,其次是另外一个正在兴起的与 TypeScript 相关的框架——Nest.js。

洋葱模型

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

Drawing 0.png

图 1 洋葱切面图

可以看到进入时穿入了多少层表皮,出去时就必须穿出多少层表皮。先穿入表皮,后穿出表皮,符合我们所说的栈列表,先进后出的原则。

然后再回到 Node.js 框架,洋葱的表皮我们可以思考为中间件:

  • 从外向内的过程是一个关键词 next();

  • 而从内向外则是每个中间件执行完毕后,进入下一层中间件,一直到最后一层。

Express、Koa都是洋葱模型,但是两者又有不同,我们下面描述,先以 Express为例看下洋葱模型:

Drawing 1.png

//  输出如下:
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 核心代码结构部分:

Drawing 2.png

它涉及的源码不多,其中:

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

Drawing 3.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 。

Drawing 4.png

图 4 中间件 push 实现

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

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

Drawing 5.png

图 5 中间件执行逻辑

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

Drawing 6.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()

回复

我来回复
  • 暂无回复内容