在引入 TypedArray 之前,JavaScript 语言没有用于读取或操作二进制数据流的机制。 Buffer 类是作为 Node.js API 的一部分引入的,用于在 TCP 流、文件系统操作、以及其他上下文中与八位字节流进行交互。这是来自 Node.js 官网的一段描述,比较晦涩难懂,总结起来一句话 Node.js 可以用来处理二进制流数据或者与之进行交互。
Buffer 用于读取或操作二进制数据流,做为 Node.js API 的一部分使用时无需 require,用于操作网络协议、数据库、图片和文件 I/O 等一些需要大量二进制数据的场景。Buffer 在创建时大小已经被确定且是无法调整的,在内存分配这块 Buffer 是由 C++ 层面提供而不是 V8 具体后面会讲解。
在这里不知道你是否认为这是很简单的?但是上面提到的一些关键词二进制、流(Stream)、缓冲区(Buffer),这些又都是什么呢?下面尝试做一些简单的介绍。
二进制
计算机只识别二进制数据
Stream
什么是流
流是为 Node.js 应用程序提供动力的基本概念之一。
它们是一种以高效的方式处理读/写文件、网络通信、或任何类型的端到端的信息交换。
流不是 Node.js 特有的概念。 它们是几十年前在 Unix 操作系统中引入的,程序可以通过管道运算符(|)对流进行相互交互。
流,英文 Stream 是对输入输出设备的抽象,这里的设备可以是文件、网络、内存等。
流是有方向性的,当程序从某个数据源读入数据,会开启一个输入流,这里的数据源可以是文件或者网络等,例如我们从 a.txt 文件读入数据。相反的当我们的程序需要写出数据到指定数据源(文件、网络等)时,则开启一个输出流。当有一些大文件操作时,我们就需要 Stream 像管道一样,一点一点的将数据流出。
为什么是流
举个例子
我们现在有一大罐水需要浇一片菜地,如果我们将水罐的水一下全部倒入菜地,首先得需要有多么大的力气(这里的力气好比计算机中的硬件性能)才可搬得动。如果,我们拿来了水管将水一点一点流入我们的菜地,这个时候不要这么大力气就可完成。
例如,在传统的方式中,当告诉程序读取文件时,这会将文件从头到尾读入内存,然后进行处理。
使用流,则可以逐个片段地读取并处理(而无需全部保存在内存中)。
Node.js 的stream模块提供了构建所有流 API 的基础。 所有的流都是EventEmitter的实例。
相对于使用其他的数据处理方法,流基本上提供了两个主要优点:
- 内存效率: 无需加载大量的数据到内存中即可进行处理。
- 时间效率: 当获得数据之后即可立即开始处理数据,这样所需的时间更少,而不必等到整个数据有效负载可用才开始。
流的示例
一个典型的例子是从磁盘读取文件。
使用 Node.js 的fs模块,可以读取文件,并在与 HTTP 服务器建立新连接时通过 HTTP 提供文件: ``` const http = require(‘http’) const fs = require(‘fs’)
const server = http.createServer(function(req, res) { fs.readFile(__dirname + ‘/data.txt’, (err, data) => { res.end(data) }) }) server.listen(3000)
readFile()读取文件的全部内容,并在完成时调用回调函数。<br />回调中的res.end(data)会返回文件的内容给 HTTP 客户端。<br />如果文件很大,则该操作会花费较多的时间。 以下是使用流编写的相同内容:
const http = require(‘http’) const fs = require(‘fs’)
const server = http.createServer((req, res) => { const stream = fs.createReadStream(__dirname + ‘/data.txt’) stream.pipe(res) }) server.listen(3000)
当要发送的数据块已获得时就立即开始将其流式传输到 HTTP 客户端,而不是等待直到文件被完全读取。
<a name="UkpPe"></a>
## pipe()
上面的示例使用了stream.pipe(res)这行代码:在文件流上调用pipe()方法。<br />该代码的作用是什么? 它获取来源流,并将其通过管道传输到目标流。<br />在来源流上调用它,在该示例中,文件流通过管道传输到 HTTP 响应。<br />pipe()方法的返回值是目标流,这是非常方便的事情,它使得可以链接多个pipe()调用,如下所示:
src.pipe(dest1).pipe(dest2)
此构造相对于:
src.pipe(dest1) dest1.pipe(dest2)
<a name="Jw6wl"></a>
## 流驱动的 Node.js API
由于它们的优点,许多 Node.js 核心模块提供了原生的流处理功能,最值得注意的有:
- process.stdin返回连接到 stdin 的流。
- process.stdout返回连接到 stdout 的流。
- process.stderr返回连接到 stderr 的流。
- fs.createReadStream()创建文件的可读流。
- fs.createWriteStream()创建到文件的可写流。
- net.connect()启动基于流的连接。
- http.request()返回 http.ClientRequest 类的实例,该实例是可写流。
- zlib.createGzip()使用 gzip(压缩算法)将数据压缩到流中。
- zlib.createGunzip()解压缩 gzip 流。
- zlib.createDeflate()使用 deflate(压缩算法)将数据压缩到流中。
- zlib.createInflate()解压缩 deflate 流。
<a name="NJMbt"></a>
## 不同类型的流
流分为四类:
- Readable: 可以通过管道读取、但不能通过管道写入的流(可以接收数据,但不能向其发送数据)。 当推送数据到可读流中时,会对其进行缓冲,直到使用者开始读取数据为止。
- Writable: 可以通过管道写入、但不能通过管道读取的流(可以发送数据,但不能从中接收数据)。
- Duplex: 可以通过管道写入和读取的流,基本上相对于是可读流和可写流的组合。
- Transform: 类似于双工流、但其输出是其输入的转换的转换流。
<a name="AgRC6"></a>
## 如何创建可读流
从[stream模块](http://nodejs.cn/api/stream.html)获取可读流,对其进行初始化并实现readable._read()方法。<br />首先创建流对象:
const Stream = require(‘stream’) const readableStream = new Stream.Readable()
然后实现_read:
readableStream._read = () => {}
也可以使用read选项实现_read:
const readableStream = new Stream.Readable({ read() {} })
现在,流已初始化,可以向其发送数据了:
readableStream.push(‘hi!’) readableStream.push(‘ho!’)
<a name="L0vSw"></a>
## 如何创建可写流
若要创建可写流,需要继承基本的Writable对象,并实现其_write()方法。<br />首先创建流对象:
const Stream = require(‘stream’) const writableStream = new Stream.Writable()
然后实现_write:
writableStream._write = (chunk, encoding, next) => { console.log(chunk.toString()) next() }
现在,可以通过以下方式传输可读流:
process.stdin.pipe(writableStream)
<a name="nMMv4"></a>
## 如何从可读流中获取数据
如何从可读流中读取数据? 使用可写流:
const Stream = require(‘stream’)
const readableStream = new Stream.Readable({ read() {} }) const writableStream = new Stream.Writable()
writableStream._write = (chunk, encoding, next) => { console.log(chunk.toString()) next() }
readableStream.pipe(writableStream)
readableStream.push(‘hi!’) readableStream.push(‘ho!’) 也可以使用readable事件直接地消费可读流: readableStream.on(‘readable’, () => { console.log(readableStream.read()) })
<a name="UuAx6"></a>
## 如何发送数据到可写流
使用流的write()方法:
writableStream.write(‘hey!\n’)
<a name="R0VOz"></a>
## 使用信号通知已结束写入的可写流
使用end()方法:
const Stream = require(‘stream’)
const readableStream = new Stream.Readable({ read() {} }) const writableStream = new Stream.Writable()
writableStream._write = (chunk, encoding, next) => { console.log(chunk.toString()) next() }
readableStream.pipe(writableStream)
readableStream.push(‘hi!’) readableStream.push(‘ho!’)
writableStream.end()
<a name="Aokd8"></a>
# Buffer
<a name="RQZY5"></a>
## 什么是缓冲区
通过以上 Stream 的讲解,我们已经看到数据是从一端流向另一端,那么他们是如何流动的呢?
通常,数据的移动是为了处理或者读取它,并根据它进行决策。伴随着时间的推移,每一个过程都会有一个最小或最大数据量。如果数据到达的速度比进程消耗的速度快,那么少数早到达的数据会处于等待区等候被处理。反之,如果数据到达的速度比进程消耗的数据慢,那么早先到达的数据需要等待一定量的数据到达之后才能被处理。
这里的等待区就指的缓冲区(Buffer),它是计算机中的一个小物理单位,通常位于计算机的 RAM 中。<br />Buffer 是内存区域。 JavaScript 开发者可能对这个概念并不熟悉,比每天与内存交互的 C、C++ 或 Go 开发者(或使用系统编程语言的任何程序员)要少得多。<br />它表示在 V8 JavaScript 引擎外部分配的固定大小的内存块(无法调整大小)。<br />可以将 buffer 视为整数数组,每个整数代表一个数据字节。<br />它由 Node.js[Buffer 类](http://nodejs.cn/api/buffer.html)实现。
<a name="F1f5P"></a>
## 为什么需要 buffer?
**公共汽车站乘车例子**<br />举一个公共汽车站乘车的例子,通常公共汽车会每隔几十分钟一趟,在这个时间到达之前就算乘客已经满了,车辆也不会提前发车,早到的乘客就需要先在车站进行等待。假设到达的乘客过多,后到的一部分则需要在公共汽车站等待下一趟车驶来。<br />![](https://cdn.nlark.com/yuque/0/2021/webp/248010/1620742152209-969d276e-91bd-4e91-85d1-0dd3b07ba8fa.webp#clientId=uaa57d5df-62c7-4&from=paste&height=320&id=u484a0f56&margin=%5Bobject%20Object%5D&originHeight=640&originWidth=1280&originalType=url&status=done&style=none&taskId=u2b569ba2-d955-468b-b308-d6897cc0a42&width=640)<br />在上面例子中的等待区公共汽车站,对应到我们的 Node.js 中也就是缓冲区(Buffer),另外乘客到达的速度是我们不能控制的,我们能控制的也只有何时发车,对应到我们的程序中就是我们无法控制数据流到达的时间,可以做的是能决定何时发送数据。
Buffer 被引入用以帮助开发者处理二进制数据,在此生态系统中传统上只处理字符串而不是二进制数据。<br />Buffer 与流紧密相连。 当流处理器接收数据的速度快于其消化的速度时,则会将数据放入 buffer 中。<br />一个简单的场景是:当观看 YouTube 视频时,红线超过了观看点:即下载数据的速度比查看数据的速度快,且浏览器会对数据进行缓冲。
<a name="lnu5P"></a>
## Buffer基本使用
<a name="BrBVP"></a>
### 如何创建 buffer
使用[Buffer.from()](http://nodejs.cn/api/buffer.html#buffer_buffer_from_buffer_alloc_and_buffer_allocunsafe)、[Buffer.alloc()](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_alloc_size_fill_encoding)和[Buffer.allocUnsafe()](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafe_size)方法可以创建 buffer。
const buf = Buffer.from(‘Hey!’)
- [Buffer.from(array)](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_from_array)
- [Buffer.from(arrayBuffer[, byteOffset[, length]])](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length)
- [Buffer.from(buffer)](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_from_buffer)
- [Buffer.from(string[, encoding])](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_from_string_encoding)
也可以只初始化 buffer(传入大小)。 以下会创建一个 1KB 的 buffer:
const buf = Buffer.alloc(1024) //或 const buf = Buffer.allocUnsafe(1024)
虽然alloc和allocUnsafe均分配指定大小的Buffer(以字节为单位),但是alloc创建的Buffer会被使用零进行初始化,而allocUnsafe创建的Buffer不会被初始化。 这意味着,尽管allocUnsafe比alloc要快得多,但是分配的内存片段可能包含可能敏感的旧数据。<br />当Buffer内存被读取时,如果内存中存在较旧的数据,则可以被访问或泄漏。 这就是真正使allocUnsafe不安全的原因,在使用它时必须格外小心。
<a name="Tuezr"></a>
### 访问 buffer 的内容
Buffer(字节数组)可以像数组一样被访问:
const buf = Buffer.from(‘Hey!’) console.log(buf[0]) //72 console.log(buf[1]) //101 console.log(buf[2]) //121
这些数字是 Unicode 码,用于标识 buffer 位置中的字符(H => 72、e => 101、y => 121)。<br />可以使用toString()方法打印 buffer 的全部内容:
console.log(buf.toString())
注意,如果使用数字(设置其大小)初始化 buffer,则可以访问到包含随机数据的已预初始化的内存(而不是空的 buffer)!
<a name="FTMqk"></a>
### 获取 buffer 的长度
使用length属性:
const buf = Buffer.from(‘Hey!’) console.log(buf.length)
<a name="VyR53"></a>
### 迭代 buffer 的内容
const buf = Buffer.from(‘Hey!’) for (const item of buf) { console.log(item) //72 101 121 33 }
<a name="EJi5C"></a>
### 更改 buffer 的内容
可以使用write()方法将整个数据字符串写入 buffer:
const buf = Buffer.alloc(4) buf.write(‘Hey!’)
就像可以使用数组语法访问 buffer 一样,你也可以使用相同的方式设置 buffer 的内容:
const buf = Buffer.from(‘Hey!’) buf[1] = 111 //o console.log(buf.toString()) //Hoy!
<a name="c4L81"></a>
### 复制 buffer
使用copy()方法可以复制 buffer:
const buf = Buffer.from(‘Hey!’) let bufcopy = Buffer.alloc(4) //分配 4 个字节。 buf.copy(bufcopy)
默认情况下,会复制整个 buffer。 另外的 3 个参数可以定义开始位置、结束位置、以及新的 buffer 长度:
const buf = Buffer.from(‘Hey!’) let bufcopy = Buffer.alloc(2) //分配 2 个字节。 buf.copy(bufcopy, 0, 0, 2) bufcopy.toString() //‘He’
<a name="E4ax9"></a>
### 切片 buffer
如果要创建 buffer 的局部视图,则可以创建切片。 切片不是副本:原始 buffer 仍然是真正的来源。 如果那改变了,则切片也会改变。<br />使用slice()方法创建它。 第一个参数是起始位置,可以指定第二个参数作为结束位置:
const buf = Buffer.from(‘Hey!’) buf.slice(0).toString() //Hey! const slice = buf.slice(0, 2) console.log(slice.toString()) //He buf[1] = 111 //o console.log(slice.toString()) //Ho
<a name="Sqfbt"></a>
## Buffer vs Cache
**缓冲(Buffer)**<br />缓冲(Buffer)是用于处理二进制流数据,将数据缓冲起来,它是临时性的,对于流式数据,会采用缓冲区将数据临时存储起来,等缓冲到一定的大小之后在存入硬盘中。视频播放器就是一个经典的例子,有时你会看到一个缓冲的图标,这意味着此时这一组缓冲区并未填满,当数据到达填满缓冲区并且被处理之后,此时缓冲图标消失,你可以看到一些图像数据。<br />**缓存(Cache)**<br />缓存(Cache)我们可以看作是一个中间层,它可以是永久性的将热点数据进行缓存,使得访问速度更快,例如我们通过 Memory、Redis 等将数据从硬盘或其它第三方接口中请求过来进行缓存,目的就是将数据存于内存的缓存区中,这样对同一个资源进行访问,速度会更快,也是性能优化一个重要的点。
<a name="X7b6i"></a>
## Buffer内存机制
在 [Nodejs 中的 内存管理和 V8 垃圾回收机制](https://www.nodejs.red/#/nodejs/memory) 一节主要讲解了在 Node.js 的垃圾回收中主要使用 V8 来管理,但是并没有提到 Buffer 类型的数据是如何回收的,下面让我们来了解 Buffer 的内存回收机制。<br />由于 Buffer 需要处理的是大量的二进制数据,假如用一点就向系统去申请,则会造成频繁的向系统申请内存调用,所以 Buffer 所占用的内存**不再由 V8 分配**,而是在 Node.js 的 **C++ 层面完成申请**,在 **JavaScript 中进行内存分配**。因此,这部分内存我们称之为**堆外内存**。<br />**注意**:以下使用到的 buffer.js 源码为 Node.js v10.x 版本,地址:[github.com/nodejs/node…](https://github.com/nodejs/node/blob/v10.x/lib/buffer.js)
<a name="e5dJo"></a>
### Buffer内存分配原理
Node.js 采用了 slab 机制进行**预先申请、事后分配**,是一种动态的管理机制。<br />使用 Buffer.alloc(size) 传入一个指定的 size 就会申请一块固定大小的内存区域,slab 具有如下三种状态:
- full:完全分配状态
- partial:部分分配状态
- empty:没有被分配状态
**8KB 限制**<br />Node.js 以 8KB 为界限来区分是小对象还是大对象,在 [buffer.js](https://github.com/nodejs/node/blob/v10.x/lib/buffer.js) 中可以看到以下代码
Buffer.poolSize = 8 * 1024; // 102 行,Node.js 版本为 v10.x
在 **Buffer 初识** 一节里有提到过 Buffer 在创建时大小已经被确定且是无法调整的 到这里应该就明白了。<br />**Buffer 对象分配**<br />以下代码示例,在加载时直接调用了 createPool() 相当于直接初始化了一个 8 KB 的内存空间,这样在第一次进行内存分配时也会变得更高效。另外在初始化的同时还初始化了一个新的变量 **poolOffset = 0** 这个变量会记录已经使用了多少字节。
Buffer.poolSize = 8 * 1024; var poolSize, poolOffset, allocPool;
… // 中间代码省略
function createPool() { poolSize = Buffer.poolSize; allocPool = createUnsafeArrayBuffer(poolSize); poolOffset = 0; } createPool(); // 129 行
此时,新构造的 slab 如下所示:<br />![](https://cdn.nlark.com/yuque/0/2021/webp/248010/1620743252039-b9d4f80d-db76-44bd-b531-a14430f7a7d7.webp#clientId=uaa57d5df-62c7-4&from=paste&height=164&id=uc98ab22c&margin=%5Bobject%20Object%5D&originHeight=328&originWidth=1276&originalType=url&status=done&style=none&taskId=u01f4374b-e6e3-4347-a10d-d91b2522404&width=638)<br />现在让我们来尝试分配一个大小为 2048 的 Buffer 对象,代码如下所示:
Buffer.alloc(2 * 1024)
现在让我们先看下当前的 slab 内存是怎么样的?如下所示:<br />![](https://cdn.nlark.com/yuque/0/2021/webp/248010/1620743252005-2bf12fa1-5781-48e0-9e48-734d7aef9e23.webp#clientId=uaa57d5df-62c7-4&from=paste&height=184&id=u7082c633&margin=%5Bobject%20Object%5D&originHeight=367&originWidth=1280&originalType=url&status=done&style=none&taskId=ud45031e3-87e2-4640-bf19-75f593807f5&width=640)<br />那么这个分配过程是怎样的呢?让我们再看 buffer.js 另外一个核心的方法 allocate(size)<br />// https://github.com/nodejs/node/blob/v10.x/lib/buffer.js#L318
function allocate(size) { if (size <= 0) { return new FastBuffer(); }
// 当分配的空间小于 Buffer.poolSize 向右移位,这里得出来的结果为 4KB if (size < (Buffer.poolSize >>> 1)) { if (size > (poolSize - poolOffset)) createPool(); var b = new FastBuffer(allocPool, poolOffset, size); poolOffset += size; // 已使用空间累加 alignPool(); // 8 字节内存对齐处理 return b; } else { // C++ 层面申请 return createUnsafeBuffer(size); } } ```
读完上面的代码,已经很清晰的可以看到何时会分配小 Buffer 对象,又何时会去分配大 Buffer 对象。
Buffer 内存分配总结
这块内容着实难理解,翻了几本 Node.js 相关书籍,朴灵大佬的「深入浅出 Node.js」Buffer 一节还是讲解的挺详细的,推荐大家去阅读下。
- 在初次加载时就会初始化 1 个 8KB 的内存空间,buffer.js 源码有体现
- 根据申请的内存大小分为 小 Buffer 对象 和 大 Buffer 对象
- 小 Buffer 情况,会继续判断这个 slab 空间是否足够
- 如果空间足够就去使用剩余空间同时更新 slab 分配状态,偏移量会增加
- 如果空间不足,slab 空间不足,就会去创建一个新的 slab 空间用来分配
- 大 Buffer 情况,则会直接走 createUnsafeBuffer(size) 函数
- 不论是小 Buffer 对象还是大 Buffer 对象,内存分配是在 C++ 层面完成,内存管理在 JavaScript 层面,最终还是可以被 V8 的垃圾回收标记所回收。
References
https://juejin.cn/post/6844903897438371847#heading-11
http://nodejs.cn/learn/nodejs-streams