《深入浅出Node.js》读书笔记

375 阅读13分钟

本笔记只包含该书精华部分

书中内容有点老了

第1章 Node简介

1.1 Node的诞生历程

1.2 Node的命名与起源

1.2.1 为什么是JavaScript

Ryan Dahl找到了设计高性能,Web服务器的几个要点:事件驱动、非阻塞I/O。

考虑到高性能、符合事件驱动、没有历史包袱这3个主要原因,JavaScript成为了Node的实现语言

1.2.2 为什么叫Node

1.3 Node给JavaScript带来的意义

JavaScript作为一门图灵完备的语言,长久以来却限制在浏览器的沙箱中运行,它的能力取决于浏览器中间层提供的支持有多少。

1.4 Node的特点

1.4.1 异步I/O

1.4.2 事件与回调函数

1.4.3 单线程

单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

单线程的弱点:

  • 无法利用多核CPU
  • 错误会引起整个应用退出,应用的健壮性值得考验
  • 大量计算占用CPU导致无法继续调用异步I/O

HTML5解决CPU占用问题的方法:Web Workers(线程) Node的解决方法:child_process(进程)思路相同,因为是进程也同时解决了"无法利用多核CPU"的问题,通过Master-Worker的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性,一举3得(不过进程占用资源比线程高)

1.4.4 跨平台

1.5 Node的应用场景

1.5.1 I/O密集型

I/O密集的优势主要在于Node利用事件循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。

1.5.2 是否不擅长CPU密集型业务

  • Node可以通过编写C/C++扩展的方式更高效地利用CPU,将一些V8不能做到性能极致的地方通过C/C++来实现
  • 如果单线程的Node不能满足需求,甚至用了C/C++扩展后还觉得不够,那么通过子进程的方式,将一部分Node进程当做常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与I/O分离,这样还能充分利用多CPU

1.5.3 与遗留系统和平共处

作为中间层

1.5.4 分布式应用

高效利用并行I/O,并行查询多个数据库

1.6 Node的使用者

  • 前后端编程语言环境统一
  • 高性能I/O用于实时应用
  • 并行I/O使得使用者可以更高效地利用分布式环境
  • 云计算平台提供Node支持
  • 游戏开发领域
  • 工具类应用

第2章 模块机制

JavaScript的变迁:

2.1 CommonJS规范

愿景:希望JavaScript能够在任何地方运行

2.1.1 CommonJS的出发点

JavaScript缺陷:

  • 没有模块系统
  • 标准库较少
  • 没有标准接口
  • 缺乏包管理系统

2.1.2 CommonJS的模块规范

1.模块引用:require 2.模块定义:exports 3.模块标识:符合小驼峰命名的字符串,或者以...开头的相对路径,或者绝对路径

2.2 Node的模块实现

在Node中引入模块,需要经历如下3个步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

  • 核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

2.2.1 优先从缓存加载

2.2.2 路径分析和文件定位

1.模块标识符分析

模块标识符:

  • 核心模块,如httpfspath等 核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其加载过程最快
  • ...开始的相对路径文件模块 在分析文件模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快 由于文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块
  • /开始的绝对路径文件模块
  • 非路径形式的文件模块,如自定义模块

模块路径查找规则:

  • 当前文件目录下的node_modules目录
  • 父目录下的node_modules目录
  • 父目录的父目录下的node_modules目录
  • 沿路径向上逐级递归,直到根目录下的node_modules目录
2. 文件定位
  • 文件扩展名分析.js.json.node的顺序尝试,在尝试的过程中,需要调用fs模块同步阻塞式地判断文件是否存在,所以如果是.node.json文件,在传递给require()的标识符中带上扩展名,会加快一点速度
  • 目录分析和包
    1. 查找package.json
    2. JSON.parse()解析出包描述对象
    3. 取出main属性指定的文件名进行定位,如果文件名缺少扩展名,将会进入扩展名分析的步骤
    4. 如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.jsindex.jsonindex.node
    5. 如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常

2.2.3 模块编译

模块定义:

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  if (parent && parent.children) {
    parent.children.push(this);
  }

  this.filename = null;
  this.loaded = false;
  this.children = [];
}

不同扩展名的载入方法:

  • .js文件。通过fs模块同步读取文件后编译执行。
  • .node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • .json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • 其余扩展名文件。它们都被当做.js文件载入。
1. JavaScript模块的编译

头尾包装:

(function (exports, require, module, __filename, __dirname) {
  // module code
});
2. C/C++模块的编译

Node调用process.dlopen()方法进行加载和执行

实际上,.node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程

3. JSON文件的编译

利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋给模块对象的exports

2.3 核心模块

核心模块其实分为C/C++编写的和JavaScript编写的两部分,其中C/C++文件存放在Node项目的src目录下,JavaScript文件存放在lib目录下

2.3.1 JavaScript核心模块的编译过程

1.转存为C/C++代码

js2c.py

JavaScript代码以字符串的形式存储在node命名空间中

2.编译JavaScript核心模块

通过process.binding('natives')从内存中取出,编译成功的模块缓存到NativeModule._cache对象上

2.3.2 C/C++核心模块的编译过程

由纯C/C++编写的部分统一称为内建模块。buffercryptoevalsfsos等模块都是部分通过C/C++编写的

2.3.3 核心模块的引入流程

2.3.4 编写核心模块

2.4 C/C++扩展模块

2.5 模块调用栈

2.6 包与NPM

2.6.1 包结构

完全符合CommonJS规范的包目录应该包含如下这些文件:

  • package.json:包描述文件。
  • bin:用于存放可执行二进制文件的目录。
  • lib:用于存放JavaScript代码的目录。
  • doc:用于存放文档的目录。
  • test:用于存放单元测试用例的代码。

2.6.2 包描述文件与NPM

2.6.3 NPM常用功能

2.6.4 局域NPM

2.6.5 NPM潜在问题

2.7 前后端共用模块

2.7.1 模块的侧重点

2.7.2 AMD规范

define(id?, dependencies?, factory);

和CommonJS的区别:

  • 需要用define来明确定义一个模块
  • 内容需要通过返回的方式实现导出

2.7.3 CMD规范

define(function(require, exports, module) {
  // The module code goes here
});

2.7.4 兼容多种模块规范

UMD

第3章 异步I/O

3.1 为什么要异步I/O

3.1.1 用户体验

3.1.2 资源分配

利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地使用CPU

3.2 异步I/O实现现状

3.2.1 异步I/O与非阻塞I/O

操作系统内核对于I/O只有两种方式:阻塞与非阻塞

调用阻塞I/O的过程: 调用非阻塞I/O的过程:

非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回

轮询技术:

  • read。它是最原始、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取。在得到最终数据前,CPU一直耗用在等待上
  • select。它是在read的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断
  • poll。
  • epoll。Linux下效率最高的I/O事件通知机制
  • kqueue。和epoll一样,不过是FreeBSD

3.2.2 理想的非阻塞异步I/O

Linux原生提供的一种异步I/O方式(AIO),不过有缺陷,不能利用缓存

3.2.3 现实的异步I/O

模拟异步I/O:

Windows下的IOCP也是线程池原理

libuv架构:

3.3 Node的异步I/O

3.3.1 事件循环

3.3.2 观察者

3.3.3 请求对象

3.3.4 执行回调

3.4 非I/O的异步API

3.4.1 定时器

数据结构:红黑树

3.4.2 process.nextTick()

3.4.3 setImmediate()

process.nextTick()属于idle观察者,setImmediate()属于check观察者 process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中

process.nextTick()在每轮循环中会将数组中的回调函数全部执行完,而setImmediate()在每轮循环中执行链表中的一个回调函数

⚠️书中这里的应该过时了,关于event loop的详细解释请看这篇blog.insiderattack.net/event-loop-…

3.5 事件驱动与高性能服务器

第4章 异步编程

4.1 函数式编程

4.1.1 高阶函数

高阶函数是可以把函数作为参数,或是将函数作为返回值的函数

4.1.2 偏函数用法

偏函数用法是指创建一个调用另外一个部分--参数或变量已经预制的函数--的函数的用法

function isType(type) {
  return (obi) => {
    return Object.prototype.toString.call(obj) === `[object ${type}]`;
  }
};

const isString = isType('String');
const isFunction = isType('Function');

4.2 异步编程的优势与难点

4.2.1 优势

资源利用,并行

4.2.2 难点

  1. 异常处理
  2. 函数嵌套过深
  3. 阻塞代码
  4. 多线程编程
  5. 异步转同步

4.3 异步编程解决方案

4.3.1 事件发布/订阅模式

4.3.2 Promise/Deferred模式

4.3.3 流程控制库

中间件尾触发模式

4.4 异步并发控制

第5章 内存控制

5.1 V8的垃圾回收与内存限制

5.1.1 Node与V8

5.1.2 V8的内存限制

只能使用部分内存

5.1.3 V8的对象分配

V8中,所有的JavaScript对象都是通过堆来进行分配的,如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到对的大小超过V8的限制为止

V8限制堆大小的原因:

  • 表层原因:最初为浏览器设计,浏览器不太可能遇到大量内存的场景
  • 深层原因:垃圾回收机制的限制,内存多的话做一次回收耗时过久

可以传递--max-old-space-size--max-new-space-size调整

5.1.4 V8的垃圾回收机制

分代式垃圾回收机制

内存分为新生代和老生代两代

在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收,在Scavenge算法的具体实现中,主要采用了Cheney算法

Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。

Scavenge的缺点是只能使用堆内存中的一半

由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升

晋升流程:

老生代采用了Mark-Sweep(标记清除)和Mark-Compact(标记整理)相结合的方式进行垃圾回收

Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的

为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。Mark-Compact在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存

增量标记(incremental marking)

延迟清理(lazy sweeping)与增量式整理(incrementalcompaction)

5.1.5 查看垃圾回收日志

5.2 高效使用内存

5.2.1 作用域

5.2.2 闭包

5.3 内存指标

5.3.1 查看内存使用情况

5.3.2 堆外内存

Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制

5.4 内存泄漏

内存泄漏的原因:

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

5.4.1 慎将内存当做缓存

5.4.2 关注队列状态

5.5 内存泄漏排查

5.6 大内存应用

stream模块用于处理大文件