本文是《Nodejs开发进阶》系列文章中的一个章节。 关于这个系列内容的组织结构,笔者并没有预先有很精心和严密的设计,基本上就是觉得有必要表述的内容就着手编写相关的内容。因此,这个序列的顺序,并不是那么有严格的先后和依赖的逻辑关系,读者可以按照自己的需求随意选择想阅读和了解的内容。
本文的内容,是讨论一下nodejs中的一个非常基础但又非常重要的一个组成: Buffer。
Buffer
Buffer,在信息技术的术语中,通常被称为"缓冲",就是一个可以提高高性能存取访问能力的,临时性的存储空间。
在Nodejs的应用开发中,会经常遇到Buffer,但好像却很少主动使用Buffer来处理和解决问题,所以初学者很容易忽略这个非常重要的内容。其实,Buffer是一项基础和底层的技术,有必要很好的理解和掌握。
在Nodejs技术文档中,有专门的Buffer章节,其中对于buffer的表述是这样的(译文):
Buffer对象用于表示固定长度的字节序列。许多Node.js API支持Buffer。
Buffer类是JavaScript的Uint8Array类的子类,以此为基础上扩展了额外的方法来涵盖更多使用场景。在Node.js的API中,凡是支持Buffer的地方,都可以直接使用普通的Uint8Array。
虽然Buffer类在全局作用域中是可用的,但是仍然推荐通过import或require语句显式地引用它。
这个表述是非常清楚的,Buffer其实就是字节数组。但JS的原生提供的Uint8Array操作能力太薄弱,所以,nodejs设计了buffer类型,提供了很多强大而灵活的功能,方便字节数组的处理。根据文档的说法,Buffer实例也是 JavaScript Uint8Array和TypedArray实例。所有TypedArray方法都可用于Buffer。但是,BufferAPI 和 TypedArrayAPI 之间存在细微的不兼容。
我们知道,字节数组本质上就是二进制数据在程序开发中的一个基础的数据类型,所以其实buffer也是非常基础而又重要的底层技术,但不知道什么原因,这样的技术却没有先出现在浏览器环境中,至今也没有类似的东西,处理二进制数据仍然使用Uint8Array或者blob,比较繁琐。
图为将Hello字符串转换为buffer的情景。读者可以在nodejs中,使用Buffer.from("Hello")程序,进行验证。

Node
> Buffer.from("Hello")
<Buffer 48 65 6c 6c 6f>
Buffer的使用场景
虽然程序员在实际开发中很少主动使用Buffer技术,但实际上buffer会出现在各种各样的应用场景中,这里简单的例举一下。
- 文本编解码和转换
nodejs中,可以借助buffer,进行各种编解码形式之间进行转换。典型的如将utf8字符串,转成buffer,并进一步转为base64等形式。
- 文件和媒体
nodejs内置的文件处理,基本上是以buffer为基础的。比如readFile方法,读取的结果,就可以是buffer。或者文件使用流进行处理的时候,也会用到buffer。
另外,对于一些媒体文件,典型的比如图片,它没办法使用字符串的形式来进行表达,最高效的方式就是使用二进制的形式,也会用到buffer。
- stream
nodejs对于大型数据,可以提供基于流(stream)的处理方式,它的技术基础和实现,中间也会大量使用buffer。流处理是一种非常高效的数据和信息处理模式,我们会有专门的章节来讨论这方面的内容。
- http
nodejs内置了http类,用于处理和http协议相关的操作。http本质上是一个网络协议,其核心是遵循http协议的网络信息传输,包括在请求和响应阶段。这里的信息传输,实际上是以数据流的方式进行的,它们在nodejs中的信息形式,就是一个个信息流分包(chunk),这个包的类型和形式,就是buffer。
- crypto
nodejs中提供了功能完善而强大的密码学函数库crypto。如果认真仔细阅读其技术文档,就会发现,它实际上都是在操作buffer。
比如最简单的hash方法:
let hash = crypto.createHash("SHA256").update("Hello").digest();
这里的digest方法,在不提供类型参数的时候,生成的变量hash类型,就是buffer。默认情况下,各种hash、hmac、encrypto等,输出的结果都是buffer;而理论上而言,这些方法的输入参数,比如要编码或者加密的内容、密钥等等,也最终都是会转换成buffer来
Buffer结构
Buffer是nodejs全局类,而且是一等成员,无需引用就可以使用。使用如下代码可以查看这个类的结构:
js
> console.log(Buffer)
[Function: Buffer] {
poolSize: 8192,
from: [Function: from],
of: [Function: of],
alloc: [Function: alloc],
allocUnsafe: [Function: allocUnsafe],
allocUnsafeSlow: [Function: allocUnsafeSlow],
isBuffer: [Function: isBuffer],
compare: [Function: compare],
isEncoding: [Function: isEncoding],
concat: [Function: concat],
byteLength: [Function: byteLength],
[Symbol(kIsEncodingSymbol)]: [Function: isEncoding]
}
可以看到此类,由一系列静态方法和属性构成,这些方法和属性都比较好理解,在后面我们会着重详细阐述。
Buffer的操作
按照通用的信息技术中信息处理的方法论,buffer的操作涉及其整个的生命周期,包括创建、转换、销毁等阶段。由于Buffer这个类型,现在仅在nodejs环境中使用,所以我们讨论的内容,没有特别说明,只限定在nodejs中,可能并不适用浏览器环境。 此外,nodejs技术文档中,buffer的内容非常其实多,限于篇幅限制,我们这里重点筛选了和日常时使用开发关联较大,或者笔者觉得有必要讨论的部分,并非全部内容。所以,要想获得完全的信息,可以参考技术文档原文。
编码格式
前面已经提到,buffer的一个重要的设计目标,就是扩展U8A的处理,其中一个重要的方面就是需要支持各种不同的信息编码形式。现在buffer可以直接支持的编码类型包括:
- ascii: 7位ASCII字符
- utf8: Unicode字符,多字节
- utf16le: Unicode字符,小端编码,2或4字节
- base64: 标准Base64字符串
- base64URL: Base64字符串,兼容URL
- hex: 16进制字符,每个字节或Buffer元素为两个十六进制字符
- binary: 纯二进制信息
这些编码形式,可以作为参数在创建、转换或者输出时使用,非常方便,具体方式可以参考相关具体函数和方法。默认编码格式为utf8。
类型判断
判断一个变量,是否为buffer,可以使用Buffer.isBuffer(obj)。
Buffer还提供了isEncodeing,isUTF8,isAscii等方法来判断对象是否使用相关编码方式。
创建
可以使用以下方式,来创建一个buffer,创建buffer的来源,可以包括新实例、文本、编码文本、字节数组、数据流等方式。
js
buf = Buffer.alloc(100,100); // 分配长度为100的空buffer,并全部置为0,或者指定的参数
buf = Buffer.allocUnsafe(100); // 快速分配
buf = Buffer.from("welcome"); // 来自文本,默认使用utf8编码
buf = Buffer.from("abcd","hex"); // 来自特定编码文本串
buf = Buffer.from(new ArrayBuffer(10) , 2, 4); // 来自ArrayBuffer, 带偏移和大小
buf = new buffer.File(sources, fileName[, options]); // 从文件创建buffer
简单的说明:
- 在比较新的nodejs版本中,已经不建议使用new Buffer() 这种形式创建Buffer,建议使用Buffer类的静态方法Buffer.from()
- 如果极端追求性能,又能保证可以在后续的操作中填充buffer,可以使用allocUnsafe,它会以合适的方式重用已有的缓存区域,性能非常高,但可能会包含意外信息(旧数据)
- 使用from创建,可以指定来源数据格式,默认为utf8,常见可选如base64,base64url,hex等等
- 也可以由ArrayBuffer,U8A等创建,它们只是相同事物的不同形式而已
转换
使用buffer类和相关函数,可以在各种形式之间进行转换,包括形式转换,分割,合并,复制,修改,填充等等,来满足开发和业务的需求。而且Buffer提供的方式,既简洁又优雅。
js
// UTF8文本 - Buffer
buf = Buffer.from("中文");
ztext = buf.toString();
// UTF8 - base64
base64 = Buffer.from("中文").toString("base64");
ztext = Buffer.from(base64,"base64").toString();
// Buffer的切割
buf1 = buf.slice(0,10);
buf2 = buf.slice(10,15);
// slice已经不建议使用了,更推荐使用subarray方法
buf3 = buf.subarray(5, 10);
// Buffer的合并, 注意是静态方法,并使用数组作为参数
buf = Buffer.concat([buf1, buf2]);
// Buffer的复制
buf = Buffer.copyBytesFrom(u8a, 1, 1);
// 复制的实例方法
buf4 = Buffer.alloc(10)
buf.copy(buf4, 8, 16, 20);
// 修改,指定位置替换部分buffer内容
buf4.set(buf.subarray(1,5), 8);
// 填充,可以指定值,偏移,长度,编码等等
buf.fill(10,2,5);
读和写
nodejs提供了一整套读写操作方法来处理buffer。
js
// 索引访问
i1 = buf1[1];
// 值遍历
for (const v of buf1) console.log(v);
// 读取 int8
console.log(buf1.readInt8(1));
// 写入 buffer,可设置位置
buf1.write('abcd', 3);
搜索和比较
在buffer中,搜索特定的值,可以使用indexOf和lastIndexOf方法,有点像数组或者字符串的方式。这个也比较好理解,就是将信息编码后,搜索一个字节数组是否存在于另一个字节数组中,和它存在的位置。
如果只是需要检查是否包含某些值,也可以使用includes()方法。
比较两个buffer的内容是否一致这个需求也是非常普遍的(如判断签名信息,它们可能是base64编码),一般认为作为两个字节数组,每个位置上的字节的值相同,就可以认为两个buffer相同。nodejs中可以使用Buffer.compare()方法,这个可比编写一个函数,来逐一比较两个字节数组简单多了。compare有静态和实例方法。静态方法可以简单的比较两个buffer对象;实例方法可以指定比较范围,更为灵活。buffer还有一个实例方法 equals,也可以用于比较两个buffer和相关类型。
销毁
如果频繁的使用buffer,则需要注意及时控制buffer对内存的占用。因为nodejs中buffer创建后的长度是不能变化的,相关的操作可能会生成大量的新实例,从而占用大量的内存资源。而nodejs中的内存释放只能依靠垃圾回收机制来实现,我们不能直接释放Buffer占用的内存。可以通过尽早的将buffer变量设置为null,切断引用,让垃圾回收机制及时回收内存。
此外,还可以考虑尽可能复用已经存在的buffer,避免频繁分配新的buffer,也可以提高buffer操作的效率。当然,这些操作会增大开发的工作量,因为需要精心设计在同一个buffer上进行操作。
在实际的实现和一般使用中,其实开发者不用为此考虑太多。因为buffer作为一个基础技术,Nodejs在buffer操作的设计和实现中,已经尽量考虑提高其操作效率的。比如我们创建buffer时,系统其实已经使用一个buffer pool,来加速buffer创建和复用,还有一些机制,基本上可以保证日常使用不会有太大的效率问题。
关于Buffer引发的思考
在理解和buffer的原理和操作等方面的内容之后,我们就会了解到关于buffer的一些我们原理没有深入思考而有趣的内容。
- 字符串和Buffer
我们一般在刚开始学开发的时候,有二进制数据的概念,但对其结构并没有太深入的了解。日常操作,更熟悉的是另一个事物:string(字符串)。因为比较直观,和日常生活经验贴近,容易理解,就是一段文本,字符串操作就是文本操作。
本来字符串的定义也比较简单和明确,字符串嘛,就是一串字符,一些字符的有序集合,不就是字符的数组,或者链表嘛。原来的ASCII字符串,每一个字符,都可以简单的找到其对应的字符编码,也就是可以用一个字节来表示。但引入字符集编码后,情况变得复杂了一点。字符串还是字符串,但其中的每个字符,由于字符集和编码方式的存在,已经不能简单的看作单个字符对应的字节了。字符串的长度,和其转换为buffer的长度就不一致了,取决于编码方式,我们应该非常清楚这一差异。
除了组织方式的差异之外,在nodejs系统中的处理,两者也有差异。其实string也是一种非常基础和常用的数据格式,所以在nodejs中,也是作为一等公民对待的。在V8中,string是在内部直接处理的,它使用V8堆存储;不像buffer需要经过C++调用操作系统方法进行堆外内存分配,并且针对常用字符串操作进行了很多其他优化,其操作效率其实要高于转换为buffer再处理的方式。
一般而言,字符串都是有限并且可控的;而buffer可以方便的处理大型二进制数据,或者流信息可变的数据,或者其他非Unicode编码的信息(nodejs字符串默认是Unicode编码)。也就是说,它们都有不同的适用场景,需要根据业务和程序实际情况进行选择。
- 文本字符串和Base64/Hex
先看下面三个字符串:
China中国
Q2hpbmHkuK3lm70=
4368696e61e4b8ade59bbd
这三个虽然看起来都是字符串,但显然第一个我们非常容易理解,另外两个一看就是一种编码,而不是一个有意义的文本。其实,后者是前者的Base64和hex编码,在信息系统中,其实是一样的,呈现形式不同而已。所以,作为信息技术从业人员,应该理解并熟悉这一点。
这里有一个实际的案例。在一个应用中,需要使用一个预先设置好的密钥,来对信息进行加密和解密,这个密钥是作为一个配置信息以字符串保存在一个配置文件中的。当使用时读取该密钥,并在加解密时使用。如果没有相关的意识,可能会直接使用这个base64字符串作为一个密钥(笔者刚开始编程时就是这样做的)。但这种方式其实是不对的,因为nodejs会将它看成一个普通的UTF8字符串进行编码(同样会转换为buffer),而不是作为Base64字符串转换为buffer,这两种方式的内容显然不同。
js
ENCRYPTION_KEY = "base64string..";
// 不正确的方式
cipher = crypto.createCipheriv(algorithm, ENCRYPTION_KEY), iv);
// 正确的方式
cipher = crypto.createCipheriv(algorithm, Buffer.from(ENCRYPTION_KEY, 'base64'), iv);
遗憾的是,这种做法,程序也是可以执行的,但我们应该理解,这样的使用方式是不当的,脱离了程序设计的原意。
- 8K Buffer问题
我们前面有一个问题有简单提到,但没有展开,就是nodejs管理buffer其实用的是资源池的机制。这里笔者在简单分析相关源码和自己的理解基础上,稍微展开一点讨论。
除了一系列方法之外,Buffer只有一个属性,就是poolSize,默认值是8x1024。Buffer中每一个元素就是一个byte,所以这个池的基本容量就是8K,然后新创建的buffer,就可以使用这个容量中的空间。这个问题,我们还是看下面的源码说的清楚一些,这里主要看逻辑,细节可以不用先管:
buffer.js
Buffer.poolSize = 8 * 1024;
function allocate(size)
{
if(size <= 0 )
return new FastBuffer();
if(size < Buffer.poolSize >>> 1 )
if(size > poolSize - poolOffset)
createPool();
var b = allocPool.slice(poolOffset,poolOffset + size);
poolOffset += size;
alignPool();
return b
} else {
return createUnsafeBuffer(size);
}
}
从代码中可以看到,这里分为几个情况。如果要求的大小小于等于零,则调用FastBuffer()方法,应该就是返回空buffer,这应该是一个安全机制;如果大于一半的池大小,会调用createUnsafeBuffer(),应该是新申请一个内存空间,然后创建buffer;而如果小于一般的池大小,则会检查默认池中,剩余的空间,空间足够的话,则直接使用池中的空间来创建buffer;否则将调用createPool创建一个pool并创建buffer。
我们可以看到,对于比较小的buffer,它会和其他小buffer来分享一个pool,它们使用offset(偏移)和size(大小)进行区分;对于比较大的buffer,它可能会独占一个pool,甚至一个独立的内存空间。
作为nodejs最基础的功能模块,Buffer Pool这一部分在nodejs中,应该是使用C++程序调用操作系统内存分配机制单独来实现的,并不在V8的堆栈内存当中。使用资源池的模式,可以减少这些调用的频繁操作,从而提高性能。
这样的设计,是一种典型的内存分配策略设计,对于不同类型的使用需求和场景,使用不同的处理,兼顾操作的效率和资源的占用,达到一个整体上比较平衡和优化的结果。那个8K的参数设计,应该也是一个在长期使用过程中评估的经验数值。其实这个参数是可调的,前提是开发者对于自己的业务特点有所了解,并且有运行优化调整评估的机制和经验。
- 数组操作
我们已经知道,本质上而言buffer就是U8A。所以它就可以被看成一个数组进行操作,理论上,就可以使用一些高效的数组操作方法如map、reduce、filter等。但实际上不行。reduce操作是可以的,因为它不限制输出结果的格式;而一般数组操作应该是不行的,因为U8A并不是标准的Array。如果要实现类似的操作,可能需要进行转换处理。
- ArrayBuffer
buffer对象有一个buffer属性。没错,这个属性的名字就叫buffer,但其实它的类型是ArrayBuffer,是此buffer实例所基于的底层ArrayBuffer对象。由于bufferPool的存在,这个buffer和ArrayBuffer的包含的实际内容不一定完全一致。所以buffer对象还提供了byteOffset(偏移)和length(长度)属性,在进行操作等转换时使用。
如,需要将buffer转换为Uint8Array,保险的做法和操作是:
let ary = new Uint8Array(buf.buffer,buf.byteOffset, buf.length);
用于浏览器前端不支持nodejs buffer,所以这些转换在和前端进行数据交互时可能是比较常见的。
- 长度限制
buffer有长度限制吗? 看文档是有的,在64位操作系统上,单一buffer实例的长度限制为 2^32,约为4GB。这个信息是一个常数 buffer.constants.MAX_LENGTH。普通网络业务类的应用,主要操作小而多样的数据和信息,这个限制已经足够使用了。
- 扩展编码支持
看nodejs的文档,除了最常用的Uint8Array之外,里面还有很多扩展编码的支持。如Int16BE, Int16LE, FloatBE...。笔者尚未遇到相关的使用场景,所以对它们的使用方式,场合,包括相关的操作方法等的理解也是缺乏的。这里只是作为一个问题先提出来。
希望哪位读者有机会涉及到相关的内容,可以不吝赐教。