Node.js与二进制数据流

认识二进制数据

二进制数据

二进制是计算技术中普遍采用的一种数制。二进制数据是用0和1两个数码来表示的数。它的基数为2,进位规则是“逢二进一”,借位规则是“借一当二”,由18世纪德国数理哲学大师 莱布尼兹发现。

—— 百度百科css

二进制数据就像上图同样,由0和1来存储数据。普通的十进制数转化成二进制数通常采用"除2取余,逆序排列"法,用2整除十进制整数,能够获得一个商和余数;再用2去除商,又会获得一个商和余数,如此进行,直到商为小于1时为止,而后把先获得的余数做为二进制数的低位有效位,后获得的余数做为二进制数的高位有效位,依次排列起来。例如,数字10转成二进制就是1010,那么数字10在计算机中就以1010的形式存储。html

而字母和一些符号则须要经过 ASCII 码来对应,例如,字母a对应的 ACSII 码是 97,二进制表示就是0110 0001。JavaScript 中可使用 charCodeAt 方法获取字符对应的 ASCII:前端

ASCII

除了ASCII外,还有一些其余的编码方式来映射不一样字符,好比咱们使用的汉字,经过 JavaScript 的 charCodeAt 方法获得的是其 UTF-16 的编码。node

中文的编码

Node 处理二进制数据

JavaScript 在诞生初期主要用于表单信息的处理,因此 JavaScript 天生擅长对字符串进行处理,能够看到 String 的原型提供特别多便利的字符串操做方式。linux

String.prototype

可是,在服务端若是只能操做字符是远远不够的,特别是网络和文件的一些 IO 操做上,还须要支持二进制数据流的操做,而 Node.js 的 Buffer 就是为了支持这些而存在的。好在 ES6 发布后,引入了类型数组(TypedArray)的概念,又逐步补充了二进制数据处理的能力,如今在 Node.js 中也能够直接使用,可是在 Node.js 中,仍是 Buffer 更加适合二进制数据的处理,并且拥有更优的性能,固然 Buffer 也能够直接看作 TypedArray 中的 Uint8Array。除了 Buffer,Node.js 中还提供了 stream 接口,主要用于处理大文件的 IO 操做,相对于将文件分批分片进行处理。git

认识 Buffer

Buffer 直译成中文是『缓冲区』的意思,顾名思义,在 Node.js 中实例化的 Buffer 也是专门用来存放二进制数据的缓冲区。一个 Buffer 能够理解成开辟的一块内存区域,Buffer 的大小就是开辟的内存区域的大小。下面来看看Buffer 的基本使用方法。github

API 简介

早期的 Buffer 经过构造函数进行建立,经过不一样的参数分配不一样的 Buffer。shell

new Buffer(size)

建立大小为 size(number) 的 Buffer。json

new Buffer(5)
// <Buffer 00 00 00 00 00>

new Buffer(array)

使用八位字节数组 array 分配一个新的 Buffer。gulp

const buf = new Buffer([0x74, 0x65, 0x73, 0x74])
// <Buffer 74 65 73 74>
// 对应 ASCII 码,这几个16进制数分别对应 t e s t

// 将 Buffer 实例转为字符串获得以下结果
buf.toString() // 'test'

new Buffer(buffer)

拷贝 buffer 的数据到新建的 Buffer 实例。

const buf1 = new Buffer('test')
const buf2 = new Buffer(buf1)

new Buffer(string[, encoding])

建立内容为 string 的 Buffer,指定编码方式为 encoding。

const buf = new Buffer('test')
// <Buffer 74 65 73 74>
// 能够看到结果与 new Buffer([0x74, 0x65, 0x73, 0x74]) 一致

buf.toString() // 'test'

更安全的 Buffer

因为 Buffer 实例因第一个参数类型而执行不一样的结果,若是开发者不对参数进行校验,很容易致使一些安全问题。例如,我要建立一个内容为字符串 "20" 的 Buffer,而错误的传入了数字 20,结果建立了一个长度为 20 的Buffer 实例。

不安全的Buffer

能够看到上图,Node.js 8 以前,为了高性能的考虑,Buffer 开辟的内存空间并未释放以前已存在的数据,直接将这个 Buffer 返回可能致使敏感信息的泄露。所以,Buffer 类在 Node.js 8 先后有一次大调整,再也不推荐使用 Buffer 构造函数实例 Buffer,而是改用Buffer.from()Buffer.alloc()Buffer.allocUnsafe() 来替代 new Buffer()

Buffer.from()

该方法用于替代 new Buffer(string)new Buffer(array)new Buffer(buffer)

Buffer.alloc(size[, fill[, encoding]])

该方法用于替代 new Buffer(size),其建立的 Buffer 实例默认会使用 0 填充内存,也就是会将内存以前的数据所有覆盖掉,比以前的 new Buffer(size) 更加安全,由于要覆盖以前的内存空间,也意味着更低的性能。

同时,size 参数若是不是一个数字,会抛出 TypeError。

安全的Buffer

Buffer.allocUnsafe(size)

该方法与以前的 new Buffer(size) 保持一致,虽然该方法不安全,可是相比起 alloc 具备明显的性能优点。

不安全的Buffer

Buffer 的编码

前面介绍过二进制数据与字符对应须要指定编码,同理将字符串转化为 Buffer、Buffer 转化为字符串都是须要指定编码的。

Node.js 目前支持的编码方式以下:

  • hex:将每一个字节编码成两个十六进制的字符。
  • ascii:仅适用于 7 位 ASCII 数据。此编码速度很快,若是设置则会剥离高位。
  • utf8:多字节编码的 Unicode 字符。许多网页和其余文档格式都使用 UTF-8。
  • utf16le:2 或 4 个字节,小端序编码的 Unicode 字符。
  • ucs2utf16le 的别名。
  • base64:Base64 编码。
  • latin1:一种将 Buffer 编码成单字节编码字符串的方法。
  • binarylatin1 的别名。

比较经常使用的就是 UTF-8UTF-16ASCII,前面说过 JavaScript 的 charCodeAt 使用的是 UTF-16 编码方式,或者说 JavaScript 中的字符串都是经过 UTF-16 存储的,不过 Buffer 默认的编码是 UTF-8

Buffer编码

能够看到一个汉字在 UTF-8 下须要占用 3 个字节,而 UTF-16 只须要 2 个字节。主要缘由是 UTF-8 是一种可变长的字符编码,大部分字符使用 1 个字节表示更加节省空间,而某些超出一个字节的字符,则须要用到 2 个或 3 个字节表示,大部分汉字在 UTF-8 中都须要用到 3 个字节来表示。UTF-16 则所有使用 2 个字节来表示,对于一下超出了 2 字节的字符,须要用到 4 个字节表示。 2 个字节表示的 UTF-16 编码与 Unicode 彻底一致,经过汉字Unicode编码表能够找到大部分中文所对应的 Unicode 编码。前面提到的 『汉』,经过 Unicode 表示为 6C49

编码表

这里提到的 Unicode 编码又被称为统一码、万国码、单一码,它为每种语言都设定了统一且惟一的二进制编码,而上面说的 UTF-8UTF-16 都是他的一种实现方式。更多关于编码的细节再也不赘述,也不是本文的重点,若是想了解更多可自行搜索。

乱码的缘由

咱们常常会出现一些乱码的状况,就是由于在字符串与 Buffer 的转化过程当中,使用了不一样编码致使的。

咱们先新建一个文本文件,而后经过 utf16 编码保存,而后经过 Node.js 读取改文件。

utf16文本

const fs = require('fs')
const buffer = fs.readFileSync('./1.txt')
console.log(buffer.toString())

输出乱码

因为 Buffer 在调用 toString 方法时,默认使用的是 utf8 编码,因此输出了乱码,这里咱们将 toString 的编码方式改为 utf16 就能够正常输出了。

const fs = require('fs')
const buffer = fs.readFileSync('./1.txt')
console.log(buffer.toString('utf16le'))

正常输出

认识 Stream

前面咱们说过,在 Node.js 中能够利用 Buffer 来存放一段二进制数据,可是若是这个数据量很是的大使用 Buffer 就会消耗至关大的内存,这个时候就须要用到 Node.js 中的 Stream(流)。要理解流,就必须知道管道的概念。

类Unix 操做系统(以及一些其余借用了这个设计的操做系统,如Windows)中, 管道是一系列将 标准输入输出连接起来的 进程,其中每个进程的 输出被直接做为下一个进程的 输入。 这个概念是由 道格拉斯·麦克罗伊Unix 命令行发明的,因与物理上的 管道类似而得名。

-- 摘自维基百科

咱们常常在 Linux 命令行使用管道,将一个命令的结果传输给另外一个命令,例如,用来搜索文件。

ls | grep code

这里使用 ls 列出当前目录的文件,而后交由 grep 查找包含 code 关键词的文件。

在前端的构建工具 gulp 中也用到了管道的概念,由于使用了管道的方式来进行构建,大大简化了工做流,用户量一会儿就超越了 grunt

// 使用 gulp 编译 scss
const gulp = require('gulp')
const sass = require('gulp-sass')
const csso = require('gulp-csso')

gulp.task('sass', function () {
  return gulp.src('./**/*.scss')
    .pipe(sass()) // scss 转 css
    .pipe(csso()) // 压缩 css
    .pipe(gulp.dest('./css'))
})

前面说了这么多管道,那管道和流直接应该怎么联系呢。流能够理解为水流,水要流向哪里,就是由管道来决定的,若是没有管道,水也就不能造成水流了,因此流必需要依附管道。在 Node.js 中全部的 IO 操做均可以经过流来完成,由于 IO 操做的本质就是从一个地方流向另外一个地方。例如,一次网络请求,就是将服务端的数据流向客户端。

const fs = require('fs')
const http = require('http')

const server = http.createServer((request, response) => {
    // 建立数据流
    const stream = fs.createReadStream('./data.json')
    // 将数据流经过管道传输给响应流
    stream.pipe(response)
})

server.listen(8100)
// data.json
{ "name": "data" }

pipe

使用 Stream 会一边读取 data.json 一边将数据写入响应流,而不是像 Buffer 同样,先将整个 data.json 读取到内存,而后一次性输出到响应中,因此使用 Stream 的时候会更加节约内存。

其实 Stream 在内部依然是运做在 Buffer 上。若是咱们把一段二进制数据比作一桶水,那么经过 Buffer 进行文件传输就是直接将一桶水倒入到另外一个桶里面,而使用 Stream,就是将桶里面的水经过管道一点点的抽取过去。

Stream 与 Buffer 内存消耗对比

这里若是只是口头说说可能感知不明显,如今分别经过 Stream 和 Buffer 来复制一个 2G 大小的文件,看看 node 进程的内存消耗。

Stream 复制文件

// Stream 复制文件
const fs = require('fs');
const file = './file.mp4';
fs.createReadStream(file)
  .pipe(fs.createWriteStream('./file.copy.mp4'))
  .on('finish', () => {
      console.log('file successfully copy');
  })

stream内存占用

Buffer 复制文件

// Buffer 复制文件
const fs = require('fs');
const file = './file.mp4';
// fs.readFile 直接输出的是文件 Buffer
fs.readFile(file, (err, buffer) => {
    fs.writeFile('./file.copy.mp4', buffer, (err) => {
        console.log('file successfully copy');
    });
});

buffer内存占用

经过上图的结果能够看出,经过 Stream 拷贝时,只占用了我电脑 0.6% 的内存,而使用 Buffer 时,占用了 15.3% 的内存。

API 简介

在 Node.js 中,Steam 一共被分为五种类型。

  • 可读流(Readable),可读取数据的流;
  • 可写流(Writable),可写入数据的流;
  • 双工流(Duplex),可读又可写的流;
  • 转化流(Transform),在读写过程当中可任意修改和转换数据的流(也是可读写的流);

全部的流均可以经过 .pipe 也就是管道(相似于 linux 中的 |)来进行数据的消费。另外,也能够经过事件来监听数据的流动。不论是文件的读写,仍是 http 的请求、响应都会在内部自动建立 Stream,读取文件时,会建立一个可读流,输出文件时,会建立可写流。

可读流(Readable)

虽然叫作可读流,可是可读流也是可写的,只是这个写操做通常是在内部进行的,外部只须要读取就好了。

可读流通常分为两种模式:

  • 流动模式:表示正在读取数据,通常经过事件监听来获取流中的数据。
  • 暂停模式:此时流中的数据不会被消耗,若是在暂停模式须要读取可读流的数据,须要显式调用stram.read()

可读流在建立时,默认为暂停模式,一旦调用了 .pipe,或者监听了 data 事件,就会自动切换到流动模式。

const { Readable } = require('stream')
// 建立可读流
const readable = new Readable()
// 绑定 data 事件,将模式变为流动模式
readable.on('data', chunk => {
  console.log('chunk:', chunk.toString()) // 输出 chunk
})
// 写入 5 个字母
for (let i = 97; i < 102; i++) {
  const str = String.fromCharCode(i);
  readable.push(str)
}
// 推入 `null` 表示流已经结束
readable.push(null)

事件读取可读流

const { Readable } = require('stream')
// 建立可读流
const readable = new Readable()
// 写入 5 个字母
for (let i = 97; i < 102; i++) {
  const str = String.fromCharCode(i);
  readable.push(str)
}
// 推入 `null` 表示流已经结束
readable.push('\n')
readable.push(null)
// 经过管道将流的数据输出到控制台
readable.pipe(process.stdout)

管道输出可读流

上面的代码都是手动建立可读流,而后经过 push 方法往流里面写数据的。前面说过,Node.js 中数据的写入都是内部实现的,下面经过读取文件的 fs 建立的可读流来举例:

const fs = require('fs')
// 建立 data.json 文件的可读流
const read = fs.createReadStream('./data.json')
// 监听 data 事件,此时变成流动模式
read.on('data', json => {
  console.log('json:', json.toString())
})

fs.createReadStream

#### 可写流(Writable)

可写流对比起可读流,它是真的只能写,属于只进不出的类型,相似于貔貅。

建立可写流的时候,必须手动实现一个 _write() 方法,由于前面有下划线前缀代表这是内部方法,通常不禁用户直接实现,因此该方法都是在 Node.js 内部定义,例如,文件可写流会在该方法中将传入的 Buffer 写入到指定文本中。

写入若是结束,通常须要调用可写流的 .end() 方法,表示结束本次写入,此时还会调用 finish 事件。

const { Writable } = require('stream')
// 建立可写流
const writable = new Writable()
// 绑定 _write 方法,在控制台输出写入的数据
writable._write = function (chunk) {
  console.log(chunk.toString())
}
// 写入数据
writable.write('abc')
// 结束写入
writable.end()

_write 方法也能够在实例可写流的时候,经过传入对象的 write 属性来实现。

const { Writable } = require('stream')
// 建立可写流
const writable = new Writable({
  // 同,绑定 _write 方法
    write(chunk) {
    console.log(chunk.toString())
  }
})
// 写入数据
writable.write('abc')
// 结束写入
writable.end()

手动写入

下面看看 Node.js 中内部经过 fs 建立的可写流。

const fs = require('fs')
// 建立可写流
const writable = fs.createWriteStream('./data.json')

// 写入数据,与本身手动建立的可写流一致
writable.write(`{
  "name": "data"
}`)
// 结束写入
writable.end()

看到这里就能理解,Node.js 在 http 响应时,须要调用 .end() 方法来结束响应,其实内部就是一个可写流。如今再回看前面经过 Stream 来复制文件的代码就更加容易理解了。

const fs = require('fs');
const file = './file.mp4';
fs.createReadStream(file)
  .pipe(fs.createWriteStream('./file.copy.mp4'))
  .on('finish', () => {
      console.log('file successfully copy');
  })

双工流(Duplex)

双工流同时实现了 Readable 和 Writable,具体用法能够参照可读流和可写流,这里就不占用文章篇幅了。

管道串联

前面介绍了经过管道(.pipe())能够将一个桶里的数据转移到另外一个桶里,可是有多个桶的时候,咱们就须要屡次调用 .pipe()。例如,咱们有一个文件,须要通过 gzip 压缩后从新输出。

const fs = require('fs')
const zlib = require('zlib')

const gzip = zlib.createGzip() // gzip 为一个双工流,可读可写
const input = fs.createReadStream('./data.json')
const output = fs.createWriteStream('./data.json.gz')

input.pipe(gzip) // 文件压缩
gzip.pipe(output) // 压缩后输出

面对这种状况,Node.js 提供了 pipeline() api,能够一次性完成多个管道操做,并且还支持错误处理。

const { pipeline } = require('stream')
const fs = require('fs')
const zlib = require('zlib')

const gzip = zlib.createGzip()
const input = fs.createReadStream('./data.json')
const output = fs.createWriteStream('./data.json.gz')

pipeline(
  input,   // 输入
  gzip,    // 压缩
  output,  // 输出
  // 最后一个参数为回调函数,用于错误捕获
  (err) => {
    if (err) {
      console.error('压缩失败', err)
    } else {
      console.log('压缩成功')
    }
  }
)

参考

相关文章
相关标签/搜索