一文带你看懂Node的Buffer类

1,759 阅读12分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

背景

TypedArray出来之前,JavaScript这门语言是不能很好地处理原始二进制数据(raw binary data)的,这是因为一开始的时候JavaScript主要还是应用在浏览器中作为脚本语言使用,所以需要处理原生二进制数据的场景是少之又少。而Node出来后,由于服务端的应用需要处理大量的二进制流例如文件读写TCP连接等,所以Node在JavaScript(V8)之外,定义了一种新的数据类型Buffer。由于Buffer在Node应用中使用十分广泛,所以只有真正掌握了它的用法,你才能写出更好的Node应用。

二进制基础

在正式介绍Buffer的具体用法之前,我们先来简单回顾一下有关二进制的知识。

身为程序员,我们应该都不会对二进制感到陌生,因为计算机所有的数据底层都是以二进制(binary)的格式储存的。换句话来说你电脑里面的文件,不管是纯文本还是图片还是视频,在计算机的硬盘里面都是由01这两个数字组成的。在计算机科学中我们把0或者1单个数字叫做一个比特(bit),8个比特可以组成一个字节(byte)。十进制(decimal)数字16如果用1个字节来表示的话,底层存储结构是: 截屏2022-10-15 下午2.23.13.png 我们可以看到16用二进制表示的话相比于十进制的表示一下子多了6位数字,如果数字再大点的话二进制的位数会更多,这样我们无论是阅读还是编写起来都很不方便。因为这个原因,程序员一般喜欢用十六进制(hexadecimal)来表示数据而不是直接使用二进制,例如我们在写CSS的时候color的值用的就是16进制(例如#FFFFFF)而不是一堆0和1。

字符编码

既然所有数据底层都是二进制,网络传输的数据也是二进制的话,为什么我们现在阅读的文章是中文而不是一堆01呢?这里就要介绍一下字符编码的概念了。所谓的字符编码简单来说就是一个映射关系表,它表示的是字符(中文字符、英文字符或者其它字符)是如何和二进制数字(包含若干个字节)对应起来的。举个例子,如果使用我们熟悉的ascii来编码,a这个英文字符的二进制表示是0b01100001(0b是二进制数字的前缀)。因此当我们的电脑从某个以ascii编码的文件中读取到0b01100001这串二进制数据时,就会在屏幕中显示a这个字符,同样a这个字符保存到计算机中或者在网络上传输都是0b01100001这个二进制数据。除了ascii码,常见的字符编码还有utf-8utf-16等。

Buffer

掌握了基本的二进制知识字符编码的概念后,我们终于可以正式学习Buffer了。我们先来看一下Buffer的官方定义:

The Buffer class in Node.js is designed to handle raw binary data. Each buffer corresponds to some raw memory allocated outside V8. Buffers act somewhat like arrays of integers, but aren't resizable and have a whole bunch of methods specifically for binary data. The integers in a buffer each represent a byte and so are limited to values from 0 to 255 inclusive. When using console.log() to print the Buffer instance, you'll get a chain of values in hexadecimal values.

简单来说所谓的Buffer就是Node在V8堆内存之外分配的一块固定大小的内存空间。当Buffer被用console.log打印出来时,会以字节为单位,打印出一串以十六进制表示的值。

创建Buffer

了解完Buffer的基本概念后,我们再来创建一个Buffer对象。创建Buffer的方式有很多种,常见的有Buffer.allocBuffer.allocUnsafeBuffer.from

Buffer.alloc(size[, fill[, encoding]])

这是最常见的创建Buffer的方式,只需要传入Buffer的大小即可

const buff = Buffer.alloc(5)

console.log(buff)
// Prints: <Buffer 00 00 00 00 00>

上面的代码中我创建了一个大小为5个字节的Buffer区域,console.log函数会打印出五个连续的十六进制数字,表示当前Buffer储存的内容。我们可以看到当前的Buffer被填满了0,这是Node默认的行为,我们可以设置后面两个参数fillencoding来指定初始化的时候填入另外的内容。

这里值得一提的是我在上面的代码中使用的是Node全局的Buffer对象,而没有从node:buffer包中显式导入,这完全是因为编写方便,在实际开发中应该采用后者的写法:

import { Buffer } from 'node:buffer'

Buffer.allocUnsafe(size)

Buffer.allocUnsafeBuffer.alloc的最大区别是使用allocUnsafe函数申请到的内存空间是没有被初始化的,也就是说可能还残留了上次使用的数据,因此会有数据安全的问题allocUnsafe函数接收一个size参数作为buffer区域的大小:

const buff = Buffer.allocUnsafe(5)

console.log(buff)
// Prints (实际内容可能有出入): <Buffer 8b 3f 01 00 00>

从上面的输出结果来看我们是控制不了使用Buffer.allocUnsafe分配出来的buffer内容的。也正是由于不对分配过来的内存进行初始化所以这个函数分配Buffer的速度会比Buffer.alloc更快,我们在实际开发中应该根据自己实际的需要进行取舍。

Buffer.from

这个函数是我们最常用的创建Buffer的函数,它有很多不同的重载,也就是说传入不同的参数会有不同的表现行为。我们来看几个常见的重载:

Buffer.from(string[, encoding])

当我们传入的第一个参数是字符串类型时,Buffer.from会根据字符串的编码(encoding参数,默认是utf8)生成该字符串对应的二进制表示。看个例子:

const buff = Buffer.from('你好世界')

console.log(buff)
// Prints: <Buffer e4 bd a0 e5 a5 bd e4 b8 96 e7 95 8c>
console.log(buff.toString())
// Prints: '你好世界'
console.log(buff.toString('ascii'))
// Prints: ''d= e%=d8\x16g\x15\f''

在上面例子中,我使用"你好世界"这个字符串完成了Buffer的初始化工作,由于我没有传入第二个encoding参数,所以默认使用的是utf8编码。后面我们通过查看第一个console.log的输出可以发现,虽然我们传入的字符串只有四个字符,可是初始化的Buffer却有12个字节,这是因为utf8编码中一个汉字会使用3个字节来表示。接着我们通过buff.toString() 方法来查看buff的内容,由于toString方法的默认编码输出格式是utf8,所以我们可以看到第二个console.log可以正确输出buff储存的内容。不过在第三个console.log中我们指定了字符的编码类型是ascii,这个时候我们会看到一堆乱码。看到这里我想你对我之前提到的字符编码一定有更深的认识了。

Buffer.from(buffer)

当Buffer.from接收的参数是一个buffer对象时,Node会创建一个新的Buffer实例,然后将传进来的buffer内容拷贝到新的Buffer对象里面。

const buf1 = Buffer.from('buffer')
const buf2 = Buffer.from(buf1)

console.log(buf1)
// Prints: <Buffer 62 75 66 66 65 72>
console.log(buf2)
// Prints: <Buffer 62 75 66 66 65 72>

buf1[0] = 0x61

console.log(buf1.toString())
// Prints: auffer
console.log(buf2.toString())
// Prints: buffer

在上面的例子中,我们先创建了一个Buffer对象buf1,里面存储的内容是"buffer"这个字符串,然后通过这个Buffer对象初始化了一个新的Buffer对象buf2。这个时候我们将buf1的第一个字节改为0x61(a的编码),我们发现buf1的输出变成了auffer,而buf2的内容却没有发生变化,这也就印证了Buffer.from(buffer)是数据拷贝的观点。

📢注意:当Buffer的数据很大的时候,Buffer.from拷贝数据的性能是很差的,会造成CPU占用飙升,主线程卡死的情况,所以在使用这个函数的时候一定要清楚地知道Buffer.from(buffer)背后都做了什么。笔者就在实际项目开发中踩过这个坑,导致线上服务响应缓慢!

Buffer.from(arrayBuffer[, byteOffset[, length]])

说完了buffer参数,我们再来说一下arrayBuffer参数,它的表现和buffer是有很大的区别的。ArrayBuffer是ECMAScript定义的一种数据类型,它简单来说就是一片你不可以直接(或者不方便)使用的内存,你必须通过一些诸如Uint16ArrayTypedArray对象作为View来使用这片内存,例如一个Uint16Array对象的.buffer属性就是一个ArrayBuffer对象。当Buffer.from函数接收一个ArrayBuffer作为参数时,Node会创建一个新的Buffer对象,不过这个Buffer对象指向的内容还是原来ArrayBuffer的内容,没有任何的数据拷贝行为。我们来看个例子:

const arr = new Uint16Array(2)

arr[0] = 5000
arr[1] = 4000

const buf = Buffer.from(arr.buffer)

console.log(buf)
// Prints: <Buffer 88 13 a0 0f>

// 改变原来数组的数字
arr[1] = 6000

console.log(buf)
// Prints: <Buffer 88 13 70 17>

从上面例子的输出我们可以知道,arrbuf对象会共用同一片内存空间,所以当我们改变原数组的数据时,buf的数据也会发生相应的变化。

其它Buffer操作

看完了创建Buffer的几种做法,我们接着来看一下Buffer其它的一些常用API或者属性

buf.length

这个函数会返回当前buffer占用了多少字节

// 创建一个大小为1234字节的Buffer对象
const buf1 = Buffer.alloc(1234)
console.log(buf1.length)
// Prints: 1234

const buf2 = Buffer.from('Hello')
console.log(buf2.length)
// Prints: 5

Buffer.poolSize

这个字段表示Node会为我们预创建的Buffer池子有多大,它的默认值是8192,也就是8KB。Node在启动的时候,它会为我们预创建一个8KB大小的内存池,当用户用某些API(例如Buffer.alloc)创建Buffer实例的时候可能会用到这个预创建的内存池以提高效率,下面是一个具体的例子:

const buf1 = Buffer.from('Hello')
console.log(buf1.length)
// Prints: 5

// buf1的buffer属性会指向其底层的ArrayBuffer对象对应的内存
console.log(buf1.buffer.byteLength)
// Prints: 8192

const buf2 = Buffer.from('World')
console.log(buf2.length)
// Prints: 5

// buf2的buffer属性会指向其底层的ArrayBuffer对象对应的内存
console.log(buf2.buffer.byteLength)
// Prints: 8192

在上面的例子中,buf1buf2对象由于长度都比较小所以会直接使用预创建的8KB内存池。其在内存的大概表示如图: 截屏2022-12-11 下午1.51.54.png 这里值得一提的是只有当需要分配的内存区域小于4KB(8KB的一半)并且现有的Buffer池子还够用的时候,新建的Buffer才会直接使用当前的池子,否则Node会新建一个新的8KB的池子或者直接在内存里面分配一个区域(FastBuffer)。

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

这个函数可以按照一定的偏移量(offset)往一个Buffer实例里面写入一定长度(length)的数据。我们来看一下具体的例子:

const buf = Buffer.from('Hello')

console.log(buf.toString())
// Prints: "Hello"

// 从第3个位置开始写入'LLO'字符
buf.write('LLO', 2)
console.log("HeLLO")
// Prints: "HeLLO"

这里需要注意的是当我们需要写入的字符串的长度超过buffer所能容纳的最长字符长度(buf.length)时,超过长度的字符会被丢弃:

const buf = Buffer.from('Hello')

buf.write('LLO!', 2)
console.log(buf.toString())
// Print:s "HeLLO"

另外,当我们写入的字符长度超过buffer的最长长度,并且最后一个可以写入的字符不能全部填满时,最后一个字符整个不写入:

const buf = Buffer.from('Hello')

buf.write('LL你', 2)
console.log(buf.toString())
// Prints "HeLLo"

在上面的例子中,由于"你"是中文字符,需要占用三个字节,所以不能全部塞进buf里面,因此整个字符的三个字节都被丢弃了,buf对象的最后一个字节还是保持"o"不变。

Buffer.concat(list[, totalLength])

这个函数可以用来拼接多个Buffer对象生成一个新的buffer。函数的第一个参数是待拼接的Buffer数组,第二个参数表示拼接完的buffer的长度是多少(totalLength)。下面是一个简单的例子:

const buf1 = Buffer.from('Hello')
const buf2 = Buffer.from('World')

const buf = Buffer.concat([buf1, buf2])
console.log(buf.toString())
// Prints "HelloWorld"

上面的例子中,因为我们没有指定最终生成Buffer对象的长度,所以Node会计算出一个默认值,那就是buf.totalLength = buf1.length + buf2.length。而如果我们指定了totalLength的值的话,当这个值比buf1.lengh + buf2.length小时,Node会截断最后生成的buffer;如果指定的值比buf1.length + buf2.length大时,生成buf对象的长度还是totalLength,多出来的位数填充的内容是0。

这里还有一点值得指出的是,Buffer.concat最后拼接出来的Buffer对象是通过拷贝原来Buffer对象得出来,所以改变原来的Buffer对象的内容不会影响到生成的Buffer对象,不过这里我们还是需要考虑拷贝的性能问题就是了。

Buffer对象的垃圾回收

在文章刚开始的时候我就说过Node所有的Buffer对象分配的内存区域都是独立于V8堆空间的,属于堆外内存。那么是否这就意味着Buffer对象不受V8垃圾回收机制的影响需要我们手动管理内存了呢?其实不是的,我们每次使用Node的API创建一个新的Buffer对象的时候,每个Buffer对象都在JavaScript的空间对应着一个对象(Buffer内存的引用),这个对象是受V8垃圾回收控制的,而Node只需要在这个引用被垃圾回收的时候挂一些钩子来释放掉Buffer指向的堆外内存就可以了。简单来说Buffer分配的空间我们不需要操心,V8的垃圾回收机制会帮我们回收掉没用的内存

总结

本篇文章我为大家介绍了Buffer的一些基础知识,包括Buffer常用API和属性,希望这些知识可以对你们的工作有所帮助。

个人技术动态

创作不易,如果你从这篇文章中学到东西,请给我点一下赞或者关注,你的支持是我继续创作的最大动力!