阅读 221

「学习笔记」更好的了解Node.js中的缓存区,流

什么是二进制数据?

计算机是以二进制形式存储和表示数据,二进制是 01 的集合。例如:01001010。比如,要存储数字 13 计算机需要将数字转换为 1101

二进制中的 01 被称为位(bit)。尽管它们很像表示一个数值,但是其实它们表示是符号。0 代表FALSE, 1代表TRUE。位的运算其实是对真假值的操作。为了存储数据,计算机包含了大量的电路,每一个电路能存储单独的一个。这种位存储器,被称为"主存储器"。计算机通过存储单元组织管理主存储器。一个典型的存储单元容量是8位,一个8位的串称为一个字节(byte)。

但是,数字不是我们唯一需要存储处理的数据,我们还需要处理字符串,图片,视频。

以字符串为例,那么计算机怎么存储字符串呢?例如,我们要存储字符串 "S" ,计算机首先会将 "S", 转换为数字'S'.charCodeAt() === 83, 那么计算机是如何知道83表示"S"的?

什么是字符集?

字符集是已经定义好的规则,每一个字符都有一个确切的数字表示。字符集有不同规则的定义,例如:"Unicode", "ASCII"。浏览器中使用"Unicode"字符集。正是"Unicode"字符集,定义了83表示"S"。

那么接下来,计算机会直接将83转为二进制吗?并不是,我们还需要使用"字符编码"。

什么是字符编码?

字符集定义了使用特定的数字表示字符(汉字也是同样的)。而字符编码定义了,如何将数字转换为特定长度的二进制数据。常见的utf-8字符编码,规定了字符最多由4个字节进行编码(一个字节由8个,0或者1表示)。

h e l l o <==Unicode==> 104 101 108 108 111 <==utf-8==> 1101000 1100101 1101100 1101100 1101111 
复制代码

对于视频,音频,图片,计算机也有特定规则,将其转换为二进制数据。计算机将所有数据类型,存储为二进制文件。这些二进制文件就是二进制数据。

什么是缓冲区?

数据流指数据从一个位置到另一个位置的移动(通常大文件会被拆解为块的形式)。

如果数据流动的速度,大于进程处理数据的速度,多余的数据会在某个地方等待。如果数据流动的速度,小于进程处理数据的速度,那么数据会在某个地方累计到一定的数量,然后在由进程进行处理。(我们无法控制流的速度)

那个等待数据,累计数据,然后发生出去的地方。就是缓冲区。缓冲区通常位于电脑的RAM(内存)中。

我们可以把缓冲区想象成一个公交车站,较早到达车站的乘客的会等待公交车,当公交车以及装满离开后,剩下的乘客会等待下一班的公交车到来。那个等待公交车的地方,就是缓冲区。

举一个常见的缓冲区的例子,我们在观看在线视频的时候,如果你的网速很快,缓冲区总是会被立即填充,然后发送出去,然后立即缓冲下一段视频。观看的过程中,不会有卡顿。如果网速很慢,则会看到loading,表示缓冲区正在被填充,当填充完成后数据被发送出去,才能看到这段视频。

Buffer类

什么是TypedArray?

TypedArray出现前,js没有读取或操作二进制数据流的机制。TypedArray并不是一个特定的全局对象,而是许多全局对象的统称。

// TypedArray 指的是下面其中之一
Int8Array // 8位二进制补码有符号整数的数组
Uint8Array // 8位无符号整型数组(0 > & < 256(8位无符号整形)),等等
Uint8ClampedArray
Int16Array
Uint16Array
Int32Array
Uint32Array
Float32Array
Float64Array
复制代码

什么是Buffer类?

Buffer类,是以Nodejs方式实现的Uint8ArrayAPI(Uint8Array属于TypedArray的一种)。用于与八字节的二进制数据进行交互。

常见的API

Buffer.from

Buffer.from, 接受多种形式的参数,下面介绍几种常见的

使用8字节的数组,作为参数

const buf = Buffer.from([0b1101000, 0b1100101, 0b1101100, 0b1101100, 0b1101111])
// hello
console.log(buf.toString())
复制代码

使用字符串作为参数

const buf = Buffer.from('Hello World!');

// HelloWorld
console.log(buf.toString())
复制代码

Buffer.alloc

创建一个指定大小,并且已经初始化的Buffer(默认被0填充)

// 创建一个大小为10个字节的Buffer,并使用0进行填充
const buf = Buffer.alloc(10)
// <Buffer 00 00 00 00 00 00 00 00 00 00>
console.log(buf)

// 创建一个大小为12个字节的Buffer,并使用汉字进行填充
const buf = Buffer.alloc(12, '大')
// 打印出 大大大大,因为汉字是3个字节的关系,所以填充了4个汉字
console.log(buf.toString())
复制代码

Buffer.allocUnsafe

创建一个指定大小,但是没有初始化填充的Buffer

// 创建一个大小为10个字节的Buffer,没有被初始化
const buf = Buffer.allocUnsafe(10)
复制代码

为什么说 Buffer.allocUnsafe 是不安全的?Buffer是内存的抽象,尝试运行console.log(Buffer.allocUnsafe(10000).toString()), 我们应该可以从控制台看到打印出了内存里的一些东西

buffer.write

向Buffer中写入字符串,如果Buffer空间不够,多余的字符串不会被写入

const buf = Buffer.alloc(5)
// 您好的长度是6个字节
buf.write('您好')
// 您
console.log(buf.toString())
复制代码

buffer.toJSON

将buffer中的数据转换为Unicode码

const buf = Buffer.from('hello')

// {
//     type: 'Buffer',
//     data: [ 104, 101, 108, 108, 111 ] h e l l o 的 Unicode码
// }
console.log(buf.toJSON())
复制代码

buffer.toString

将buffer解码成字符串


const buf = Buffer.from([0b1101000, 0b1100101, 0b1101100, 0b1101100, 0b1101111 ])
// hello
console.log(buf.toString())
复制代码

String Decoder

考虑下面这种情况,因为两个汉字的字节长度是6,所以字节长度等于5的buffer是放不下的,所以打印出来的字符串是不完整的。

const buf = Buffer.alloc(5, '您好')
// 您�
console.log(buf.toString())
复制代码

那么有什么办法可以将Buffer中不完整的字符串输出出来呢?我们可以使用String Decoder

const { StringDecoder } = require('string_decoder')
const decoder = new StringDecoder('utf8')

// Buffer.from('好') <Buffer e5 a5 bd>
const str1 = decoder.write(Buffer.alloc(5, '您好'))
// 您
console.log(str1)
const str2 = decoder.end(Buffer.from([0xbd]))
// 好
console.log(str2)
复制代码

StringDecoder的实例接受写入Buffer的实例,使用内部缓冲区确保解码的字符串不包含不完成的字节,并且将不完整的字节,保存起来,直到下一次使用write或者end。

decoder.write

返回已解码的字符串,字符串不包含不完整的字节,不完整的字节。不完整的字节会保存到decoder内部的缓冲区中。

const { StringDecoder } = require('string_decoder')
const decoder = new StringDecoder('utf8')

// 哈喽 <Buffer e5 93 88 e5 96 bd>
const str = decoder.write(Buffer.from([0xe5, 0x93, 0x88, 0xe5, 0x96]))
// 哈,0xe5, 0x96由于不完整不会被返回,而是保存在decoder的内部缓冲区
console.log(str)
复制代码

decoder.end

会将decoder内部缓存区剩余的buffer一次性返回。


const { StringDecoder } = require('string_decoder')
const decoder = new StringDecoder('utf8')

// 哈喽 <Buffer e5 93 88 e5 96 bd>
decoder.write(Buffer.from([0xe5, 0x93, 0x88, 0xe5, 0x96]))
const str = decoder.end()
// �,decoder内部缓冲区剩余的字节是不完整的
console.log(str)
复制代码

什么是流?

流是数据的集合,流不像字符串或者数组一样是立即可用的,流不会全部存在内存中。处理大量数据时,流非常有用。

Nodejs中,许多模块都实现了流模式。下图是实现了流模式的内置模块(图片来自于Samer Buna的在线课程)

image

流的类型

  1. Writable,可写入流,是写入目标的抽象,常见的例子:fs.createWriteStream
  2. Readable,可读取流,是数据源的抽象。常见的例子:fs.createReadStream
  3. Duplex,可读可写流(双工流)
  4. Transform,也是一种可读可写流,但是可以读取写入的时候修改转换数据。所以也可以叫做转换流。例如:zlib.createGzip压缩数据流

流的管道

const fs = require('fs')
// 可读流作为数据源
const readable = fs.createReadStream('./数据源.json')
// 可写流作为目标
const writable = fs.createWriteStream('./目标.json')
// 将数据源通过管道连接到目标
readable.pipe(writable)
复制代码

在这几行简单的代码中我们将可读流的输出(readable作为数据源),连接管道至可写流的输入(writable作为目标)。源必须是可读流,目标必须是可写流。

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

const readable = fs.createReadStream('./数据源.json')
// gzip是一个双工流
const gzip = zlib.createGzip()
const writable = fs.createWriteStream('./目标.gz')

// 数据源连接到转换流(gzip),转换流处理数据后,连接到目标上
readable
    .pipe(gzip)
    .pipe(writable)
复制代码

我们也可以将可读流的管道连接到双工流(转换流)上。总结一下pipe方法的用法。pipe可以返回一个目标流,目标流可以连接到双工流,可写流上。

可读流
    .pipe(双工流)
    .pipe(双工流)
    .pipe(可写流)
复制代码

使用pipe是消费流最简单的方法,它会自动管理一些操作,比如错误处理,比如如果可读流没有数据可供消费时的情况。当然我们也可以通过事件消费流,但是最好避免两者混合使用。

流的事件

如果需要对流实现,更自定义的控制,可以使用事件消费流。下面的这段代码和之前的pipe的代码是等效的。

const fs = require('fs')

const readable = fs.createReadStream('./数据源.json')
const writable = fs.createWriteStream('./目标.json')

// 当可读流绑定data事件时,会将流切换到流动模式
readable.on('data', (chunk) => {
    writable.write(chunk);
})

readable.on('end', () => {
    writable.end()
})
复制代码

下图是可读流,可写流的事件与方法的列表(图片来自于Samer Buna的在线课程)

事件列表.png

关于上面的例子中存在的一些问题

上面对于流事件的示例中,是存在隐患的。具体的问题原因,可以查看我的这篇文章简单理解 backpressure(背压)机制


// 其实这段代码是其实有问题的
readable.on('data', (chunk) => {
    writable.write(chunk);
})
复制代码

流的可读流的暂停和流动模式

默认情况下,可读流是处于暂停状态的,但是它们可以被切换到流动模式,并在需要时切换回暂停模式。有时,模式会被自动发生切换。

在暂停模式时,我们可以使用read方法从流中读取数据。

const fs = require('fs')

const readable = fs.createReadStream('./数据源.json')
const writable = fs.createWriteStream('./目标.json')
// 当可读流是可以被读取时或者会发生变化时或者到达流的尽头时。readable可以被触发
readable.on('readable', () => {
    let chunk
    while (chunk = readable.read(1)) {
        writable.write(chunk)
    }
})
复制代码

在流动模式时,数据会持续流动,我们必须添加事件并消费它。如果不能处理流动的数据,数据是会丢失的。我们可以添加data事件处理数据。添加data事件,会自动将可读流的模式,由暂停模式切换到流动模式。

如果需要实现两种模式的手动切换可以使用resume()(从暂停模式中恢复)和pause()(进入暂停模式)方法。(如果监听了可读流的readable的事件,resume()方法无效)

下面的例子中,每一次通过可写流写入一点数据,都会暂停一秒可读流1s,1s后继续写入。

const fs = require('fs')

const readable = fs.createReadStream('./数据源.json')
const writable = fs.createWriteStream('./目标.json')

// 自动切换到流动模式
readable.on('data', (chunk) => {
    console.log('写入')
    writable.write(chunk)
    readable.pause()
    console.log('暂停')
    // 暂停1s后,重新切换到流动模式
    setTimeout(() => {
        readable.resume()
    }, 1000)
})
复制代码

下图是两种模式之前的切换(图片来自于Samer Buna的在线课程)。添加data事件时(单独添加data事件,不存在readable事件时),模式会自动切换。

暂停和流动模式的互相转换

流的实现

创建自定义可写流

创建一个自定义的可写流,我们需要继承 stream.Writable 类。并在子类中实现 _write 方法。


const { Writable } = require('stream')

class CustomWritable extends Writable {
    /**
     * @param chunk 需要写入的数据
     * @param encoding 编码格式
     * @param next 处理完成的回调
     */
    _write(chunk, encoding, next) {
        try {
            // 仅仅是做了一个打印
            console.log(`仅仅做了一个打印:${chunk.toString()}`)
            next()
        } catch (error) {
            next(error)
        }
    }
}
复制代码

这个流本身没有多大的意义。这个自定义可写流,只会对可读流输入的数据,进行一个打印。我们可以链接到 process.stdin 可读流上,对终端的输入,进行一个打印。


const customWritable = new CustomWritable()

process.stdin.pipe(customWritable)
复制代码

创建自定义可读流

创建一个可读流,我们需要继承 stream.Readable 类。并在子类中实现 _read 方法。

const { Readable } = require('stream'); 

class CustomReadable extends Readable {
    _read () {
    }
}
复制代码

这个流本身也没有很大的意义。我们可以使用 push 方法,将数据添加到流的内部队列中,以供流进行消费。我们可以结合前一个自定义的可写流,将 push 的数据打印出来。


const customReadable = new CustomReadable()
const customWritable = new CustomWritable()

customReadable.push('我喜欢西尔莎罗南')
// 通知流不会有任何数据了
customReadable.push(null)

// 可读流将数据传递给可写流
// 可写流将数据打印出来
customReadable.pipe(customWritable)
复制代码

创建自定义转换流

创建一个可读流,我们需要继承 stream.Transform 类。并在子类中实现 _transform 方法。

const { Transform } = require('stream')

class CustomTransform extends Transform {
    _transform (chunk, encoding, next) {
        // 我们把可读流的内容,全部转换为大写
        chunk = chunk.toString().toUpperCase()
        next(null, chunk)
    }
}
复制代码

这个自定义流,会将可读流传过来的字符串,全部转换为大写。

const customReadable = new CustomReadable()
const customWritable = new CustomWritable()
const customTransform = new CustomTransform()

customReadable.push('abcdefg')
customReadable.push(null)

customReadable
    .pipe(customTransform)
    .pipe(customWritable)
复制代码

对象模式

Node中的流,默认都是使用Buffer 或者 字符串进行传输,我们可以开启 objectMode 开关。使流可以传输js的对象。


const { Readable, Writable } = require('stream')

class CustomReadable extends Readable {
    _read () {
    }
}

class CustomWritable extends Writable {
    _write(chunk, _, next) {
        try {
            // 这里可以打印js对象
            // chunk可以使js对象
            console.log(chunk)
            next()
        } catch (error) {
            next(error)
        }
    }
}

const customReadable = new CustomReadable({
    objectMode: true // 开启对象模式
})
const customWritable = new CustomWritable({
    objectMode: true // 开启对象模式
})

// 我们可以传输对象了
customReadable.push(['a', 'b', 'c', 'd'])
customReadable.push(null)

customReadable.pipe(customWritable)
复制代码

参考