Node Buffer

在Node中,应用要处理网络协议、数据库、图片处理、文件上下载等,在网络流和文件的操作中,还要处理大量二进制数据,于是Buffer应运而生。

Buffer结构

Buffer像一个Array对象,但它主要用于操作字节。

Buffer对象

Buffer对象类似于数组,它的元素为16进制的两位数,即0到255的数值:

1
2
3
4
var str = "我是nodejs"
var buf = new Buffer(str, 'utf-8')
console.log(buf)
// <Buffer e6 88 91 e6 98 af 6e 6f 64 65 6a 73>

由此可见,不同编码的字符串占用的元素个数各不相同,中文在UTF-8编码下占用3个元素,字母和标点符号占用一个元素。

Buffer内存分配

Buffer对象的内存分配不是在V8的堆内存中,而是在Node的C++层面实现的。为了高效的使用申请来的内存,Node采用了slab分配机制。slab是一种动态内存管理机制。

简单而言,slab就是一块申请好的固定大小的内存区域,它有如下3种状态。

  1. full:完全分配
  2. partial:部分分配
  3. empty:没有被分配

Node以8KB为界限来区分Buffer是大对象还是小对象。这个8KB的值也就是一个slab的大小,在JS层面,以它为单位单元来进行内存的分配。

分配小Buffer对象

使用局部变量pool作为中间处理对象。利用pool的used属性记录使用了slab多少个字节。

再次创建Buffer的时候,回判断当前slab的剩余空间是否足够,不够的话就会构造新的slab,那么原来slab中剩余的空间将被浪费。

分配大Buffer对象

如果需要超过8KB的Buffer对象,将回直接分配一个SlowBuffer对象作为slab单元,这个slab单元将会被这个大Buffer对象独占。

总结:真正的内存是在Node的C++层面提供的,JS层面只是使用它。

Buffer的转换

支持的编码类型

1
2
3
4
5
6
ASCII
UTF-8
UTF-16LE/UCS-2
Base64
Binary
Hex

字符串转Buffer

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

1
new Buffer(str, [encoding])

Buffer对象可以存储不同编码类型的字符串转码的值,调用write方法可以实现该目的:

1
buf.write(string, offset, length, encoding)

由于可以不断写入内容到Buffer对象中,并且每次写入可以指定编码,所以Buffer对象中可以存在多种编码转化后的内容,需要小心的是,每种编码所用的字节长度不同,将Buffer反转成字符串要谨慎处理。

Buffer转字符串

实现Buffer向字符串的转换也十分简单,Buffer对象的toString方法可以将Buffer对象转换为字符串:

1
buf.toString(encoding, start, end)

Buffer不支持的编码类型

Node的Buffer对象支持的编码类型有限,只有少数几种编码类型可以在字符串和Buffer之间转换,为此,Buffer提供了一个isEncoding函数来判断编码是否支持转换:

1
Buffer.isEncoding(encoding)

Buffer的拼接

Buffer在使用中,通常是一段一段的方式传输,以下是常见的从输入流中读取内容的示例代码:

1
2
3
4
5
6
7
8
9
var fs = require("fs")
var rs = fs.createReadStream('test.txt', { highWaterMark: 11 })
var data = ''
rs.on('data', function (chunk) {
data += chunk
})
rs.on('end', function () {
console.log(data);
})

我们在test.txt中写入李白的《静夜思》:

1
2
3
4
5
《静夜思》
床前明月光,
疑是地上霜。
举头望明月,
低头思故乡。

运行代码,得到以下结果:

1
2
3
4
5
《静夜思》
床前明月光,
疑是地上霜。
举头望明月,
低头思故乡。

data时间中获取到的chunk既是Buffer,一旦输入流中有宽字节编码时,问题就会暴露出来,问题的起源多半来自这里:

1
data += chunk

这句代码里隐式调用了toString操作,它等价于下面的代码:

1
data = data.toString() + chunk.toString()

对于英文来说,这句代码不会造成任何问题,但是对于宽字节的中文,却会形成问题,我们将可读流的Buffer长度限制为11,然后再次运行代码:

1
var rs = fs.createReadStream('test.txt', { highWaterMark: 11 })

得到结果:

1
2
3
4
5
《静夜��》
床��明月光���
疑是���上霜。
举头望明月,
低头思��乡。

乱码是如何产生的

上面的诗歌,“思”、“前”、“地”、“故”、“,”四个字符没有正常输出,取而代之的是�。产生这个输出结果的原因在于文件可读流在读取时会逐个读取Buffer。这首诗的原始Buffer应为:

1
<Buffer e3 80 8a e9 9d 99 e5 a4 9c e6 80 9d e3 80 8b 0d 0a e5 ba 8a e5 89 8d e6 98 8e e6 9c 88 e5 85 89 ef bc 8c 0d 0a e7 96 91 e6 98 af e5 9c b0 e4 b8 8a e9 ... 45 more bytes>

由于我们限定了Buffer长度为11,因此Buffer变成了:

1
2
3
4
5
6
7
8
9
<Buffer e3 80 8a e9 9d 99 e5 a4 9c e6 80>
<Buffer 9d e3 80 8b 0d 0a e5 ba 8a e5 89>
<Buffer 8d e6 98 8e e6 9c 88 e5 85 89 ef>
<Buffer bc 8c 0d 0a e7 96 91 e6 98 af e5>
<Buffer 9c b0 e4 b8 8a e9 9c 9c e3 80 82>
<Buffer 0d 0a e4 b8 be e5 a4 b4 e6 9c 9b>
<Buffer e6 98 8e e6 9c 88 ef bc 8c 0d 0a>
<Buffer e4 bd 8e e5 a4 b4 e6 80 9d e6 95>
<Buffer 85 e4 b9 a1 e3 80 82>

前面提到中文在UTF-8编码下占用3个字节,所以第一个Buffer对象输出时,只能正常显示三个字符,Buffer中剩下的两个字节(e6 80)会以乱码的形式显示。以此类推,形成了一些问题无法正常显示的问题。

setEncoding与string_decoder

可读流还有一个设置编码的方法setEncoding:

1
2
var rs = fs.createReadStream('test.txt', { highWaterMark: 11 })
rs.setEncoding('utf-8')

重新执行程序,得到正常的输出:

1
2
3
4
5
《静夜思》
床前明月光,
疑是地上霜。
举头望明月,
低头思故乡。

事实上,在调用setEncoding时,可读流对象在内部设置了一个decoder对象,每次data事件都通过该decoder对象进行Buffer到字符串的解码,然后传递给调用者。乱码问题得以解决,还是在于decoder,decoder对象来自于string_decoder模块StringDecoder的实例对象,它的原理可以用下面代码说明:

1
2
3
4
5
6
7
8
9
10
var StringDecoder = require("string_decoder").StringDecoder
var decoder = new StringDecoder("utf-8")

var buf1 = new Buffer([0xe3, 0x80, 0x8a, 0xe9, 0x9d, 0x99, 0xe5, 0xa4, 0x9c, 0xe6, 0x80])
console.log(decoder.write(buf1)); //《静夜

var buf2 = new Buffer([0x9d, 0xe3, 0x80, 0x8b, 0x0d, 0x0a, 0xe5, 0xba, 0x8a, 0xe5, 0x89])
console.log(decoder.write(buf2));
// 思》
//床

“思”的转码并没有如平常一样在两个部分分开输出,StringDecoder在得到编码后,知道宽字节字符在UTF-8编码思三个字节存储的,所以第一次write时只输出前九个字节形成的字符,“思”的前两个字节被保留在StringDecoder内部,第二次write时,将这两个字符和后续11个字节组合在一起,继续以3为倍数进行转码,于是乱码问题就被解决了。

正确拼接Buffer

正确拼接Buffer的解决方案是将多个小Buffer对象拼接为一个Buffer对象,然后通过iconv-lite一类的模块来转码,+=的方式显然不行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var fs = require("fs")
var iconv = require('iconv-lite')

var chunks = []
var size = 0
var rs = fs.createReadStream('test.txt')
rs.on('data', function (chunk) {
chunks.push(chunk)
size += chunk.length
})
rs.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对象复制的过程,实现细腻,值得学习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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时二进制数据,字符串与Buffer之间存在编码关系。