IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    重读《深入浅出 Node.js》 · 看不见我的美 · 是你瞎了眼

    馬腊咯稽发表于 2020-08-02 00:00:00
    love 0
    重读《深入浅出 Node.js》

    Node 简介

    Node 的结构与 Chrome 十分相似,它们都是基于事件驱动的异步架构,浏览器通过事件驱动来服务界面上的交互,Node 通过事件驱动来服务 I/O;作为后端 JS 的运行平台,Node 没有改写语言本身的任何特性,它将前端中广泛运用的思想迁移到了服务器端。

    Node 特点:异步输入输出、回调函数、单线程、基于 libuv 实现跨平台。

    模块化

    Node 中模块分为两类:核心模块和文件模块;核心模块在 Node 启动时被直接加载进内存中,文件模块则是在运行时动态加载。Node 对引入过的模块都会进行缓存,同浏览器缓存文件一样,不同的是 Node 缓存的是编译和执行之后的对象。

    Node 在对 JS 模块编译的过程中对文件内容进行了头尾包装,这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过原生模块的 runInThisContext 方法执行,返回一个 function 对象。

    1
    2
    3
    4
    5
    6
    
    (function (exports, require, module, __filename, __dirname) {
     var math = require('math');
     exports.area = function (radius) {
     return Math.PI * radius * radius;
     };
    });
    

    Node 对 C/C++模块调用 process.dlopen 进行加载和执行,dlopen 方法在 Windows 和 *nix 平台下分别有不同的实现,通过 libuv 兼容层进行了封装。

    Node 在对 JSON 文件编译时,利用 fs 模块同步读取 JSON 文件的内容之后,调用 JSON.parse 得到对象,然后将它赋给模块对象的 exports,以供外部调用。

    Node 最初采用的是 CommonJS 规范,从 v13.2 版本开始,Node 默认支持 ES6 模块;ES6 的模块规范和 CommonJS 规范的差别在于:

    1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用;
    2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口;
    3. CommonJS 模块的顶层 this 指向当前模块,ES6 模块中,顶层的 this 指向 undefined;
    4. ES6 模块中不存在:arguments、require、module、exports、__filename、__dirname。

    CommonJS 加载的是一个对象,该对象只有在脚本运行完才会生成;而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成,Webpack 的 TreeShaking 就利用了 ES6 模块可以静态分析的特点。

    Node 要求 ES6 模块采用 .mjs 文件扩展名;Node 遇到 .mjs 文件,就认为它是 ES6 模块,默认启用严格模式;如果不希望将后缀名改成 .mjs,可以在项目的 package.json 文件中,指定 type 字段为 module;如果这时还要使用 CommonJS 模块,那么需要将 CommonJS 脚本的后缀名都改成 .cjs。

    总之,.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。

    异步 I/O

    Node 利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,以更好地使用 CPU。为了弥补单线程无法利用多核 CPU 的缺点,Node 提供了子进程,子进程可以通过工作进程高效地利用 CPU。

    操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞;阻塞 I/O 的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。非阻塞 I/O 则不带数据直接返回,返回之后,CPU 的时间片可以用来处理其他事务,性能提升是明显的;但由于完整的 I/O 并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态;为了获取完整的数据,应用程序需要重复调用 I/O 操作来确认是否完成。

    由于 Windows 平台和 *nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,并保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 *nix 目录或是 Win 目录下的源文件到目标程序中。

    事件循环、观察者、请求对象、I/O 线程池这四者共同构成了异步 I/O 模型的基本要素。事实上,在 Node 中,除了 JS 是单线程外,Node 自身其实是多线程的;除了用户代码无法并行执行外,所有的 I/O(磁盘 I/O 和网络 I/O 等)则是可以并行的。

    事件循环

    事件循环对观察者的检查是有先后顺序的,process.nextTick 属于 idle 观察者,setImmediate 属于 check 观察者。在每一个轮循环检查中,idle 观察者先于 I/O 观察者,I/O 观察者先于 check 观察者。

    process.nextTick 的回调函数保存在一个数组中,setImmediate 的结果则是保存在链表中;在行为上,process.nextTick 在每轮循环中会将数组中的回调函数全部执行完,而 setImmediate 在每轮循环中执行链表中的一个回调函数。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    console.log('start');
    setTimeout(function () {
     console.log('setTimeout one');
    }, 0);
    process.nextTick(function () {
     console.log('process.nextTick one');
    });
    setImmediate(function () {
     console.log('setImmediate one');
     process.nextTick(function () {
     console.log('process.nextTick in setImmediate');
     });
    });
    setTimeout(function () {
     console.log('setTimeout two');
    }, 0);
    process.nextTick(function () {
     console.log('process.nextTick two');
    });
    setImmediate(function () {
     console.log('setImmediate two');
    });
    console.log('end');
    /**
     * start
     * end
     * process.nextTick one
     * process.nextTick two
     * setTimeout one
     * setTimeout two
     * setImmediate one
     * process.nextTick in setImmediate
     * setImmediate two
     */
    

    垃圾回收

    调用 process.memoryUsage() 可以看到 Node 进程的内存占用情况,每个属性值的单位为字节:

    1
    2
    3
    4
    5
    6
    
    {
     rss: 27152384, // 常驻内存,为进程分配了多少物理内存,包含所有的 C++ 和 JS 对象与代码
     heapTotal: 5959680, // 堆中总共『申请』的内存量
     heapUsed: 3728776, // 堆中『使用中』的内存量
     external: 1393760 // V8 管理的、绑定到 JS 的 C++ 对象的内存量
    }
    

    当在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过 V8 的限制为止。V8 限制堆的大小的原因是 V8 的垃圾回收机制的限制;V8 做一次小的垃圾回收需要 50 毫秒以上,做一次非增量式的垃圾回收甚至要 1 秒以上;这是垃圾回收中引起 JS 线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降;因此,在当时的考虑下直接限制堆内存是一个好的选择。

    V8 提供了选项让我们使用更多的内存;Node 在启动时可以传递 max_old_space_size 或 max_new_space_size 来调整内存限制的大小:

    1
    2
    3
    4
    
    // 设置老生代内存空间的最大值,单位为 MB
    node --max_old_space_size=2048 test.js
    // 设置新生代内存空间的最大值,单位为 KB
    node --max_new_space_size=1024 test.js
    

    V8 的垃圾回收策略主要基于分代式垃圾回收机制;按照对象的存活时间将内存进行不同的分代,然后分别对不同分代的内存施以不同的算法。

    在 V8 中,主要将内存分为新生代和老生代两代,新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。V8 使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值时,就会引起进程出错。

    对于新生代内存,主要通过 Scavenge 算法进行垃圾回收;将堆内存一分为二,在这两个 semispace 空间中,只有一个处于使用中(From 空间),另一个处于闲置状态(To 空间)。当分配对象时,先是在 From 中进行分配;当开始进行垃圾回收时,会检查 From 中的存活对象,存活对象将被复制到 To 中,而非存活对象占用的空间将会被释放;完成复制后,From 和 To 的角色发生对换。简而言之,就是将存活对象在两个 semispace 空间之间进行复制。

    Scavenge 的缺点是只能使用堆内存中的一半,但在时间效率上有优异的表现。

    当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象;这种对象随后会被移动到老生代中,采用新的算法进行管理;对象从新生代中移动到老生代中的过程称为晋升。对象晋升的条件主要有两个:一个是对象是否经历过 Scavenge 回收,一个是 To 空间的内存占用比超过限制。

    对于老生代中的对象,采用标记清除算法;在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。Scavenge 中只复制活着的对象,而 Mark-Sweep 只清理死亡对象;活对象在新生代中只占较小部分,死对象在老生代中只占较小部分。

    Mark-Sweep 最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态;这种内存碎片会对后续的内存分配造成问题,为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 被提出来。标记整理是在 Mark-Sweep 的基础上演变而来的,它们的差别在于对象在标记为死亡后,在整理的过程中将活着的对象往一端移动,移动完成后直接清理掉边界外的内存。

    由于 Mark-Compact 需要移动对象,它的执行速度不可能很快,所以在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用 Mark-Compact。

    在 V8 的回收策略中两者是结合使用的,目前介绍到的 3 种主要垃圾回收算法的简单对比:

    垃圾回收的 3 种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”;由于新生代内存默认配置得较小,且其中存活对象通常较少,所以即便全停顿影响也不大;老生代内存通常配置得较大,且存活对象较多,垃圾回收的标记、清理、整理等动作造成的停顿就会比较可怕;为此,V8 从标记阶段入手,引入增量标记(从行为上看很像 React Fiber),将原本要一口气停顿完成的动作是拆分为许多小“步进”,垃圾回收与应用逻辑交替执行直到标记阶段完成。

    内存指标

    os.totalmem() 和 os.freemem() 这两个方法用于查看操作系统的内存使用情况,它们分别返回系统的总内存和闲置内存,单位为字节:

    1
    2
    3
    4
    
    > os.totalmem()
    17179869184
    > os.freemem()
    1706803200
    

    通过 process.momoryUsage() 的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量;这意味着 Node 中的内存使用并非都是通过 V8 进行分配的;那些不是通过 V8 分配的内存称为堆外内存。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    
    var total = [];
    for (var j = 0; j < 15; j++) {
     showMem();
     total.push(useMem());
    }
    showMem();
    function showMem() {
     var mem = process.memoryUsage();
     var format = function (bytes) {
     return (bytes / 1024 / 1024).toFixed(2) + ' MB';
     };
     console.log(
     'Process: heapTotal ' +
     format(mem.heapTotal) +
     ' heapUsed ' +
     format(mem.heapUsed) +
     ' rss ' +
     format(mem.rss)
     );
     console.log('---------------------------------------');
    }
    function useMem() {
     const size = 200 * 1024 * 1024;
     const buffer = new Buffer.alloc(size);
     for (var i = 0; i < size; i++) {
     buffer[i] = 0;
     }
     return buffer;
    }
    

    在 node 中运行以上代码,输出以下结果:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    Process: heapTotal 4.05 MB heapUsed 2.06 MB rss 21.72 MB
    ---------------------------------------
    Process: heapTotal 5.48 MB heapUsed 2.72 MB rss 225.77 MB
    ---------------------------------------
    Process: heapTotal 5.48 MB heapUsed 2.73 MB rss 425.98 MB
    ---------------------------------------
    ...
    ...
    ...
    Process: heapTotal 4.48 MB heapUsed 2.03 MB rss 2624.34 MB
    ---------------------------------------
    Process: heapTotal 4.48 MB heapUsed 2.03 MB rss 2824.34 MB
    ---------------------------------------
    Process: heapTotal 4.48 MB heapUsed 1.95 MB rss 3024.34 MB
    ---------------------------------------
    

    heapTotal 与 heapUsed 的变化极小,rss 的值已经超过 V8 的限制值;原因是 Buffer 对象不同于其他对象,它不经过 V8 的内存分配机制,不会有堆内存的大小限制;这意味着利用堆外内存可以突破内存限制的问题。

    内存泄漏

    造成内存泄漏的原因有:

    • 缓存;
    • 队列消费不及时;
    • 作用域未释放。

    缓存的访问效率要比 I/O 的效率高,一旦命中缓存,就可以节省一次 I/O 的时间;一旦一个对象被当做缓存来使用,那它将会常驻在老生代内存中;这将影响垃圾回收的效率。对象不同于严格意义的缓存,缓存有着完善的过期策略,而普通对象并没有;所以在 Node 中,任何试图拿内存当缓存的行为都应当被限制。

    目前比较好的解决方案是采用进程外的缓存(例如:Redis),进程自身不存储状态;外部的缓存有着良好的缓存过期淘汰策略以及内存管理,不影响 Node 进程的性能:

    • 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效;
    • 进程之间可以共享缓存。

    EventEmitter

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    const EventEmitter = require('events').EventEmitter;
    const AudioDevice = {
     play: function (track) {
     console.log('play', track);
     },
     stop: function () {
     console.log('stop');
     }
    };
    class MusicPlayer extends EventEmitter {
     constructor() {
     super();
     this.playing = false;
     }
    }
    const musicPlayer = new MusicPlayer();
    musicPlayer.on('play', function (track) {
     this.playing = true;
     AudioDevice.play(track);
    });
    musicPlayer.on('stop', function () {
     this.playing = false;
     AudioDevice.stop();
    });
    /**
     * EventEmitter 实例发生错误会发出一个 error 事件
     * 如果没有监听器,默认动作是打印一个堆栈并退出程序
     */
    musicPlayer.on('error', function (err) {
     console.error('Error:', err);
    });
    musicPlayer.emit('play', 'The Roots - The Fire');
    setTimeout(function () {
     musicPlayer.emit('stop');
    }, 1000);
    

    Buffer

    Buffer 是一个类数组对象,它的元素为 16 进制的两位数,即 0 到 255 的数值,主要用于操作字节;它将性能相关的部分用 C++ 实现(node_buffer),将非性能相关的部分用 JS 实现(Buffer/SlowBuffer)。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    var str = '深入浅出 node.js';
    var buf = Buffer.from(str, 'utf-8');
    console.log(buf); // <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 20 6e 6f 64 65 2e 6a 73>
    console.log(buf.length); // 20
    // 如果是小数,舍弃小数部分,只保留整数部分
    buf[0] = 3.1415;
    console.log(buf[0]); // 3
    // 赋值如果小于 0,就将该值逐次加 256,直到得到一个 0 到 255 之间的整数
    buf[1] = -1;
    console.log(buf[1]); // 255
    // 如果得到的数值大于 255,就逐次减 256,直到得到一个 0 到 255 之间的整数
    buf[2] = 256;
    console.log(buf[2]); // 0
    

    Node 在内存的使用上应用的是在 C++ 层面『申请』内存、在 JS 中『分配』内存的策略;Node 采用动态内存管理机制(slab)高效地使用申请来的内存;slab 是一块申请好的固定大小的内存区域,具有如下 3 种状态:

    • full,完全分配状态;
    • partial,部分分配状态;
    • empty,没有被分配状态。

    Buffer 的内存分配是在 Node 的 C++ 层面实现的;Node 以 8KB(每个 slab 的大小)为界限来区分 Buffer 是大对象还是小对象。

    Buffer 对象可以与字符串之间相互转换,Node 当前支持 utf8、utf16le、latin1、base64、hex、ascii、binary(latin1 的别名)、ucs2(utf16le 的别名)字符编码。

    1
    2
    3
    4
    5
    6
    7
    8
    
    // 判断编码是否支持转换
    Buffer.isEncoding('GB2312'); // false
    // utf8 是默认编码
    const buf = Buffer.from('hello world', 'utf8');
    console.log(buf.toString('hex')); // 68656c6c6f20776f726c64
    console.log(buf.toString('base64')); // aGVsbG8gd29ybGQ=
    console.log(Buffer.from('fhqwhgads', 'utf8')); // <Buffer 66 68 71 77 68 67 61 64 73>
    console.log(Buffer.from('fhqwhgads', 'utf16le')); // <Buffer 66 00 68 00 71 00 77 00 68 00 67 00 61 00 64 00 73 00>
    

    Buffer 在使用场景中,通常是以一段一段的方式传输:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    var fs = require('fs');
    var rs = fs.createReadStream('ThinkInSilence.md');
    {
     // 设置可读流编码可以解决乱码问题;这里的 highWaterMark 是指每次读取的长度,其大小决定会触发系统调用和 data 事件的次数
     var rs = fs.createReadStream('ThinkInSilence.md', { highWaterMark: 11 });
     /**
     * 可读流在内部设置了一个 decoder 对象(来自 StringDecoder 的实例)
     * 每次 data 事件都通过该 decoder 对象进行 Buffer 到字符串的解码
     * data 不再收到原始的 Buffer 对象
     */
     rs.setEncoding('utf8');
    }
    var data = '';
    rs.on('data', function (chunk) {
     // 这里暗含了 toString 操作,一旦输入流中有宽字节编码时,就会出现乱码
     data += chunk;
    });
    rs.on('end', function () {
     // toString 方法默认以 UTF-8 为编码,中文字在 UTF-8 下占 3 个字节,Buffer 大小可能会造成乱码
     console.log(data); // 床前明���光,疑���地上霜;举头���明月,���头思故乡。
    });
    

    Buffer 正确的拼接方式并不是『+=』,而是使用 Buffer.concat(Array) 的方式:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    var chunks = [];
    var size = 0;
    res.on('data', function (chunk) {
     chunks.push(chunk);
     size += chunk.length;
    });
    res.on('end', function () {
     var buf = Buffer.concat(chunks, size);
     var str = iconv.decode(buf, 'utf8');
     console.log(str);
    });
    

    Cookie

    HTTP 是一个无状态的协议,Cookie 用来记录服务器与客户端之间的状态;Cookie 的处理分为如下几步:

    • 服务器向客户端发送 Cookie;
    • 浏览器将 Cookie 保存;
    • 每次发生请求都会将 Cookie 发向服务器。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // Cookie 格式是 key1=value2;key2=value2 形式的
    function parseCookie(cookie) {
     if (!cookie) {
     return;
     }
     const entries = cookie.split(';');
     const temp = {};
     entries.forEach(item => {
     const arr = item.split('=');
     const key = arr[0].trim();
     const value = arr[1] || '';
     temp[key] = value;
     });
     return temp;
    }
    

    Session

    Session 只保留在服务器端,数据的安全性得到一定的保障,数据也无须在每次请求中都被传递;如何将客户端和服务器中的 Session 对应呢:

    • 基于 Cookie 来实现用户和数据的映射;
    • 通过查询字符串来实现客户端和服务器数据的对应。

    一旦服务器端启用了 Session,它将约定一个键值作为 Session 口令;将 Session 口令放在 Cookie 中;如果用户请求 Cookie 中没有携带口令,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间;如果口令被篹改,客户端就丢失了与 Session 的映射关系,也无法修改服务器端存在的数据了。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    
    const EXPIRES = 20 * 60 * 1000;
    const sessions = {}; // 将所有 Session 保存在内存中,会有性能问题
    const key = 'session_id';
    // 生成 Session
    function generate() {
     const session = {};
     session.id = Date.now() + Math.random();
     session.cookie = {
     // 设置过期时间
     expire: Date.now() + EXPIRES
     };
     sessions[session.id] = session;
     return session;
    }
    // 处理请求,检查 Cookie 口令与服务端数据
    function handler(req, res) {
     let id = req.cookies[key];
     if (!id) {
     req.session = generate();
     } else {
     let session = sessions[id];
     if (!session) {
     req.session = generate();
     } else {
     if (session.cookie.expire > Date.now()) {
     session.cookie.expire = Date.now() + EXPIRES;
     req.session = session;
     } else {
     delete sessions[id];
     req.session = generate();
     }
     }
     }
     if (!req.session.isVisit) {
     res.session.isVisit = true;
     res.writeHead(200);
     res.end('欢迎『首次』访问');
     } else {
     res.writeHead(200);
     res.end('欢迎『再次』访问');
     }
    }
    // 响应请求时,设置 Session
    var writeHead = res.writeHead;
    res.writeHead = () => {
     let cookies = res.getHeader('Set-Cookie');
     let session = serialize('Set-Cookie', req.session.id);
     cookies = Array.isArray(cookies)
     ? cookies.concat(session)
     : [cookies, session];
     res.setHeader('Set-Cookie', cookies);
     return writeHead.apply(this, arguments);
    };
    

    文件上传

    通过报头的 Transfer-Encoding 或 Content-Length 可判断请求中是否带有内容:

    1
    2
    3
    
    function hasBody(req) {
     return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
    }
    

    报文内容部分会通过 data 事件触发,我们需要以流的方式处理:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    function handler(req, res) {
     if (hasBody(req)) {
     var bufs = [];
     req.on('data', chunk => {
     bufs.push(chunk);
     });
     req.on('end', () => {
     req.rawBody = Buffer.concat(bufs).toString();
     // ...
     });
     } else {
     // ...
     }
    }
    

    RESTful

    RESTful 设计哲学是将服务器端提供的内容实体看作一个资源,并表现在 URL 上;一个用户的地址『/users/jacksontian』代表了一个资源,对这个资源的操作,主要体现在 HTTP 请求方法上,不是体现在 URL 上:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    过去的设计:操作行为主要体现在行为上,主要使用的请求方法是 POST 和 GET
    POST /user/add?username=jacksontian 增
    GET /user/remove?username=jacksontian 删
    POST /user/update?username=jacksontian 改
    GET /user/get?username=jacksontian 查
    
    RESTful:通过 URL 设计资源、通过请求方法定义资源的操作、通过 Accept 决定资源的表现形式
    POST /user/jacksontian
    DELETE /user/jacksontian
    PUT /user/jacksontian
    GET /user/jacksontian
    

    举个例子:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    const express = require('express');
    const routes = require('./routes');
    const app = express();
    app.use(express.json());
    app.use(express.methodOverride());
    app.get('/pages', routes.pages.index);
    app.get('/pages/:id', routes.pages.show);
    app.post('/pages', routes.pages.create);
    app.patch('/pages/:id', routes.pages.patch);
    app.put('/pages/:id', routes.pages.update);
    app.del('/pages/:id', routes.pages.remove);
    modules.exports = app;
    

    中间件

    中间件的行为比较类似 Java 中的过滤器,在进入具体的业务处理之前,先让过滤器处理一些与业务无关的技术细节;由于 Node 异步的原因,我们需要提供一种机制,在当前中间件处理完成后,通知下一个中间件执行:

    中间件往往先于业务逻辑执行,为了让业务逻辑提早执行,中间件的编写和使用需要注意两点:

    • 编写高效的中间件:尽早调用 next 执行后续逻辑、缓存需要重复计算的结果、避免不必要的计算(比如『报文体解析』对于 GET 是不必要的);
    • 合理利用路由,避免不必要的中间件执行。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    function staticFile(req, res, next) {
     const pathname = url.parse(req.url).pathname;
     fs.readFile(path.join(ROOT, pathname), (err, file) => {
     if (err) {
     return next(err);
     }
     res.writeHead(200);
     res.end(file);
     });
    }
    functon xmlMiddleware(req,res,next){
     if(!req.is("xml")){
     return next()
     }
     let body = "";
     req.on("data",str=>{
     body+=str;
     })
     req.on("end",()=>{
     req.body = xml2json.toJson(body.toString(),{
     object:true,
     sanitize:false
     })
     next();
     })
    }
    // 对所有路径都会使用中间件
    app.use(staticFile);
    // 仅对静态目录使用中间件,提升匹配效率
    app.use('/public/xml', xmlMiddleware);
    

    响应客户端

    服务器端响应的报文,最终都要被客户端处理;服务器端的响应从一定程度上决定或指示了客户端该如何处理响应的内容;在内容响应的过程中,响应报头中的 Content-* 字段会告知客户端响应的内容信息:

    1
    2
    3
    4
    5
    6
    
    内容以 gzip 编码
    Content-Encoding: gzip
    内容长度为 21170 个字节
    Content-Length: 21170
    内容类型为 JS,字符集为 UTF-8
    Content-Type: text/javascript; charset=utf-8
    

    客户端在接收到这个报文后,正确的处理过程是通过 gzip 来解码报文体中的内容,用长度校验报文体内容是否正确,然后再以字符集 UTF-8 将解码后的脚本插入到文档节点中。

    浏览器通过不同的 Content-Type 的值来决定何种渲染方式,这个值我们简称为 MIME 值:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    // 纯文本
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('<html><body>Hello World</body></html>\n');
    // HTML
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<html><body>Hello World</body></html>\n');
    // 使用 mime 模块获知文件的 MIME 值
    var mime = require('mime');
    mime.lookup('/path/to/file.txt'); // => 'text/plain'
    mime.lookup('file.txt'); // => 'text/plain'
    mime.lookup('.TXT'); // => 'text/plain'
    mime.lookup('htm'); // => 'text/html'
    

    在附件下载场景下,无论响应内容是什么 MIME 值,需要客户端去下载它;为了满足这种需求,需要设置 Content-Disposition 字段,客户端会根据它的值判断是应该将报文数据解析还是下载;当内容只需即时查看时,它的值为 inline,当数据可以存为附件时,它的值为 attachment;Content-Disposition 字段还能通过参数指定保存时应该使用的文件名。

    1
    
    Content-Disposition: attachment; filename="filename.ext"
    

    响应附件下载:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    res.sendfile = filepath => {
     // 检查文件是否存在
     fs.stat(filepath, (err, stat) => {
     if (err) return;
     var stream = fs.createReadStream(filepath);
     // 设置内容
     res.setHeader('Content-Type', mime.lookup(filepath));
     // 设置大小
     res.setHeader('Content-Length', stat.size);
     // 设置为附件
     res.setHeader(
     'Content-Disposition',
     `attachment; filename=${path.basename(filepath)}`
     );
     res.writeHead(200);
     stream.pipe(res);
     });
    };
    

    响应 JSON:

    1
    2
    3
    4
    5
    
    res.json = json => {
     res.setHeader('Content-Type', 'application/json');
     res.writeHead(200);
     res.end(JSON.stringify(json));
    };
    

    响应跳转:

    1
    2
    3
    4
    5
    
    res.redirect = url => {
     res.setHeader('Location', url);
     res.writeHead(302);
     res.end(url);
    };
    

    响应 HTML:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    res.render = (view, data) => {
     res.setHeader('Content-Type', 'text/html');
     res.writeHead(200);
     // 通过模板引擎(Pug、Mustache...)将数据渲染为 HTML
     var html = render(view, data);
     res.end(html);
    };
    function render(view, data) {
     let template = view.replace(
     /<%=([\s\S]+?)%>/g,
     (match, code) => `obj.${code}`
     );
     template = `var tpl = ${template}\nreturn tpl;`;
     let complied = new Function('obj', template);
     return complied(data);
    }
    

    主从模式

    Node 决定在 V8 上构建时就不得不面对两个问题:如何充分利用多核 CPU?如何保证主进程的健壮性和稳定性?Node 的 child_process 模块提供了衍生子进程,可以实现对多个进程的控制。

    Master-Worker 主从模式:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    // master.js
    const fork = require('child_process').fork;
    const cpus = require('os').cpus();
    for (let i = 0; i < cpus.length; i++) {
     /**
     * 通过 fork 复制的进程都是一个独立的进程,有着独立而全新的 V8 实例
     * 这里启动多个进程只是为了充分将 CPU 资源利用起来,而不是为了解决并发问题
     */
     fork('./worker.js');
    }
    // worker.js
    const http = require('http');
    http
     .createServer((req, res) => {
     res.writeHead(200, {
     'Content-Type': 'text/plain'
     });
     res.end('i am worker');
     })
     .listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1');
    

    运行 node master.js 将会复制多个 Node 进程;主进程仅负责调度或管理工作进程,工作进程负责具体的业务处理。

    child_process 模块给予 Node 随意创建子进程的能力;它提供了 4 个方法用于创建子进程:

    • child_process.spawn 使用给定的命令衍生新的进程,并传入 args 中的命令行参数(默认为空数组);
    • child_process.fork 是 child_process.spawn 的特例,专门用于衍生新的进程;与 child_process.spawn 一样返回 ChildProcess 对象;返回的 ChildProcess 会内置额外的通信通道,允许消息在父进程和子进程之间来回传递;
    • child_process.exec 与 child_process.spawn 不同的是其接口不同,它有一个回调函数获知子进程的状况;
    • child_process.execFile 类似于 child_process.exec 但默认情况下不会衍生 shell;比 child_process.exec 稍微更高效。
    1
    2
    3
    4
    5
    
    var cp = require('child_process');
    cp.spawn('node', ['worker.js']);
    cp.fork('./worker.js');
    cp.exec('node worker.js', function (err, stdout, stderr) {});
    cp.execFile('worker.js', function (err, stdout, stderr) {});
    

    父子进程之间会创建 IPC 通道进行通信:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    // parent
    const cp = require('child_process');
    const sub = cp.fork(__dirname + '/sub.js');
    sub.on('message', m => {
     console.log('parent got msg from child: ' + m);
    });
    sub.send({ msg: 'hello son' });
    // child
    process.on('message', m => {
     console.log('child got msg from parent: ' + m);
    });
    process.send({ msg: 'hello papa' });
    

    IPC 的目的是为了让不同的进程能够互相访问资源并进行协调工作,Node 中 IPC 是由管道(pipe)技术实现的(libuv 提供);在 Windows 下由命名管道实现,在 *nix 下由 Unix Domain Socket 实现。

    父进程在实际创建子进程之前,会创建 IPC 通道并监听它,然后才真正创建出子进程,并通过环境变量 NODE_CHANNEL_FD 告诉子进程这个 IPC 通道的文件描述符;子进程在启动的过程中,根据文件描述符去连接这个已存在的 IPC 通道,从而完成父子进程之间的连接。

    由于 IPC 通道与 socket 的行为比较类似,属于双向通信;不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效;只有启动的子进程是 Node 进程时,子进程才会根据环境变量去连接 IPC 通道,对于其他类型的子进程则无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的 IPC 通道。

    句柄

    句柄(标识符)是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符;句柄可以用来标识一个 socket 对象、一个 UDP 套接字、一个管道等。通过进程间发送句柄可以实现多个进程监听同一端口;主进程接收到 socket 请求后,将这个 socket 直接发送给工作进程,而不是重新与工作进程之间建立新的 socket 连接来转发数据。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    // 主进程
    const child_one = require('child_process').fork('./child.js');
    const child_two = require('child_process').fork('./child.js');
    const server = require('net').createServer();
    server.on('connection', socket => {
     socket.end('handled by parent\n');
    });
    server.listen(1337, () => {
     child_one.send('server', server);
     child_two.send('server', server);
    });
    // 子进程
    process.on('message', (m, server) => {
     m === 'server' &&
     server.on('connection', socket => {
     socket.end('handled by child\n pid is ' + process.pid + '\n');
     });
    });
    

    对于主进程而言,我们甚至想要它更轻量一点,那么是否将服务器句柄发送给子进程之后,就可以关掉服务器的监听,让子进程来处理请求呢?

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    // 主进程
    const child_one = require('child_process').fork('./child.js');
    const child_two = require('child_process').fork('./child.js');
    const server = require('net').createServer();
    server.listen(1337, () => {
     child_one.send('server', server);
     child_two.send('server', server);
     server.close();
    });
    // 子进程
    const http = require('http');
    const server = http.createServer((req, res) => {
     res.writeHead(200, { 'Content-Type': 'text/plain' });
     res.end('handled by child\n pid is ' + process.pid + '\n');
    });
    process.on('message', (m, tcp) => {
     m === 'server' &&
     tcp.on('connection', socket => {
     server.emit('connection', socket);
     });
    });
    

    子进程对象 send 方法可以发送的句柄类型包括如下几种:

    • net.Socket TCP 套接字;
    • net.Server TCP 服务器;
    • net.Native C++ 层面的 TCP 套接字或 IPC 管道;
    • dgram.Socket UDP 套接字;
    • dgram.Native C++ 层面的 UDP 套接字。

    send 方法在将消息发送到 IPC 管道前,将消息组装成两个对象,一个参数是 handle,另一个是 message;发送到 IPC 管道中的实际上是我们要发送的句柄文件描述符。message 对象在写入到 IPC 管道时也会通过 JSON.stringify 进行序列化,最终发送到 IPC 通道中的信息都是字符串。

    1
    2
    3
    4
    5
    
    {
     cmd: 'NODE_HANDLE', // 如果以 NODE_ 为前缀,它将响应一个内部事件 internalMessage;NODE_HANDLE 表示它将取出 message.type 值和得到的文件描述符一起还原出一个对应的对象
     type: 'net.Server',
     msg: message
    }
    

    Node 进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。

    自动重启

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    // master.js
    const fork = require('child_process').fork;
    const cpus = require('os').cpus();
    const server = require('net').createServer();
    const workers = {};
    server.listen(1337);
    for (let i = 0; i < cpus.length; i++) {
     createWorker();
    }
    function createWorker() {
     let worker = fork(__dirname + './worker.js');
     worker.on('exit', () => {
     console.log('worker: ' + worker.pid + ' exited.');
     delete workers[worker.pid];
     createWorker();
     });
     worker.send('server', server);
     workers[worker.pid] = worker;
     console.log('create worker. pid: ' + worker.pid);
    }
    // worker.js
    process.on('exit', () => {
     for (let pid in workers) {
     workers[pid].kill();
     }
    });
    process.on('uncaughtException', err => {
     logger.error(err);
     process.send({
     act: 'suicide'
     });
     // 如果有未捕获的异常,退出进程
     worker.close(() => {
     process.exit(1);
     });
    });
    

    限量重启

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    
    let limit = 10;
    let duration = 60000;
    const restart = [];
    const workers = {};
    function createWorker() {
     if (isTooFrequently()) {
     // 如果重启太过频繁,不再重启
     process.emit('giveup', length, duration);
     return;
     }
     let worker = fork(__dirname + '/work.js');
     worker.on('exit', () => {
     console.log('worker ' + worker.pid + ' exited.');
     delete workers[worker.pid];
     });
     worker.on('message', message => {
     message.act === 'suicide' && createWorker();
     });
     worker.send('server', server);
     workers[worker.pid] = worker;
     console.log('create worker. pid: ' + worker.pid);
    }
    function isTooFrequently() {
     let time = Date.now();
     let length = restart.push(time);
     if (length > limit) {
     restart = restart.slice(limit * -1);
     }
     return (
     restart.length >= limit &&
     restart[restart.length - 1] - restart[0] < duration
     );
    }
    // 监听自定义 giveup 事件
    process.on('giveup', (count, time) => {
     process.close(() => {
     process.exit(1);
     });
     setTimeout(() => {
     process.exit(1);
     }, 5000);
    });
    // 监听主进程退出,退出所有子进程
    process.on('exit', () => {
     for (let key in workers) {
     workers[key].kill();
     }
    });
    

    负载均衡

    Node 默认提供的负载均衡机制是采用操作系统的抢占式策略,各个进程可以根据自己的繁忙度(CPU、I/O)来进行抢占;影响抢占的是 CPU 繁忙度。Node 在 v0.11.2 新增了轮叫调度(Round-Robin),可以通过设置 NODE_CLUSTER_SCHED_POLICY 环境变量来实现;轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作进程。

    1
    2
    3
    4
    
    cluster.schedulingPolicy = cluster.SCHED_RR;
    cluster.schedulingPolicy = cluster.SCHED_NONE;
    export NODE_CLUSTER_SCHED_POLICY=rr;
    export NODE_CLUSTER_SCHED_POLICY=none;
    

    cluster 模块用以解决多核 CPU 的利用率问题,它的底层实现还是 child_process,同时提供了较完善的 API 用以处理进程的健壮性问题,允许简易的创建共享服务器端口的子进程。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    const http = require('http');
    const cluster = require('cluster');
    const numCPUs = require('os').cpus().length;
    // cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
    // cluster.isMaster = (cluster.isWorker === false);
    // 如果是主进程
    if (cluster.isMaster) {
     console.log(`主进程 ${process.pid} 正在运行`);
     // 修改 fork 默认行为
     cluster.setupMaster({
     // some config...
     });
     // 衍生工作进程
     for (let i = 0; i < numCPUs; i++) {
     cluster.fork();
     }
     cluster.on('exit', (worker, code, signal) => {
     console.log(`工作进程 ${worker.process.pid} 已退出`);
     });
    } else {
     http
     .createServer((req, res) => {
     res.writeHead(200);
     res.end('你好世界\n');
     })
     .listen(8000);
     console.log(`工作进程 ${process.pid} 已启动`);
    }
    

    最佳实践

    • 动静分离:将动态请求和静态请求分离后,Node 专注于处理动态请求;
    • 启用缓存:消减同步 I/O 带来的时间浪费;
    • 多进程架构:使用工具进行进程管理;
    • 读写分离:将数据库进行主从设计,减少读写操作的相互影响;
    • 记录日志:记录那些意外产生的异常错误;
    • 文件分组:按照业务角色分组比按技术角色分组更好。

    参考

    • The Node.js Best Practices List
    • 构建自己的 Node.js 知识体系


沪ICP备19023445号-2号
友情链接