nodejs的Buffer解析
- 什么是Buffer
- Buffer的结构
- Buffer对象API解析
- 解决Buffer拼接时导致的乱码问题
- Buffer的性能
一、什么是Buffer?
首先Buffer是nodejs全局上的一个內置模块,可以直接在不用require引入就可以直接调用的模块。
Buffer的作用就是让JavaScript可以直接操作二进制数据,关于Buffer就离不开一些关键名词:二进制、流、管道、IO。关于二进制不需要做太多的解释了,像图片、文件这些都是以二进制的方式存储在磁盘中。当需要使用这些资源的时候,就是要去拿到这些数据,拿数据的这个操作就是读数据。拿到数据后就需要提供给程序,让这些数据作用到具体的应用上,而作用到具体应用的数据基本上不是直接使用二进制数据,而是应用能使用的另一种编码格式的数据。并且当这个数据被作用到具体应用后,这个数据就需要以这个编码格式一直保持被引用的状态,直到应用不在使用这个数据然后被系统回收。
从上面使用数据的过程来看就可以明白,拿到二进制数据后需要转换编码格式,然后还需要一个存放这个数据物理资源,这个物理资源通常被称作缓存,所以很多时候也将Buffer称作缓存。
从二进制数据到其他进制的编码格式,然后在被作用到具体应用,这就是数据从二进制到具体应用表达的过程,这个过程可以理解为一个流程,这个流程里可能还会有程序添加的一些其他操作,这一系列过程被简称为流。(在nodejs中有一个专门用于处理流操作的模块Stream,后面具体解析)
在很多数据操作的时候并不是一次性将一个二进制文件全部读出来,更多可能是基于程序的需要一点点的读取,这时候就需要一个程序逻辑来处理这种操作,将每一个需要的读取文件流程按照逻辑作用到具体应用中,这个程序逻辑我们将其称作管道。(在nodejs中的FS模块中有一个pipe方法,这个方法就可以简单的理解为管道)
反之,向磁盘存储数据也一样,将上面描述的数据读取操作反过来就是文件写操作。文件的读写操作就被称为IO。(在nodejs中负责处理文件操作的FS模块,就是nodejs读写操作“IO”的具体表达)
二、Buffer的结构
上面解析了什么是Buffer和与其相关的一些概念,为了更深入的了解nodejs中的Buffer,还需要了解Buffer这个模块在nodejs的结构。
2.1Buffer模块结构
前面说过Buffer是全局作用域上的一个模块,可以理解为它是全局上的一个属性,这个属性引用着Bufeer模块对象,从这个角度来说它是一个JavaScript模块。但是JavaScript自身不具备直接操作二进制文件的能力,所以实质上Buffer在nodejs底层上还有一个C++模块。这是因为IO操作是非常消耗性能的,所以nodejs在Buffer模块构建设计上,将性能部分用C++实现,将非性能部分用JavaScript实现。如图所示:
2.2Buffer对象结构
Buffer在nodejs的javaScript中是一个对象,与数组非常类似,它的元素为16进制的两位数,即0到255的数值。
let str = "nodejs的学习路径"; let buf = new Buffer(str,'utf-8'); console.log(buf); //打印结果:
不同编码的字符占用的元素各不相同,utf-8编码汉字占用3个元素,字母和半角标点符号占用1个元素,所以上面的str转成Buffer由21个元素组成。根数组一样,Buffer可以使用length属性获取长度,也可以通过下标访问元素,构造Buffer对象时也可以和数组一样直接设置长度。
console.log(buf.length); //21 console.log(buf[3]); //101 console.log(new Buffer(30));//
了解了Buffer对象的基本结构,接着就来看看它的元素取值策略,前面说过它的元素为16进制,那它在不赋值的情况下会如何取值?如果超出16进制取值范围会发生什么?先来看下面的测试代码:
let buf = new Buffer(10); console.log(buf[10]);//undefined buf[0] = -10; buf[1] = -260; buf[2] = 260; buf[3] = 515; console.log(buf); //
当使用new Buffer()创建一个新的空Buffer对象时,它的元素值nodejs8版本前是0~255之间的随机值(为什么是随机值后面会解释),之后时0。然后是关于Buffer对象元素的赋值,前面说过Buffer对象元素是16进制的两位数,即0~255。
赋值小于0时,就将该值逐次加256直至这个元素值大于或等于0。所以buf[0]赋值为-10实际上的取值是-10+256=246,即f6;赋值为-260的buf[1]实际取值是-260+256+256=252。
赋值大与256时,就将该值逐次减去256直至这个元素值小于或等于256。所以buf[2]赋值为260实际取值是260-256=4,即04;赋值为515的buf[3]实际取值是515-256-256=3,即03。
2.3Buff对象在内存中的结构,即Buffer的内存分配:
在介绍这一部分内容之前,建议先了解nodejs的内存机制,不然相关内容不好理解,参考博客: 。
还记得在nodejs内存控制中通过process.memoryUsage()方法获取当前进程的内存使用情况,它返回的对象中包含一个属性arrayBuffer,在nodejs内存控制那篇博客中讲其称为独立内存,也就是不受v8进程内存限制的内存使用大小。而接下来所说的Buffer对象使用的内存就都是指这个独立的内存,不使用独立内存或其他描述区分。
Buffer对象的内存分配不是v8的堆内存中,而是在nodejs的C++层面实现内存的申请。因为需要处理大量的字节数据不能采用一点内存就向系统申请一点内存,这会导致大量的内存申请的系统调用,对操作系统有一定压力。为此Nodejs在内存使用上应用的是C++层面申请内存、在JavaScript中分配内存的策略。
为了高效的使用申请来的内存,nodejs采用了slab分配机制。slab是一种动态内存管理机制,最早诞生于SunOS操作系统(Solaris)中,目前*nix操作系统有广泛的应用。
slab就是一块申请好的固定大小的内存区域,slab有以下三种状态:full(完全分配状态)、partial(部分分配状态)、empty(没有被分配状态)。
在nodejs中默认8KB为界限来区分Buffer是大对象还是小对象,这个8KB的值也就是每个slab的大小值,在JavaScript层面以它作为单位单元进行内存分配。
console.log(Buffer.poolSize); //8192
分配小Buffer对象:
如果指定Buffer的大小少于8KB,nodejs会按照小对象的方式分配,所谓小对象分配就是如果当前的slab已经申请的一块内存的剩余空间,大于当前的Buffer需要的内存,就直接将这个Buffer分配到这个slab中。如果当前Buffer对象需要的内存大于当前slab的剩余空间,就重新申请一个全新的slab来存储Buffer对象的数据。并且之前没有被分配完的slab不在接受内存分配,这部分没有被分配的内存就会被闲置。
分配大Buffer对象:
如果需要超过8KB的Buffer对象,将会直接分配一个对应Buffer大小的slab单元,这个Buffer对象会独占该slab单元。
2.4创建Buffer对象(参考官方V16版本文档)
在nodejs中创建Buffer有四种方式,首先分别来介绍四种方式:Buffer.from()、Buffer.alloc()、Buffer.allocUnsafe()、Buffer.allocUnsafeSlow()。可能这里会疑惑为什么没有new Buffer(),这种方式虽然依然可以创建Buffer对象,但在新版本中已经被上面的四个Buffer类的静态方法替代了,在新版本中使用new Buffer()会出现警告提示。
--Buffer.from():接收数据创建Buffer对象,Buffer内存大小由传入由传入的数据决定。只要其需要的内存大小少于当前已创建的Slab剩余内存空间,就直接使用当前的Slab中的剩余空间。
--Buffer.alloc():创建指定大小的Buffer对象,并初始化Buffer。只要其指定的大小少于已创建的Slab剩余内存空间,就直接使用当前Slab中的剩余空间,并对使用的这部分空间做初识化处理。
--Buffer.allocUnsafe():创建指定大小的Buffer对象,未初始化Buffer存在风险。只要其指定的大小少于Buffer.poolSize的一半,并且也少于当前已创建的Slab剩余内存空间,就直接使用当前Slab中的剩余空间,但不会对使用的这部分空间做初识化处理,存在内存暴露的风险。
--Buffer.allocUnsafeSlow():分配一块全新的内存空间(slab内存单元),创建指定大小的Buffer对象,未初始化Buffer存在风险。由于每次都需要分配全新的slab内存单元,会导致性能下降,而且同样它不会对使用的这部分内存空间做初识化处理。
关于创建Buff对象因为nodejs版本更新变化导致的差异,这里推荐参考官方的版本更新说明:创建Buffer对象的版本差异问题。这里把几个关键的内容提出来解析:
在新版本中为什么不直接使用Buffer()和new Buffer()创建Buffer对象?
答:传入参数如果是数值字符串会被识别为创建一个指定内存大小的Buffer对象,而不是创建一个指定数据的Buffer对象,需要额外将数值字符串JSON化。并且这两种方式如果是创建一个指定空间大小的Buffer对象不会对使用的内存做初始化处理,存在内存暴露的风险。
什么是内存做初识化处理?内存暴露是什么意思?
答:但内存管理在做内存垃圾回收时,它只是将内存不在被对象引用的内存空间资源释放出来,然后没的程序执行可以使用这部分内存空间,但原来的内存数据并没有被抹除。内存初识化就是将当前使用的内存空间使用0或者fill指定的内存覆盖原来的数据,如果不做内存初识化处理,而此时新创建的Buffer对象内就直接可以将原来的内存数据读取出来,这就可能导致内存原来的敏感数据被恶意读取使用,这就是内存暴露。
为什么有的关于nodejs文档中Buffer(num)和new Buffer(num)创建新的Buffer对象,其元素值是0~255的随机数?
答:这个问题其实前面两个问题的解析看明白了就已经回答了,因为Buffer对象内的元素是16进制即0~255,而很多文档和资料都是在nodejs8版本前编写的,如前面所说nodejs8版本之前不会对创建的Buffer对象做初识化操作,所以所谓的0~255的随机数就是内存中原来的数据。
三、Buffer对象的API解析
3.1创建Buffer对象:
在第二节中已经介绍了创建Buffer对象的四个方法,但只针对性的解析了四种方式创建Buffer对象与物理存储结构之间的关系,这里详细的就创建对象API的参数解析,也就是实际应用。
Buffer.from():支持String、Array、ArrayBuffer、Object、buffer五种类型,也就是可以将这五种JavaScript类型的数据转换成十六进制数据,但部分类型并不能完全支持。然后就是可选参数包括:编码(encoding)、偏移量、长度,但不是五种类型数据所可选的参数不是一样的,详细见示例及注释。
--Buffer.from(String [,encoding]);
1 let bufStr = Buffer.from('至若春和景明,波澜不惊'); //默认编码utf-8。web标准编码也是utf-8,所以不需要设置编码模式,如果js不是标准编码需要注意设置编码 2 let bufStrBase64 = Buffer.from('6Iez6Iul5pil5ZKM5pmv5piO77yM5rOi5r6c5LiN5oOK','base64');//'至若春和景明,波澜不惊'的base64编码 3 console.log(bufStr); 4 console.log(bufStrBase64); 5 console.log(bufStr.toString());//同样toString将Buffer对象转换成字符串时,也是默认utf-8编码 6 console.log(bufStrBase64.toString('base64'));//如果使用时依然需要base64编码,还是要使用base编码转换字符串 7 console.log(bufStrBase64.toString());//如果使用时使用utf8编码模式,就可以直接使用默认编码 8 //打印结果 9 //10 // 11 //至若春和景明,波澜不惊 12 //6Iez6Iul5pil5ZKM5pmv5piO77yM5rOi5r6c5LiN5oOK 13 //至若春和景明,波澜不惊
这里需要注意的是,Buffer在内存中存储的永远是字符编码对应的字符的十六进制数据,而我们在使用console.log直接打印Buffer时内部将十六进制数据用数字加字母的形式呈现出来。所以只需要将你存储的字符和对应的编码在写入时设定好,在内存中存储的十六进制数据只要再取出时,编码模式中包含对应的字符就能读取对应编码模式的值。
--Buffer.from(Array);
1 let arr = [1,2,3,4]; 2 let arrStr = [1,2,3,'四']; 3 let arrObj = [1,2,3,{a:'aa'}]; 4 let arrBuf = Buffer.from(arr); 5 let arrStrBuf = Buffer.from(arrStr); 6 let arrObjBuf = Buffer.from(arrObj); 7 console.log(arrBuf); 8 console.log(arrStrBuf);//写入的元素‘四’的十六进制显然不可能是00,也就是说Buffer只能存储纯数字元素数组 9 console.log(arrObjBuf);//同上 10 console.log(arrBuf.toString());//Buffer对象没有提供直接将写入的数组提取出来的接口,这里会什么都读不到 11 //打印结果 12 //13 // 14 //
需要注意的是,Buffer.from(array)只能接收纯数字数组,不需要设置编码,而且nodejs的Buffer模块没有提供直接读取为数组的API。解决Buffer.from(array)存储包含非数字元素的情况可以使用JSON.stringify,但不能包含Function类型的元素,因为JSON也无法转换Function类型的值。
1 let obj = {a:"a",b:"b"}; 2 let fun = function(){console.log(obj.a)}; 3 let arrCite = [obj,fun]; 4 let arrCiteBuf = Buffer.from(arrCite); 5 let arrCiteJsonBuf = Buffer.from(JSON.stringify(arrCite)); //实际这里本质上就是给Buffer.from传入了一个String类型的参数 6 console.log(arrCiteBuf); 7 console.log(arrCiteJsonBuf); 8 console.log(arrCiteBuf.toString()); //这里什么也不会答应 9 console.log(arrCiteJsonBuf.toString());//获取数组的JSON类型 10 console.log(JSON.parse(arrCiteJsonBuf.toString()));//获取数组 11 //打印结果 12 //13 // 14 // 15 //[{"a":"a","b":"b"},null] 16 //[ { a: 'a', b: 'b' }, null ]
使用JSON实现Buffer存储非数组元素数组,是典型的用空间换时间,而且并不能实现function类型元素的存储。
--Buffer.from(object[, offsetOrEncoding[, length]]);
1 let objStrBuf = Buffer.from(new Object('至若春和景明,波澜不惊')); 2 let objArrBuf = Buffer.from(new Object([1,2,3])); 3 let objArr1Buf = Buffer.from(new Object([1,2,3,'四'])); //最后一个元素‘四’无法正常写入内存 4 console.log(objStrBuf); 5 console.log(objStrBuf.toString()); 6 console.log(objArrBuf); 7 console.log(objArr1Buf); 8 //打印结果 9 //10 //至若春和景明,波澜不惊 11 // 12 //
在nodejs中实际上是不能将Object类型直接写入Buffer中的,当Buffer.from()接收到一个Object对象时会隐性执行Object.valueOf(),然后再将String、Array写入Buffer。如果传入的对象就是一个[object Object]类型的话会报错。当然如果必须写入一个Object对象的话还是使用JSON,但Function类型的值依然不能正常写入。
1 let obj = { 2 a:'aaa', 3 b:[1,2,3,'四'], 4 c:function(){console.log('ccc')} 5 }; 6 let objBuf = Buffer.from(JSON.stringify(obj)); 7 console.log(objBuf); 8 console.log(objBuf.toString()); 9 console.log(JSON.parse(objBuf.toString())); 10 //打印结果 11 //12 //{"a":"aaa","b":[1,2,3,"四"]} 13 //{ a: 'aaa', b: [ 1, 2, 3, '四' ] }
实际上上面的这些示例没有多大意义,一般情况下的需求都是写入字符串(String)和纯数字元素(Array),介绍这些主要是为了更深入的理解Buffer内存写入数据的底层原理。接着来看一下这段代码:
1 let bufStr = Buffer.from('至若春和景明,波澜不惊'); 2 let bufBase = Buffer.from('6Iez6Iul5pil5ZKM5pmv5piO77yM5rOi5r6c5LiN5oOK'); 3 console.log(bufStr); 4 console.log(bufBase); 5 //打印结果 6 //7 //
这里将字符串和字符串的Base64编码写入Buffer来比较是应为我们知道字母和数字在utf-8中只占用一个字节,如果不比较的话可能会误以为直接写入字符串的Base64为编码字符串比写入字符串本身占用空间少,实际不然。
Buffer.alloc(size[, fill[, encoding]]):这是一个创建Buffer对象并对内存中的数据做初始化处理,并且可以通过fill可选参数指定初识化信息,和通过encoding指定初识化数据的编码类型。
1 let buf64 = Buffer.alloc(44,5,'base64'); 2 let bufUtf8 = Buffer.alloc(33,2,'utf8'); 3 console.log(buf64); 4 console.log(bufUtf8); 5 buf64.write('6Iez6Iul5pil5ZKM5pmv5piO77yM5rOi5r6c5LiN5oOK','base64'); //需要注意的是这里写入还需要设置写入数据的编码,前面alloc的编码是指定初识化数据的 6 bufUtf8.write('至若春和景明,波澜不惊');//这里是默认的utf-8所以在写入时就不需要再指定了 7 console.log(buf64); 8 console.log(bufUtf8); 9 console.log(buf64.toString()); 10 console.log(bufUtf8.toString()); 11 //打印结果 12 //13 // 14 // 15 // 16 //至若春和景明,波澜不惊 17 //至若春和景明,波澜不惊
Buffer.allocUnsafe(size)与Buffer.allocUnsafeSlow(size):这两个方法与alloc的差别在读写上没有什么差别,它们与alloc的差别就是内存的使用和初识化内存数据这两个问题上,由于不做初识化处理它们只需要接收一个参数size设置使用内存的空间大小就可以了。所以这里就不再重复介绍了,关于对象创建的API解析到这里就介绍完了。
3.2Buffer对象的属性和方法,以及一些应用:
--Buffer.concat(list[, totalLength]):这是一个拼接Buffer的静态方法,参数list是所有拼接的Buffer对象的列表,totalLength是指定拼接成新的Buffer对象的长度,如果长度不够则会忽略后面数据。
let buf1 = Buffer.from('至若春和景明');
let buf2 = Buffer.from('波澜不惊');
let buf = Buffer.concat([buf1,buf2]);
console.log(buf);
console.log(buf.toString());
//打印结果
//
//至若春和景明波澜不惊
--Buffer.isBuffer(obj);这是一个Buffer静态方法,用来判断对选是否是一个Buffer对象。
let buf = Buffer.from('若春和景明,波澜不惊');
let obj = {a:"aa"};
console.log(Buffer.isBuffer(buf));//true
console.log(Buffer.isBuffer(obj));//false
--Buffer.keys():这是一个Buffer对象方法(公有方法),用来获取Buffer对象的key迭代器。
let buf = Buffer.from('若春和景明,波澜不惊');
for(const key of buf.keys()){
console.log(key); //逐个打印key,Buffer类似数组,所以key可理解为索引
}
四、解决Buffer拼接时导致的乱码问题
使用Buffer除了性能编码等需要非常属性以外,还需要注意读取Buffer后拼接导致的乱码问题,比如下面的示例:
1 let buf = Buffer.from('至若春和景明,波澜不惊,上下天光,一碧万顷,沙鸥翔集,锦鳞游泳,岸芷汀兰,郁郁青青。'); 2 let start = 0; 3 let end = 10; 4 let str = ''; 5 while(start < buf.length){ 6 str += buf.subarray(start,end); //subarray获取Buffer对象中的指定片段,与字符串拼接时会默认调用toString方法 7 start = end ; 8 end = start + 10; 9 } 10 console.log(str); 11 //打印结果 12 //至若春???景明??波澜不惊,上???天光??一碧万顷,沙???翔集??锦鳞游泳,岸???汀兰??郁郁青青。
这个问题并不好解决,因为想UTF-8存在字符不等长字节的现象,没办法直接通过固定的字符字节长倍数读取的方式来解决。针对这种情况nodejs提供了一个字符串解码器模块(string_decoder),这个模块不会默认加载,需要手动引入使用,详细参考下面的示例代码:
字符串解码器官方文档:http://nodejs.cn/api/string_decoder.html
1 const StringDecoder = require('string_decoder').StringDecoder; 2 let decoder = new StringDecoder('utf8'); 3 let buf = Buffer.from('至若春和景明,波澜不惊,上下天光,一碧万顷,沙鸥翔集,锦鳞游泳,岸芷汀兰,郁郁青青。'); 4 let start = 0; 5 let end = 9; 6 let str = ''; 7 while(start < buf.length){ 8 str += decoder.write(buf.subarray(start,end)); 9 start = end ; 10 end = start + 9; 11 } 12 console.log(str); 13 //打印结果 14 //至若春和景明,波澜不惊,上下天光,一碧万顷,沙鸥翔集,锦鳞游泳,岸芷汀兰,郁郁青青。
五、Buffer的性能
5.1在网络响应数据之前,将静态数据转换为Buffer可以有效的减少CPU的重复使用,节省服务器资源。
1 let http =require('http'); 2 let helloworld = ''; 3 for(let i = 0; i < 1024 * 10; i++){ 4 helloworld += 'a'; 5 } 6 helloworldBuf = new Buffer.from(helloworld); 7 http.createServer(function(req,res){ 8 res.writeHead(200); 9 // res.end(helloworld);//因为网络上传输的数据是二进制,这里里会隐式转换成Buffer 10 res.end(helloworldBuf); //这里直接使用缓存的Buffer,节省了在传输环节做转换的时间 11 }).listen(12306);
5.2Buffer在文件读取中的性能问题:
//fs.createReadStream(path, opts):opts { ... highWaterMark:64 * 1024 //默认一次最大读取大小 }
1.highWaterMark设置对buffer内存的分配和使用有一定影响
2.highWaterMark设置过小,可能导致系统调用次数过多
文件读取基于buffer分配,buffer基于Slowbuffer分配,如果文件过小,则可能造成slab的浪费。
另外,fs.createReadStream()内部使用了fs.read()实现,会多次调用系统磁盘,如果文件过大的话,highWaterMark将会决定出发系统调用的次数和data事件的次数。