理解Buffer
在Node中,应用需要处理网络协议、操作数据库、处理图片、接收上传文件等,在网络流和文件的操作上,还要处理大量二进制数据,JavaScript只有的字符串远远不能满足这些需求,于是Buffer对象应运而生。
Buffer结构
Buffer是一个像Array的对象,但它主要用于操作字符串,Buffer是一个典型的JavaScript与C++结合的模块,它将性能相关部分由C++实现(node_buffer),将非性能相关的部分用JavaScript实现(Buffer/SlowBuffer)。Buffer所占用的内存不是通过V8分配的,属于堆外内存,由于V8垃圾回收性能的影响,将常用的操作对象用更高效和专有的内存分配回收策略来管理,由于Buffer太过常见,Node在进程启动时就已经加载了它,并将其放在全局对象(global)上,所以在使用Buffer时,无需通过require()即可以即可直接使用
Buffer对象
Buffer对象类似于数组,它的元素为16进制的两位数,即0-255的数值。
var str = "深入浅出node.js";
var buf = new Buffer(str, 'utf-8');
console.log(buf);
console.log(buf.length);
// => <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73>
// length: 19,可以使用length访问到长度
// buf[2]: 177 ,也可以使用下标访问到元素,b1的16进制转换为10进制为177,11*16 + 1
中文在utf-8编码下占用3个元素,字母和半角标点符号占用1个元素,在最新的版本 new Buffer()已经废弃,在这里可以使用Buffer.from代替
// 分配一个长100字节的Buffer对象
var buf = Buffer.alloc(100);
console.log(buf.length); // => 100
console.log(buf[10]); // 0,未主动赋值,每个元素都将是0
buf[20] = 100;
console.log(buf[20]); // 主动赋值之后,将是赋值后的结果100
buf[21] = -100;
console.log(buf[21]); // 156,赋值如果不在0-255之间,如果小于0则是加上256,
buf[22] = 300;
console.log(buf[22]); // 44,如果大于255,则减去256
Buffer的内存分配
Buffer对象的内存分配不是在V8的堆内存中,而是在Node的C++层实现内存的申请的。因为处理大量的字节数据不能采用需要一点内存就像操作系统申请一点内存的方式,这可能造成大量内存申请的系统调用,对操作系统有一定的压力,为此Node在内存的使用上应用的是C++层面申请内存,在JavaScript中分配内存的策略。
为了高效的使用申请来的内存,Node采用了slab分配机制,slab是一种动态内存管理机制,简单而言,slab就是一块申请好的固定大小的内存局域,具有以下3中状态。
-
full:完全分配状态
-
partial:部分分配状态
-
empty: 没有被分配状态
当我们需要一个Buffer对象,可以使用const buf1 = Buffer.alloc(size);
创建一个指定大小(size)的Buffer对象,Node以8KB为界限来区分Buffer是大对象还是小对象,这个8KB的值也就是每个slab的大小值。在JavaScript层面,以它作为单位单元进行内存的分配。
分配小Buffer对象
当构造小于8KB的Buffer对象时,它会去检查一个全局变量pool是否有被创建(slab指向这个pool变量),如果没有,则会创建一个新的slab单元指向它,新创建的slab状态为empty,同时将当前Buffer对象parent属性指向该slab,并记录是从这个slab的哪个位置开始使用的(offset),slab对象自身也记录被使用了多少字节(used),分配之后状态会变为partial。
当再次创建一个Buffer对象时,构造过程中会判断这个slab的剩余空间是否足够,如果足够,使用剩余空间,并更新slab的分配状态。如果不够将会构造新的slab,原slab中剩余的空间将会被浪费。
new Buffer(1);
new Buffer(8192);
这种情况,第一个slab的8KB将会被1字节的Buffer独占,因为第二次分配时空间不够,将会创建新的slab,同时需要注意的是,只有这些小Buffer对象都可以被回收的时候,slab的8KB空间才会被回收,尽管创建了1个字节的Buffer对象,但是如果不释放它,实际可能就是8KB的内存没有释放。
分配大Buffer对象
如果需要超过8KB的Buffer对象,将会直接分配一个SlowBuffer对象作为slab单元,这个slab单元将会被这个大Buffer对象独占。这里的SlowBuffer类是在C++中定义的,虽然引用buffer可以访问它,但是不推荐直接操作它,而是用Buffer替代。
上面提到的Buffer对象都是JavaScript层面的,能够被V8的垃圾回收标记回收,但是其内部的parent属性指向的SlowBuffer对象却来自于Node自身C++中的定义,是C++层面上的Buffer对象,所用内存不爱V8的堆中。
{{% notice tip %}}
简单而言,真正的内存是在Node的C++层面提供的,JavaScript层面都只是使用它,当进行小而频繁的Buffer操作时,采用slab的机制进行预先申请和事后分配,使用JavaScript到操作系统之间不必有过多的内存申请方面的系统调用,对于大块的Buffer而言,则直接使用C++层面提供的内存,而无需细腻的分配操作。
{{% /notice %}}
Buffer的转换
Buffer对象可以与字符串之间相互转换,支持的字符串编码有'ASCII'、'UTF-8'、'UTF-16LE/UCS-2'、'Base64'、'Binary'、'Hex'。
字符串转Buffer
Buffer.from(str, [encoding])
// 编码是可选项,默认使用UTF-8进行转码和存储
一个BUffer对象可以使用存储不同编码类型的字符串转码的值,调用write方法可以实现该目的
buf.write(string, [offset], [length], [encoding])
// 每次写入可以指定编码
当一个Buffer存在不同编码时,需要小心的是,每个编码所使用的字节长度不同,将Buffer反转回字符串时需要谨慎处理。
Buffer转字符串
buf.toString([encoding], [start], [end])
使用toString方法进行转换,start和end参数可以实现整体和局部的转换,如果Buffer对象由多种编码写入,就需要在局部指定不同的编码,才能正常转换回正常的编码。
Buffer不支持的编码类型
Buffer提供了一个isEncoding()函数来判断编码是否支持转换。
Buffer.isEncoding(encoding)
// 返回布尔值,true为支持
在中国常用的GBK、GB2312和BIG-5编码都不再支持的行列中,对于不支持的编码类型可以使用Node生态圈中的模块完成转换。最常用的有iconv和iconv-lite两个模块。
iconv-lite采用纯JavaScript实现,iconv则通过C++调用libiconv库完成,前者比后者更轻量,无需编译和处理环境依赖直接使用,在性能方面,由于转码都是耗用CPU,在V8的高性能下,少了C++到JavaScript的层次转换,纯JavaScript的性能比C++实现得更好。
Buffer的拼接
var fs = require('fs');
var rs = fs.createReadStream('test.md', {highWaterMark: 11});
// 将文件可读流的每次读取的Buffer长度限制为11,导致了乱码的出现
var data = '';
rs.on("data", function (chunk){
data += chunk;
});
rs.on("end", function () {
console.log(data);
// 窗前明��光,疑���地上霜。举头��明月,���头思故乡。
// test.md中,写的是李白的静夜思
});
上面这段代码常见于流读取的示范,data事件中获取的chunk对象即是Buffer对象,一般初学者,容易将Buffer对象当做字符串来理解,但是一旦输入流中有宽字节编码时,问题就会暴露。
出现问题的代码即Buffer的拼接,data += chunk;
,这里其实等价于data = data.toString() + chunk.toString();
,对于英文环境不会出现问题,但是对于宽字节的中文就会形成乱码问题。
乱码是如何产生的
由于我们限定了Buffer对象的长度为11,上面提到的toString()方法默认以UTF-8为编码,中文字在UTF-8占3个字节,所以就会有中文字节被截断读取的情况,形成乱码。虽然示例中是我们自己构造了11这个限制,但是对于任意长度的Buffer而言,宽字节字符串都有可能存在被截断的情况,只不过Buffer的长度越大出现的概率越低而已,但该问题依然不可忽视。
setEncoding()和string_decoder()
可读流有一个设置编码的方法setEncoding(),该方法的作用是让data事件中传递不再是一个Buffer对象,而是编码后的字符串。
// 修改上例代码为下
var rs = fs.createReadStream('test.md', { highWaterMark: 11});
rs.setEncoding('utf8');
// 得到正确的输出
拿读取静夜思来说,限制每次读取Buffer的长度限制为11,大概需要读取7次,在设置编码后这个次数并不会改变,意味着设置编码并未改变按段读取的基本方式。
事实上,在调用setEncoding()时,可读流对象在内部设置了一个decoder对象,每次data事件都通过该decoder对象进行Buffer到字符串的解码,然后传递给调用者,decoder对象来自于string_decoder模块的StringDecoder的实例对象,它在得到编码后,知道宽字节字符串在UTF-8编码下是以3个字节的方式存储的,所以在第一次的时候,只会输出前9个字节转码形成的字符,将剩余的2个字节和后续的11个字节组合在一起,再次使用3的整数倍字节进行转码,于是解决了乱码问题,但是它并非万能的,它目前只支持UTF-8、Base64和UCS-2/UTF-16LE这3中编码,可以通过setEncoding()解决大部分的问题,但并不难能从根源上解决问题。
正确拼接Buffer
解决方案为将多个小Buffer对象拼接为一个Buffer对象,然后通过iconv-lite一类的模块来转码这种方式。
var fs = require('fs');
var iconv = require('iconv-lite');
var res = fs.createReadStream('test.md', {highWaterMark: 11});
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对象,然后使用iconv-lite模块进行转码。Buffer.concat()方法封装了从小Buffer对象向大Buffer对象的复制过程,使用了Buffer实例的copy方法,拷贝 buf 中某个区域的数据到 target 中的某个区域。
Buffer与性能
在应用中,我们通常会操作字符串,但一旦在网络中传输,都需要转换为Buffer,以进行二进制数据传输。在Web应用中,字符串转换Buffer是时时刻刻发生的,提高字符串到Buffer的转换效率,可以很大程度地提高网络吞吐率。
通过预先转换静态内容为Buffer对象,可以有效地减少CPU的重复使用,节省服务器资源。在Node构建Web应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为Buffer的方式,使性能直接提升。
由于文件自身是二进制数据,所以在不需要改变内容的场景下,尽量只读取Buffer,然后直接传输,不做额外的转换,避免消耗。,即第一个例子中,不要使用data += chunk
变成字符串的拼接。
文件读取
Buffer的使用除了与字符串的转换有性能损耗时外,在文件读取时,highWaterMark的设置对性能的影响至关重要。
在fs.createReadStream(path, opts)时,我们可以传入一些参数。
fs.createReadStream('test.md', {
flags: 'r', // 文件系统标志,r是默认值,代表打开文件用于读取。如果文件不存在,则出现异常
encoding: null,
fd: null,
mode: 0666,
highWaterMark: 64 * 1024, // 每次读取的Buffer长度限制
start: 90,
end: 99 // start end 来制定读取文件的范围
})
fs.createReadStream()的工作方式是在内存中准备一段Buffer,然后在fs.read()读取的时候逐步从磁盘中将字节赋值到Buffer中,完成一次读取时,则从这个Buffer中通过slice()方法取出部分数据作为一个小Buffer对象,再通过data事件传递给调用方,如果Buffer用完,则重新分配一个,如果还有剩余,则继续使用。
在理想状况下,每次读取的长度就是用户指定的highWaterMark,但是有可能读到了文件的结尾,或者文件本身的字节就小于highWaterMark指定的值,那么这个预先指定的Buffer对象将会有部分剩余,不过好在这里的内存可以分配给下次读取时使用,只有剩余数量小于128字节时,才会重新分配新的Buffer对象。所以highWaterMark的大小对性能有两个影响点。
- highWaterMark设置对Buffer内存的分配和使用有一定影响。
因为每个slab是8KB的大小,如果设置的不合理,当slab最后一段空间使用的时候,由于highWaterMark的设置可能会导致最多127字节的空间浪费。比如文件大小刚好为8192字节时,设置了highWaterMark为8065,会造成需要两次读取,第二次读取的时候由于剩余空间为127,此时小于128,会重新分配新的Buffer对象。
- highWaterMark设置过小,可能导致系统调用次数过多。
由于fs.createReadStream()内部采用fs.read()实现,将会引起对磁盘的系统调用,对于大文件而言,highWaterMark的大小决定触发系统调用和data事件的次数。
在读取一个相同的大文件时,highWaterMark值的大小与读取速度的关系:该值越大,读取速度越快。