深入浅出nodeJs-模块,异步和内存

这绝对是一本实践性极强的技术书,不管是否学习过 Node,只要你爱好技术,都推荐你阅读它。

Node 特点

异步 I/O

Node 保存了 JavaScript 在浏览器中单线程的特点。而且在 Node 中,JavaScript 与其余线程是无法共享线程的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题。 单线程的弱点有以下几点

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

跨平台

node 基于 libuv 实现跨平台

模块机制

在经历十多年的发展后,社区为 js 制定了 CommonJS 方案

Node 的模块实现

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

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

在 node 中,模块分为两类

  1. 核心模块。核心模块在 Node 源代码的编译过程中,编译进了二进制执行文件。在 node 进程启动时,部分核心模块就直接加载进内存中,所以这部分模块在引入时,第二步和第三步会直接省略,在路径分析中优先判断,所以加载速度最快
  2. 文件模块。在运行时加载,需要走完整的三步,加载速度较慢

node 会对引入的模块进行二次缓存,以减少引入时的开销。缓存的内容是编译和执行后的对象。而且 require 方法对相同模块的加载都一律采用缓存优先的方式 路径分析 require 接收一个标识符作为参数,模块标识符分为以下几类

  1. 核心模块。比如 http、fs、path 等。核心模块的优先级仅次于缓存加载,在 Node 源代码编译过程中就已经成为二进制代码,加载速度最快。同名标识符不会加载成功
  2. .或..开始的相对路径文件模块或是绝对路径模块。会被当做文件模块处理,require 会把它转为真实路径,并以真实路径为索引,将编译后的结果放在缓存中。由于指明了路径,所以加载速度仅次于核心模块
  3. 非路径形式的文件模块,也就是自定义模块。它最特殊,可能是一个文件或是包的形式,查找速度最慢,它会按以下路径逐级查找
  • 当前文件夹下的 node_modules 文件夹
  • 父目录下 node_modules 文件夹
  • 沿路径向上递归,直到根目录下的 node_modules 文件夹

文件定位 缓存优化可以让二次引入时不需要三步的过程,但还是有一些细节需要注意

  • 文件后缀。如果文件标识符没有后缀,会按 .js、.json、.node 的顺序依次尝试
  • 目录分析和包。在分析路径之后,如果定位到一个文件夹,则 node 会把目录当成一个包来处理。首先会查找目录下 package.json 的 main 属性指定的文件名进行定位。如果 main 指定错误,或是没有 package.json,会把 index 当做默认文件名,依次查找 index.js、index.json、index.node。如果还是没有找到,直接报错

模块编译 在 Node 中,每个模块都是一个对象,定义如下

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 文件载入

每一个编译成功的模块都会将其文件路径作为索引缓存在 Module.__cache 对象上 Module._extensions 会被赋值到 require()的 extensions 属性,所以通过在代码中访问 require.extensions 可以获取系统中已有的扩展加载方式,比如以下代码

console.log(require.extensions);

如果想对自定义的扩展名进行特殊的加载,可以通过类似 require.extensions['.ext']的方法实现。但官方不鼓励,最后先编译成 .js 文件再进行加载

  1. js 模块的编译 在编译的过程中,Node 对 JavaScript 文件内容进行了包装。在头部添加了 (function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n}); 。包装结果如下
(function(exports, require, module, __filename, __dirname) {
    var math = require("math");
    exports.area = function(radius) {
        return Math.PI * radius * radius;
    };
});

这就是这些变量并没有定义却能使用的原因。在执行之后,模块的 exports 属性返回给了调用方。 2. c++ 模块的编译 Node 调用 process.dlopen()方法进行加载和执行。在 Node 的架构下,dlopen()方法在 Windows 和 Linux 下分别有不同的视线,通过 libuv 兼容层进行了封装。 实际上,.node 模块是编写 C/C++ 模块之后编译生成的,所以只有加载和执行的过程。 3. JSON 模块的编译 Node 使用 fs 模块读取 JSON 文件的内容之后,调用 JSON.parse() 得到对象,之后赋值给 exports 对象

包与 npm

Node 对模块规范的实现,一定程度上解决了变量依赖、依赖关系等组织性问题。包的出现,是在模块的基础上进一步组织代码

包结构

包实际上是一个存档文件,即一个目录打包为 .zip 或 .gzip 的文件,安装后解压还原为目录。完全符合 CommonJS 规范的包应该包含以下目录

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

包描述文件与 NPM

  • name: 包名。由小写的字母和数字组成,可以包含., _和-。包名是唯一的
  • description: 包简介
  • version: 版本号
  • keywords: 关键词数组
  • matainers: 包维护者列表
  • constributors: 贡献者列表,第一个是包的作者本人
  • bugs: 一个可以反馈 bug 的网页地址或邮件地址
  • licenses: 当前包所使用的许可证列表
  • repositories: 托管源代码到位置列表,表明可以通过哪些方式和地址访问包的源代码
  • dependencies: 使用当前包所需要依赖的包列表。NPM 会通过这个属性帮助自动加载依赖的包
  • homepage: 当前包的网站地址
  • os: 操作系统支持列表
  • cpu: cpu 架构支持列表
  • engine: 支持的 js 引擎列表
  • builtin: 标志当前包是否是内建在底层系统的标准组件
  • directories: 包目录说明
  • implements: 实现规范的列表
  • scripts: 脚本说明对象

在包描述文件规范中,NPM 实际需要的字段主要有 name、version、description、keywords、 repositories、author、bin、main、scripts、engines、dependencies、devDependencies。 与包规范的区别在于多了 author、bin、main􀖖devDependencies 这 4 个字段。

  • author。包作者。
  • bin。一些包作者希望包可以作为命令行工具使用。配置好 bin 字段后,通过 npm install package_name -g 可以将脚本添加到执行路径中,之后可以在命令行中直接执行。
  • main。模块引入方法 require()在引入包时,会优先检查这个字段,并将其作为包中其余模块的入口。如果不存在这个字段,require()会查找包目录下的 index.js、index.node、index.json 文件作为默认入口。
  • devDependencies。开发依赖包

NPM 常用功能

  • -g 是将一个包安装为全局可用的可执行命令,根据包描述文件中的 bin 字段配置
"bin": {
  "express": "./bin/express"
}
  • Node 可执行文件的位置是/usr/local/bin/node, 模块目录就是/usr/local/lib/node_ modules。最后, 通过软链接的方式将 bin 字段配置的可执行文件链接到 Node 可执行目录下
  • 发布包
    • npm init
    • npm adduser
    • npm publish
  • npm ls 可以展示包的文件目录结构,生成依赖树

异步 I/O

PHP 语言从头到脚都是以同步阻塞的方式来执行的。 与 Node 的事件驱动、异步 I/O 设计理念比较相近的一个知名产品为 Nginx

为什么要异步 I/O

采用异步请求,在下载资源期间,JS 和 UI 的执行都不会出于等待状态,可以继续响应用户的交互请求

资源分配

假如业务场景中有一组互不相关的任务需要完成,现行的主流方法有以下两种

  • 单线程串行一次执行。单线程比较符合编程人员按顺序思考的思维方式,但它的缺点在于性能,I/O 的进行会让后续任务等待,者会造成资源不能更好地利用
  • 多线程并行完成。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。在复杂的业务中,多线程会面临锁、状态同步等问题。但是多线程可以更有效地利用 CPU

Node 在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步 I/O,让单线程远离阻塞,更好地利用 CPU

异步 I/O 实现现状

操作系统内核对于 I/O 只有两种方式:阻塞和非阻塞。在调用阻塞 I/O 时,应用程序需要等待 I/O 完成才返回结果。非阻塞 I/O 和阻塞 I/O 的差别为调用之后会立即返回 非阻塞 I/O 也存在一些问题,由于完整的 I/O 并没有完成,立即返回的斌不是业务层期望的数据。而仅仅是当前调用的状态,此时应用程序需要通过轮询调用来确认 I/O 操作是否完成 现在的轮询技术有以下几种

  • read。最原始的一种,通过重复调用来检查 I/O 的状态来完成完整数据的读取
  • select。在 read 的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断
  • poll。相较 select 有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。
  • epoll。是 Linux 下效率最高的 I/O 事件通知机制,在进入轮询时没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒
  • kqueue。仅在 FreeBSD 系统下存在

现实的异步 I/O 通过让部分线程进行阻塞 I/O 或非阻塞 I/O 加轮询来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将 I/O 得到的数据进行传递

Node 的异步 I/O

完成整个异步 I/O 环节的有事件循环、观察者和请求对象等

  • 事件循环。当进程启动时,Node 会启动一个类似于 While(true)的循环,每执行一次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。典型的生产者/消费者模型
  • 观察者。每个事件循环中有一个或多个观察者,而判断是否有事件处理的过程就是向这些观察者询问是否有要处理的事件。
  • 请求对象。从 Js 发起调用到内核执行完成 I/O 操作的过渡过程中,存在一种中间产物,它叫做请求对象。请求对象是异步 I/O 过程中的重要产物,所有的状态都保存在这个对象中

组装好请求对象、送入 I/O 线程池中等待执行,实际上完成了异步 I/O 的第一部分,回调通知是第二部分 事件循环、观察者、请求对象、I/O 线程池这四者共同构成了 Node 异步 I/O 模型的基本要素

非 I/O 的异步 API

在 Node 中存在一些与 I/O 无关的异步 API,它们分别是 setTimeout(), setInterval(), setImmediate()和 process.nextTick() setTimeout()和 setInterval()与浏览器中的 API 是一致的,分别用于单次和多次执行任务。实现原理与异步 I/O 比较类似,但是不需要 I/O 线程池的处理。调用 setTimeout()或 setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。 定时器的问题在于,它并非精确的 process.nextTick()的操作较为轻量,它只会将回调函数放入队列中,在下一轮 Tick 时取出执行。而定时器采用红黑树的操作时间复杂度为 O(lg(n)),nextTick()的时间复杂度为 O(1)。它属于 idle 观察者 setImmediate() 的优先级低于 process.nextTick()。它属于 check 观察者 具体实现上,process.nextTick()的回调函数保存在一个数组中,而 setImmediate()的结果则是保存在链表中。process.nextTick()在每轮循环中会将数组中的回调函数全部执行,而 setImmediate()在每轮循环中执行链表中的一个回调函数

事件驱动与高性能服务器

事件驱动的实质,就是通过主循环加事件触发的方式允许程序 以下为几种经典的服务模型

  • 同步式。一次只能处理一个请求,并且其余请求都处于等待状态
  • 每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是不具备扩展性
  • 每线程/每请求。为每个请求启动一个线程,当大并发请求到来时,内存将很快用光,导致服务器缓慢。Apache 就是这种方式

Node 通过事件驱动的方式处理请求,不需要为每一个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销

异步编程

难点

  1. 异常处理。我们通常使用 try/catch/final 进行异常捕获。而异常并不会发生在 try/catch 内。所以 Node 在处理异常上形成了一种约定,将异常作为回调函数的第一个实参范湖,如果为空值,表明异步调用没有异常抛出
  2. 函数嵌套过深。
  3. 阻塞代码。没有 sleep()这样的线程沉睡功能
  4. 多线程编程。在浏览器中 JS 执行线程与 UI 渲染共用一个线程,同时提出了 Web Workers,通过 JS 执行与 UI 渲染分离来利用多核 CPU。而在 Node 则是使用 child_process 作为基础 API,而 cluster 则是更深层次的应用
  5. 异步转同步。

异步编程的解决方案

  • 事件发布/订阅模式
  • Promise
  • async

内存控制

内存控制正是在海量请求和长时间允许的前提下探讨的 在 Node 中通过 JS 使用内存时就会发现只能使用部分内存。在这样的限制下,将会导致 Node 无法直接操作大内存对象。这是因为 Node 基于 v8 构建,而 v8 的内存管理机制在浏览器下绰绰有余,在 Node 中却成为了限制 在 v8 中,所有 js 对象都是通过堆来进行内存分配 process.memoryUsage() 能得到内存信息

v8 的垃圾回收机制

v8 的垃圾回收策略主要基于分代式垃圾回收机制。

  • v8 的内存分代。在 v8 中,主要讲内存分为新生代和老生代两代,新生代为存活时间较短的对象,老生代中的对象为存回时间较长或常驻内存的对象
  • Scavenge 算法。新生代的对象主要通过 Scavenge 算法进行垃圾回收。它会将对内存一分为二,一个处于使用中称为 From,一个处于空闲状态称为 to。

当分配对象时,会先在 From 空间中分配,垃圾回收时,会检查 From 空间中的存活对象,这些存货对象将会被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间会和 To 空间角色发生互换 Scavenge 算法是典型的空间换时间算法。 一定条件下,会将新生代的对象移动到老生代中,也就是完成对象晋升。晋升的条件有两个 - 对象是否经历过Scavenge回收 - To空间的内存占比超过限制,如果复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代空间中

  • 标记清除(Mark-Sweep)和标记整理(Mark-Compact)。
    • 标记清除分为标记和清除的两个阶段,它会在标记期间遍历堆中所有对象,并标记活的对象,在清除阶段时,只清未被标记的对象。Scavenge 算法只复制活着的对象,标记清除只清理死亡对象。
    • 标记清除的最大问题是在进行一次标记清除过程后,内存会出现不连续的状态,此时需要使用标记整理将活着的对象移向内存的一边,移动完成后直接清理边界外的内存
    由于标记整理耗费的时间比较长,所以只会在空间不足以对从新生代晋升到老生代的对象进行分配时才会使用标记整理
  • 增量标记(incremental marking)。由于垃圾回收器是同步进行的,所以为了降低它阻塞的时间。v8 会从标记阶段入手,将原本要一口气停顿做完的动作改为增量标记。也就是拆分成很多小的步进,完成每个步进时再让 js 执行一会

高效使用内存

开发者的责任是为了让垃圾回收机制更高效地工作

  • 作用域。作用域会触发垃圾回收。能形成作用域的有函数、with、全局作用域
    1. 标识符查找。与作用域相关的就是作用域查找,所谓标识符,其实就是变量名。js 在执行时会先去当前作用域查找该变量,如果找不到,会向上级作用域查找,直到查到为止
    2. 作用域链。标识符从当前作用找不到定义后往上级作用域查找直到全局作用域的过程使得作用域很像一个链条。这就叫作用域链
    3. 变量的主动释放。如果变量是全局变量,而由于全局变量要到进程退出时才会释放,此时会导致变量常驻内存。如果需要释放常驻内存的对象,此时可以通过 delete 删除引用关系,或将变量重新赋值,让旧的对象脱离引用关系。
  global.foo = "global object";
  delete global.foo;
  global.foo = undefined; // or null
  • 闭包。实现外部作用域访问内部作用域中变量的方法叫做闭包。这得益于高阶函数的特性,函数可以作为参数或返回值。一旦有变量引用中间函数,则这个中间函数不会释放,同时原始作用域也不会释放,除非不再有引用

内存指标

一旦达到 v8 的内存限制,将会导致内存溢出错误,进而导致进程退出

  • 查看内存使用情况。
    1. 查看进程的内存占用。process.memoryUsage()。
    2. os 模块中的 totalmem() 和 freemem() 用于查看操作系统的内存使用情况,分别返回总内存和闲置内存。
  • 堆外内存。node 中的内存使用并非都是通过 v8 进行分配的,我们讲那些不是 v8 进行分配的内存称为堆外内存。而受 v8 垃圾回收限制的主要是 v8 的堆内存

内存泄露

内存泄露的实质就是应当回收的对象出现意外没有被回收,变成了常驻在老生代中的对象。造成内存泄露的原因有以下几个

  1. 缓存
  2. 队列消费不及时
  3. 作用域未释放
  • 慎将内存当缓存。一旦一个对象被当做缓存来使用,就会被常驻在内存中。存储的键越多,长期存活的对象也越多。有几种方法可以处理这类问题
  1. 缓存限制策略。LRU 算法,限制对象键值对长度,达到限制长度后,有新的键值对进来,就会移除最不常使用的键值对。
  2. 缓存的解决方案。进程之间无法共享内存,如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用时一种浪费。此时我们可以使用进程外的缓存,进程自身不缓存状态。比如使用 redis。这样会有两个好处
    • 减少常驻内存的使用,垃圾回收更高效
    • 进程之间可以共享缓存
  • 关注队列状态。js 中可以通过队列(数组对象)完成很多特殊需求,比如生产者和消费者,而一旦消费速度低于生产速度,就会产生堆积。

比如写日志,一旦生成速度大于文件写入速度,就会形成写入操作的堆积。可以换用消费速度更高的技术,或是监控队列的长度,超过就直接告警。还可以包含超时机制,一旦在限定时间未完成响应,就传递超时异常,给消费速度一个下限值

内存泄露排查

有许多常见的工具用于定位 node 应用的内存泄漏

  • node-heapdump。对 v8 堆内存抓取快照
  • node-mtrace。使用了 GCC 的 mtrace 工具来分析堆的使用
  • drace。
  • node-memwatch。采用 WTFPL 许可发布

大内存应用

由于 node 的内存限制,操作大文件需要小心,好在 node 提供了 stream模块用来处理大文件 stream 模块是 node 的原生模块,直接引用即可,stream 继承自 EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分为读和可写两种。 node 中的大多数模块都有 stream 的应用,比如 fs 的 createStream() 和 createWriteStream() 方法用于创建可读流和可写流

var reader = fs.createReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.on('data', function(chunk) {
    writer.write(chunk);
});
reader.on('end', function() {
    writer.end();
});

或者更简洁的方式

var reader = fs.createReadStream('in.txt');
var writer = fs.createWriteStream('out.txt');
reader.pipe(writer);

理解 Buffer

Buffer 对象用于处理大量的二进制数据

Buffer 结构

Buffer 是一个像 Array 的结构,但它主要用于操作字节

  • 模块结构。buffer 是一个典型的 js 与 c++ 结合的模块。它的性能相关部分用 C++ 实现,非性能相关的部分用 js 实现

![node 模块结构](./imgs/node%E6%A8%A1%E5%9D%97%E7%BB%93%E6%9E%84. PNG)

  • Buffer 对象。Buffer 对象类似于数组,它的元素为 16 进制的两位数,即 0 到 255 的数值,示例代码如下:
var str = "􀸨入􀴱出node.js";
var buf = new Buffer(str, 'utf-8');
console.log(buf);
// => <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73>

由上面的示例可见,不同编码的字符串占用的元素个数各不相同。 Buffer 受 Array 类型的影响很大,可以访问 length 属性得到长度,也可通过下标访问元素,代码如下

var buf = new Buffer(100);
console.log(buf.length); // => 100
console.log(buf[10]); // 0 ~ 255 的随机值

我们还可以通过下标赋值

  • buffer 内存分配。Buffer 对象的内存分配不是在 v8 的堆内存中,而是在 node 的 c++ 层面实现内存的申请的,之后再由 js 进行分配

为了高效地使用申请来的内存,node 采用了 slab 分配机制。slab 是一种动态内存管理机制。简单而言,slab 就是一块申请好的固定大小的内存区域。slab 有以下三种状态

  • full:完全分配状态
  • partial:部分分配状态
  • empty:未分配状态

当我们需要一个 Buffer 对象,可以通过以下方式分配指定大小的 buffer 对象: new Buffer(size);

Node 以 8kb 为界限区分 Buffer 是大对象还是小对象 Buffer.poolSize = 8 * 1024;

这个 8kb 的值就是每个 slab 的大小值,在 js 层面,以它为单位单元进行内存的分配

  1. 分配小 Buffer 对象。如果指定 Buffer 的大小少于 8 KB,node 会按照小对象的方式进行分配。Buffer 的分配过程中主要使用一个局部变量 pool 作为中间处理对象,处于分配状态的 slab 单元都指向它。以下是分配一个全新的 slab 单元的操作,它会将新申请的 SlowBuffer 对象指向它:
var pool;

function allocPool() {
    pool = new SlowBuffer(Buffer.poolSize);
    pool.used = 0;
}
// 构造小Buffer对象时的代码如下:
new Buffer(1024);
// 检查 pool对象,如果pool没有被创建,则创建一个新对象
if (!pool || pool.length - pool.used < this.length) allocPool();

当前 Buffer 对象的 parent 属性指向该 slab,并记录下是这个 slab 的哪个位置(offset)开始使用的,slab 对象也会记录使用了多少个字节

this.parent = pool;
this.offset = pool.used;
pool.used += this.length;
if (pool.used & 7) pool.used = (pool.used + 8) & ~7;

从一个新的 slab 单元中初次分配了一个 Buffer 对象的示意图

bufferInSlab

此时 slab 的状态为 partial 之后再次创建一个 Buffer 对象,构造过程中会判断 slab 的剩余空间是否足够。如果足够,使用 slab 剩余空间,并更新 slab 状态。如果不够,则会构造新的 slab,原 slab 中的空间会被浪费

// 下列代码占用了两个 slab 单元
new Buffer(1);
new Buffer(8192);

只有在 slab 中的所有 Buffer 都被释放时,slab 才会被释放 2. 分配大 Buffer 对象。 如果分配超过 8kb 的 Buffer 对象,将会分配一个 SlowBuffer 对象作为 slab 单元,这个 slab 单元会被这个大 Buffer 对象独占

// Big buffer, just alloc one
this.parent = new SlowBuffer(this.length);
this.offset = 0;

SlowBuffer 是 c++ 定义的,推荐直接使用 Buffer,因为 Buffer 对象是 js 层面的,能够被垃圾回收标记。而如果引用 SlowBuffer 则因为是 C++ 层面上的 Buffer 对象,所以不会被回收

Buffer 的转换

Buffer 对象可以与字符串之间相互转换。目前支持的字符串编码类型有以下几种。

  • ASCII
  • UTF-8
  • UTF-16LE/UCS-2
  • Base64
  • Binary
  • Hex

字符串转 Buffer 通过构造函数完成

new Buffer(str, [encoding])

通过构造函数转换的 Buffer 对象,存储的只能是一种编码类型。encoding 参数不传递时,默认按 UTF-8 编码进行转码和存储。 一个Buffer对象可以存储不同编码类型的字符串转码的值,调用 write() 方法可以实现该目的

buf.write(string, [offset], [length], [encoding])

Buffer 转字符串 Buffer 对象的 toString 可以将 Buffer 对象转换为字符串

buf.toString([encodeing], [start], [end])

Buffer 不支持的编码类型 只有少数几种编码类型可以在字符串和 Buffer 之间转换。

// 如果支持则返回 true,否则为 false
// 中国的 GBK、GB2312和BIG-5编码都不在支持的行列中
Buffer.isEncoding(encoding)

对于不支持的编码类型,可以借助 Node 生态圈中的模块完成转换,比如 iconv 和 iconv-lite Buffer 的拼接 Buffer 在使用场景中,通常是以一段一段的方式传输。以下是常见的从输入流中读取内容的示例代码

var fs = require('fs');
var rs = fs.createReadStream('test.md');
var data = '';
rs.on("data", function(chunk) {
    // 这局代码隐藏了 toString 操作,等同于
    // data.toString() + chunk.toString();
    data += chunk;
});
rs.on("end", function() {
    console.log(data);
});

在转换中文字符串时,由于中文在 utf-8 下占三个字节,所以如果一个 Buffer 对象可能会把字节截断导致乱码,此时可以使用

var rs = fs.createReadStream('test.md', {
    highWaterMark: 11
});
rs.setEncoding('utf8');

再次执行程序,可以得到中文字符,这是因为可读流内部的 decoder 对象在 write 时,如果有字符被截断,会先把截断的部分保留在 StringDecoder 内部,之后在进行拼接 但这种方式只能处理 UTF-8、Base64 和 UCS-2、UTF-16LE 这三种编码,并不能从根本上解决问题 正确拼接Buffer 如果不使用 setEncoding 方法后,还可以将多个小 Buffer 对象拼接为一个 Buffer 对象,然后通过 iconv-lite 一类的模块来转码。正确 Buffer 拼接方法如下

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);
});

正确方式是用一个数组来存储所有的 Buffer 片段并记录下所有片段的总长度,然后调用 Buffer.concat() 方法生成一个合并的 Buffer 对象。Buffer.concat() 方法封装了从小 Buffer 对象到大 Buffer 对象的过程:

Buffer.concat = function(list, length) {
    if (!Array.isArray(list)) {
        throw new Error('Usage: Buffer.concat(list, [length])');
    }
    if (list.length === 0) {
        return new Buffer(0);
    } else if (list.length === 1) {
        return list[0];
    }
    if (typeof length !== 'number') {
        length = 0;
        for (var i = 0; i < list.length; i++) {
            var buf = list[i];
            length += buf.length;
        }
    }
    var buffer = new Buffer(length);
    var pos = 0;
    for (var i = 0; i < list.length; i++) {
        var buf = list[i];
        buf.copy(buffer, pos);
        pos += buf.length;
    }
    return buffer;
};

Buffer 与性能

Buffer 在文件I/O和性能I/O中运用广泛,尤其在网络传输中,它的性能举足轻重。在应用中我们通常会操作字符串,但在网络中传输,都需要转换为 Buffer,以进行二进制数据传输。 在 web 应用中,字符串转换到 Buffer 是时刻发生的。 通过预先转换内容为 Buffer 对象,可以有效地减少 CPU 的重复使用,节省服务器资源。在 node 构建的 web 应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为 Buffer 的方式,使性能得到提升。

  • 文件读取。Buffer 的使用除了与字符串的转换有关外,在文件的读取时,有一个 highWaterMark 的设置对性能的影响至关重要。在使用fs.createReadStream(path, opts)时,我们可以传入一些参数,代码如下:
{
    flags: 'r',
    encoding: null,
    fd: null,
    mode: 0666,
    highWaterMark: 64 * 1024
}

还可以传递 start 和 end 来指定读取文件的位置范围:

{
    start: 90,
    end: 99
}

fs.createReadStream() 的工作方式是在内存中准备一段 Buffer,然后在 fs.read() 读取时逐步从磁盘中将字节复制到 Buffer 中,完成一次读取时,则从这个 Buffer 中通过 slice() 方法取出部分数据作为一个小 Buffer 对象,再通过 data 事件传递给调用方。用完则重新分配,如果还有剩余就继续使用

var pool;

function allocNewPool(poolSize) {
    pool = new Buffer(poolSize);
    pool.used = 0;
}

在理想的状态下,每次读取的长度就是用户指定的 highWaterMark 。但有可能文件本身没有 highWaterMark 这么大,此时这个 Buffer 对象会有部分剩余,但是可以分配给下次读取时继续使用。当 pool 单元剩余数量小于 128 (kMinPoolSpace) 字节时,才会重新分配一个新的 Buffer 对象。判断条件如下

if (!pool || pool.length - pool.used < kMinPoolSpace) {
    // discard the old pool
    pool = null;
    allocNewPool(this._readableState.highWaterMark);
}

highWaterMark 的大小对性能有两个影响的点

  1. highWaterMark 设置对 Buffer 内存的分配和使用有一定的影响
  2. highWaterMark 设置过小,可能导致系统调用次数过多

由于 fs.createReadStream() 内部采用 fs.read() 实现,将会引起对磁盘的系统调用,highWaterMark 的大小决定会触发系统调用和 data 事件的次数