Node.js 中 Buffer 对象的转换及应用

Buffer 对象可以与字符串之间相互转换。目前支持的字符串编码类型有:

  • ASCII
  • UTF-8
  • UTF-16LE/UCS-2
  • Base64
  • Binary
  • Hex

Buffer 与字符串之间的转换

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

new Buffer(str, [encoding]);

通过构造函数转换的 Buffer 对象,存储的只能是一种编码类型。默认按 UTF-8 编码进行转码和存储。

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

buf.write(string, [offset], [length], [encoding]);

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

Buffer 对象转字符串是通过 toString() 方法:

buf.toString([encoding], [start], [end]);

base64 编码与解码

// 将字符串转化为 base64 编码
let str = new Buffer('key1=value1&key2=value2').toString('base64');
console.log(str);                                              // 'a2V5MT12YWx1ZTEma2V5Mj12YWx1ZTI='
// 解码
console.log(new Buffer(str, 'base64').toString());             // 'key1=value1&key2=value2'

应用:编码解码图片

let fs = require('fs');

// function to encode file data to base64 encoded string
function base64_encode(file) {
    // read binary data
    let bitmap = fs.readFileSync(file);
    // convert binary data to base64 encoded string
    return new Buffer(bitmap).toString('base64');
}

// function to create file from base64 encoded string
function base64_decode(base64str, file) {
    // create buffer object from base64 encoded string, it is important to tell the constructor that the string is base64 encoded
    let bitmap = new Buffer(base64str, 'base64');
    // write buffer to file
    fs.writeFileSync(file, bitmap);
    console.log('******** File created from base64 encoded string ********');
}

// convert image to base64 encoded string
let base64str = base64_encode('kitten.jpg');
console.log(base64str);
// convert base64 string back to image 
base64_decode(base64str, 'copy.jpg');

iconv

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

Buffer.isEncoding(encoding)

将编码类型作为参数传入上面的函数,如果支持转换则返回值为true,否则为false。遗憾的是,在中国常用的GBKGB2312BIG-5编码都不在支持的行列中。

对于不支持的编码类型,可以借助 Node 生态圈中的模块完成转换。iconviconv-lite两个模块可以支持更多的编码类型转换,包括宽字节编码 GBK 和 GB2312。

iconv-lite采用纯Javascript实现,iconv则通过 C++ 调用libiconv库完成。前者比后者轻量,无须编译和处理环境依赖直接使用。在性能方面,由于转码都是耗用 CPU,在 V8 的高性能下,少了 C++ 到 Javascript 的层次转换,纯 Javascript 的性能比 C++ 实现得更好。

以下为iconv-lite的实例代码:

let iconv = require('iconv-lite');
// Buffer 转字符串
let str = iconv.decode(buf, 'GBK');
// 字符串转 Buffer
let buf = iconv.encode(str, 'GBK');

Buffer 的拼接

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

let fs = require('fs');

let rs = fs.createReadStream('test.md');
let data = '';
rs.on('data', function (chunk) {
  data += chunk;
});
rs.on('end', function () {
  console.log(data);
});

上面这段代码常见与国外,用于流读取的示范,data 事件中获取的chunk对象即是 Buffer 对象。对于初学者而言,容易将 Buffer 当作字符串来理解,所以在接受上面的示例时不会觉得有任何异常。

一旦输入流中有宽字节编码时,问题就会暴露出来。如果你在通过 Node 开发的网站上看到�乱码符号,那么该问题的起源多半来自于这里。

这里潜藏的问题在于如下这句代码:

data += chunk;

这句代码里隐藏了toString()操作,它等价于如下代码:

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

值得注意的是,外国人的语境通常是英文环境,在他们的场景下,这个toString()不会造成任何问题。但对于宽字节的中文,却会形成问题。比如下面将文件可读流每次读取的 Buffer 长度限制为 11:

let rs = fs.createReadStream('test.md', {highWaterMark: 11});

将会得到以下输出:

床前明���光,疑���地上霜;举头���明月,���头思故乡。

乱码的产生

产生这个输出结果的原因在于文件可读流在读取时会逐个读取 Buffer。由于限定了 Buffer 的长度为 11,因此只读流需要读取 7 次才能完成完整的读取,结果是以下几个 Buffer 对象的依次输出:








上文提到buf.toString()方法默认以 UTF-8 为编码,中文字在 UTF-8 下占 3 个字节。所以第一个 Buffer 对象在输出时,只能显示 3 个字符,Buffer 中剩下的 2 个字节(e6 9c)将会以乱码的形式显示。第二个 Buffer 对象的第一个字节也不能形成文字,只能显示乱码。于是形成一些文字无法正常显示的问题。

这个示例中我们构造了 11 这个限制,但是对于任意长度的 Buffer 而言,宽字节字符串都有可能存在被截断的情况,只不过 Buffer 的长度越大出现的概率越低而已。

setEncoding() 与 string_decoder()

可读流有一个设置编码的方法setEncoding()

readable.setEncoding(encoding);

该方法的作用是让data事件中传递的不再是一个 Buffer 对象,而是编码后的字符串。

let rs = fs.createReadStream('test.md', {highWaterMark: 11});
rs.setEncoding('utf8');

重新执行程序,将输出:

床前明月光,疑是地上霜;举头望明月,低头思故乡。

但是无论如何设置编码,触发data事件的次数依旧相同,这意味着设置编码并不能改变按段读取的基本方式。

事实上,在调用setEncoding()时,可读流对象在内部设置了一个decoder对象。每次data事件都通过该decoder对象进行 Buffer 到字符串的编码,然后传递给调用者。所以设置编码后,data不再收到原始的 Buffer 对象。但是这依旧无法解释为何设置编码后乱码问题被解决了,因为在前述分析中,无论如何转码,总是存在宽字节字符串被截断的问题。

最终乱码问题得以解决,还是在于decoder的神奇之处。decoder对象来自于string_decoder模块StringDecoder的实例对象:

let StringDecoder = require('string_decoder').StringDecoder;
let decoder = new StringDecoder('utf8');                   // 床前明

let buf1 = new Buffer([0xE5, 0xBA, 0x8A, 0xE5, 0x89, 0x8D, 0xE6, 0x98, 0x8E, 0xE6, 0x9C]);
console.log(decoder.write(buf1));

let buf2 = new Buffer([0x88, 0xE5, 0x85, 0x89, 0xEF, 0xBC, 0x8C, 0xE7, 0x96, 0x91, 0xE6]);
console.log(decoder.write(buf2));                          // 月光,疑

将前文提到的前两个 Buffer 对象写入decoder中。奇怪的地方在于“月”的转码并没有如平常一样在两个部分分开输出。StringDecoder在得到编码后,知道宽字节字符串在 UTF-8 编码下是以 3 个字节的方式存储的,所以第一次输出时,只输出前 9 个字节转码形成的字符,“月”字的前两个字节被保留在StringDecoder实例内部。第二次输出时,会将这 2 个剩余字节和后续 11 个字节组合在一起,再次用 3 的整数倍字节进行转码。于是乱码往年提通过这种中间形式被解决了。

虽然string_decoder模块很奇妙,但是并非万能,目前只能处理 UTF-8、Base64 和 UCS-2、UTF-16LE 这 3 种编码。所以,通过string_decoder不可否认能解决大部分乱码问题,但并不能从根本上解决问题。

正确地拼接 Buffer

正确地解决乱码的方式是将多个小 Buffer 对象拼接为一个 Buffer 对象,然后通过iconv-lite一类的模块来转码。正确的 Buffer 拼接方法如下:

let chunks = [];
let size = 0;
res.on('data', function (chunk) {
  chunks.push(chunk);
  size += chunk.length;
});
res.on('end', function () {
  let buf = Buffer.concat(chunks, size);
  let str = iconv.decode(buf, 'utf8');
  console.log(str);
});

正确的拼接方式是用一个数组来存储接收到的所有 Buffer 判断并记录下所有片段的总长度,然后调用Buffer.concat()方法封装了从小 Buffer 对象向大 Buffer 对象的复制过程。

Buffer.concat()方法实现如下:

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 (let i = 0; i < list.length; i ++) {
      let buf = list[i];
      length += buf.length;
    }
  }

  let buffer = new Buffer(length);
  let pos = 0;
  for (let i = 0; i < list.length; i ++) {
    let buf = list[i];
    buf.copy(buffer, pos);
    pos += buf.length;
  }
  return buffer;
};

Buffer 与传输性能的关系

网络 I/O

在应用中,我们通常会操作字符串,但一旦在网络中传输,都需要转换为 Buffer,以进行二进制数据传输。

let http = require('http');
let str = '';

for (let i = 0; i < 1024 * 10; i ++) {
  str += 'a';
}

let buf = new Buffer(str);

http.createServer(function (req, res) {
  res.writeHead(200);
  res.end(buf);
}).listen(8001);

通过预先转换静态内容为 Buffer 对象,使向客户端输出的是一个 Buffer 对象,无需在每次响应时进行转换,可以有效减少 CPU 的重复使用,节省服务器资源,提高网络吞吐率。

所以在不需要改变内容的场景下,尽量只读取 Buffer,然后直接传输,不做额外的转换,避免消耗。在 Node 构建的 Web 应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以预先转换为 Buffer。

文件 I/O

fs.createReadStream()的工作方式是在内存中准备一段 Buffer,然后在fs.read()读取时逐步从磁盘中将字节复制到 Buffer 中。完成一次读取时,则从这个 Buffer 中通过slice()方法取出部分数据作为一个小 Buffer 对象,再通过data事件传递给调用方。如果 Buffer 用完,则重新分配一个;如果还有剩余,则继续使用。分配一个新的 Buffer 对象的操作如下:

let pool;

function allocNewPool(poolSize) {
  pool = new Buffer(poolSize);
  pool.used = 0;
}

在理想的状况下,每次读取的长度就是用户指定的highWaterMark。但是有可能读到了文件结尾,或者文件本身就没有指定的highWaterMark那么大,这个预先指定的 Buffer 对象将会有部分剩余,不过好在这里的内存可以分配给下次读取时使用。pool是常驻内存的,只有当pool单元剩余数量小于 128 (kMinPoolSpace)字节时,才会重新分配一个新的 Buffer 对象。分配新的 Buffer 对象的判断条件如下:

if (!pool || pool.length - pool.used < kMinPoolSpace) {
  // discard the old pool
  pool = null;
  allocNewPool(this._readableState.highWaterMark);
}

这里与 Buffer 的内存分配类似,highWaterMark大小对性能有两个影响的点。

  • highWaterMark设置对 Buffer 内存的分配和使用有一定影响
  • highWaterMark设置过小,可能导致系统调用次数过多

文件流读取基于 Buffer 分配,Buffer 则基于 SlowBuffer 分配,这可以理解为两个维度的分配策略。如果文件较小(< 8KB),可能造成 slab 未能完全使用。

由于fs.createReadStream()内部采用fs.read()实现,将会引起对磁盘的系统调用,对大文件而言,highWaterMark的大小会决定触发系统调用和data事件的次数。读取一个相同的大文件时,highWaterMark值的大小与读取速度的关系:该值越大,读取速度越快。

你可能感兴趣的:(Node.js 中 Buffer 对象的转换及应用)