跟着文档学Node(二):Buffer

341 阅读6分钟

本系列文章的目的是在结合文档的前提下深入理解Node.js的各个稳定模块。

在学习的过程中遵循what -> why -> how的思路,即了解该模块是什么、为什么要有这个模块(有什么作用)、怎么去使用该模块的api。

系列文章:

跟着文档学Node(一):Stream

What: Buffer是什么?

Buffer 对象用于表示固定长度的字节序列。

从官方文档中的定义中可以看出两点:Buffer用来存储二进制字节、每个实例有固定长度。有点类似于存放字节的数组。

buffer数组每一项元素是16进制的两位数,即十进制的0到255。换算为二进制就是8位,刚好是一个字节。

const buf = Buffer.from('汉');
console.log(buf); // <Buffer e6 b1 89>

Why: 为什么要有Buffer?

Buffer的中文翻译就是缓冲,从模块名字就可以看出该模块可以用于流式传输过程中的缓冲区。

但Node.js中提出Buffer的概念,主要还是因为在服务器端场景下Node.js程序需要处理很多涉及二进制数据的事情,例如:网络协议、文件上传、数据库等。而JavaScript本身的字符串并不能满足这些需求,因此需要Buffer来帮助处理二进制数据。

How: Buffer的妙用

内存分配

Buffer对象的内存分配并不是在V8的堆内存中,而是由C++层来向操作系统单独申请内存。

因为处理二进制数据时可能频繁地提出内存申请。而频繁地向操作系统提出申请会对操作系统造成过大的压力,因此Node.js采用的策略是C++层先申请好内存,然后JS层来分配这块预先申请好的内存。即一次申请,多次分配。

这个申请的内存大小由Buffer.poolSize来决定,该值可以修改,默认值是8192,即8kb。

在程序初始化后C++会申请一块poolSize大小的内存空间,称为slab。

随后js在创建Buffer对象时,会判断该Buffer对象是大对象还是小对象。小于poolSize是小对象,反之是大对象。

对于大对象,则会由C++另外申请一块和该Buffer对象大小相同的内存空间单独给该对象使用。

对于小对象,则会分配slab剩余的空间给该对象,如果剩余空间不足,则由C++申请一块新的slab空间:

image.png

初始化

Buffer类有几个创建Buffer对象的函数:alloc、allocUnsafe、allocUnsafeSlow、from。

alloc

Buffer.alloc可以指定新Buffer对象的长度和填充对象的初始化值。它在新建对象后会调用fill方法来填充新对象的值。

image.png

allocUnsafe

Buffer.allocUnsafe 只能指定新Buffer对象的长度。因为它在创建时并不会填充新对象的值。

缺点是allocUnsafe初始化后的Buffer对象可能包含之前的数据。

好处是少了初始化填充这一步,创建的速度会更快。

因此当我们想在创建Buffer对象后重新赋值,应该使用allocUnsafe方法。

allocUnsafeSlow

allocUnsafeSlow和 allocUnsafe的不同点在于前者创建Buffer时并不会占用预先申请的slab内存。所以无论allocUnsafeSlow 申请的大小是多少都会重新申请一块新的内存来存放新对象。

from

Buffer.from可以从字符串、数组、Buffer对象中获得初始值去创建新的Buffer对象。

字符转换

Buffer对象支持的字符编码如下:

  • UTF-8
  • UTF-16LE/UCS-2
  • HEX
  • ASCII
  • base64
  • latin1

如果对字符编码不太了解,👉初识字符编码

从Buffer对象转换为字符串可以使用Buffer.toString,从字符串转换为Buffer对象可以使用Buffer.from或者Buffer.fill。

以上方法都支持encoding参数来设置转换过程中使用的字符编码。

拼接

Buffer对象的拼接不能直接用运算符加号来拼接,因为Buffer对象遇到加号时会先调用toString(),然后再相加得到新的字符串:

const buf = Buffer.from("abc");
console.log(Object.prototype.toString.call(buf + 123)); // [object String]

正确的做法是使用Buffer.concat来拼接:

const buf = Buffer.from('abc');
const buf2 = Buffer.from('123');
console.log(Object.prototype.toString.call(Buffer.concat([buf, buf2]))); // [object Uint8Array]

使用场景

流式读取大文件

在使用Stream对象来流式传输数据时,可以使用Buffer对象来作为缓冲区,具体见 跟着文档学Node(一):Stream

网络传输

Node.js在进行网络传输时,会把字符串转换为Buffer对象,再进行二进制数据传输。因此如果能将传输的字符串数据提前转换为Buffer对象,那么就不需要每次调用网络IO时再进行转换。这样能大大提升传输效率。

扩展

ArrayBuffer

ArrayBuffer本质上是一块存储着二进制数据的内存空间。

const buf = new ArrayBuffer(1024);

上述代码开辟了一块新的1kb的内存,但我们不能直接操作这块ArrayBuffer内存,而是需要用一层抽象的视图层来操作。

视图层则分为两类:TypedArray和DataView。它们的区别是TypedArray适用于简单操作,DataView适合复杂复合的操作。更多介绍可以参考 二进制数组

视图层的作用是指定一个规则来读取ArrayBuffer内存块中的数据,不同的视图层类型有不同的读取规则。

例如TypedArray有Int8Array和Int16Array这两个类型,前者将ArrayBuffer的8位二进制视为数组的一个元素,而后者将16位二进制视为一个元素。

看个例子:

const arrayBuffer = new ArrayBuffer(32);
const int8View = new Int8Array(arrayBuffer);
const int16View = new Int16Array(arrayBuffer);
int8View[0] = 1;
int8View[1] = 1;
console.log(int16View[0]); // 257

image.png

因为Int8Array是用8位二进制作为一个元素,因此我们把int8View的第0项设置为1后,ArrayBuffer的前8位二进制就变为了00000001。同理设置了int8View的第1项后就ArrayBuffer就变为上图所示。

此时再使用int16View来读取同一个ArrayBuffer时,int16View[0]就是指向了前16位数据,即0000000100000001,转换为十进制就是257。

ArrayBuffer 和 Buffer

在引入TypedArray之前,js是没有操作二进制数据的能力的。而上文提到Buffer模块提供了操作二进制数据的功能。那么Buffer模块和TypedArray或者DataView有什么关系吗?

实际上Buffer就是属于TypedArray中的Uint8Array类。我们可以从源码中看到Buffer模块在新建Buffer实例时是会返回FastBuffer实例:

image.png

而FastBuffer确实是继承了Uint8Array:

image.png

也就是说Buffer模块封装了Uint8Array类,使开发者可以使用Buffer模块的api,方便地去操作ArrayBuffer。

SharedArrayBuffer

众所周知js是单线程语言,为了承担密集CPU计算任务,web worker引入了多线程。

web worker中的worker线程可以通过postMessage和主线程通信。如果在两个线程间需要通信数据,常见的做法是复制一份数据传输给另外一个线程。这种做法的缺点是通信效率比较低。

于是ES8引入了SharedArrayBuffer,它和ArrayBuffer的区别就在于它可以被worker线程和主线程共享。