MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js Stream 流与异步数据处理

2021-12-234.5k 阅读

什么是 Node.js Stream 流

在 Node.js 环境中,Stream 流是一种处理流式数据的抽象接口。简单来说,它允许我们以连续、渐进的方式处理数据,而不是一次性将所有数据加载到内存中。这种处理方式在处理大文件、网络通信等场景下显得尤为重要,因为它可以显著减少内存的占用,提高应用程序的性能和稳定性。

Stream 本质上是一个基于事件驱动的模块,它通过一系列事件来通知数据的流动和状态的变化。Node.js 提供了四种基本类型的流:可读流(Readable Stream)、可写流(Writable Stream)、双工流(Duplex Stream)和转换流(Transform Stream)。

可读流(Readable Stream)

可读流用于从数据源读取数据。数据源可以是文件系统中的文件、网络连接、标准输入等。在 Node.js 中,可读流有两种工作模式:暂停模式(paused mode)和流动模式(flowing mode)。

在暂停模式下,数据不会自动流动,需要手动调用 read() 方法来从流中读取数据。例如,以下代码展示了如何使用可读流读取一个文件并以暂停模式处理数据:

const fs = require('fs');
const readableStream = fs.createReadStream('example.txt');

readableStream.on('readable', () => {
    let chunk;
    while (null!== (chunk = readableStream.read(1024))) {
        console.log(`Read ${chunk.length} bytes of data: ${chunk.toString()}`);
    }
});

readableStream.on('end', () => {
    console.log('All data has been read.');
});

在上述代码中,我们使用 fs.createReadStream() 创建了一个可读流来读取 example.txt 文件。通过监听 readable 事件,我们在数据可读时调用 read() 方法读取数据块,每次读取 1024 字节。当所有数据读取完毕,end 事件会被触发。

在流动模式下,数据会自动从流中流出,并通过 data 事件进行处理。要切换到流动模式,可以调用 resume() 方法,或者为 data 事件添加监听器。例如:

const fs = require('fs');
const readableStream = fs.createReadStream('example.txt');

readableStream.on('data', (chunk) => {
    console.log(`Received ${chunk.length} bytes of data: ${chunk.toString()}`);
});

readableStream.on('end', () => {
    console.log('All data has been read.');
});

这里,一旦为 data 事件添加了监听器,可读流就进入了流动模式,数据会不断地通过 data 事件传递给监听器。

可写流(Writable Stream)

可写流用于将数据写入到目的地,目的地可以是文件系统中的文件、网络连接、标准输出等。我们可以通过 write() 方法向可写流中写入数据,并且可以监听 drain 事件来了解流是否已经处理完之前写入的数据。

以下是一个将数据写入文件的可写流示例:

const fs = require('fs');
const writableStream = fs.createWriteStream('output.txt');

const data = 'This is some data to be written to the file.';
const writeResult = writableStream.write(data);

if (!writeResult) {
    console.log('The write buffer is full.');
    writableStream.once('drain', () => {
        console.log('Buffer has been drained.');
    });
}

writableStream.end();
writableStream.on('finish', () => {
    console.log('All data has been written to the stream.');
});

在这个例子中,我们使用 fs.createWriteStream() 创建了一个可写流,将字符串 data 写入到 output.txt 文件中。write() 方法返回一个布尔值,表示写入操作是否成功。如果返回 false,说明写入缓冲区已满,需要等待 drain 事件触发后再继续写入。最后,调用 end() 方法表示没有更多数据要写入,当所有数据都被处理完毕后,finish 事件会被触发。

双工流(Duplex Stream)

双工流同时具备可读流和可写流的功能,它允许数据在两个方向上流动。例如,网络套接字(net.Socket)就是一种双工流,既可以发送数据(可写),也可以接收数据(可读)。

以下是一个简单的自定义双工流示例:

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

class MyDuplexStream extends Duplex {
    constructor() {
        super();
        this.buffer = [];
    }

    _write(chunk, encoding, callback) {
        this.buffer.push(chunk);
        callback();
    }

    _read(size) {
        const chunk = this.buffer.shift();
        if (chunk) {
            this.push(chunk);
        } else {
            this.push(null);
        }
    }
}

const myDuplex = new MyDuplexStream();
myDuplex.write('Hello');
myDuplex.write(' ');
myDuplex.write('World');

myDuplex.on('data', (chunk) => {
    console.log(`Received: ${chunk.toString()}`);
});

在上述代码中,我们自定义了一个双工流 MyDuplexStream_write() 方法用于处理写入的数据,_read() 方法用于从内部缓冲区读取数据。当我们向 myDuplex 写入数据后,通过监听 data 事件可以读取到写入的数据。

转换流(Transform Stream)

转换流是双工流的一种特殊类型,它在数据从可读端流向可写端的过程中对数据进行转换。例如,zlib 模块中的 DeflateInflate 流就是转换流,用于数据的压缩和解压缩。

以下是一个简单的转换流示例,将输入的字符串转换为大写:

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

class UppercaseTransform extends Transform {
    _transform(chunk, encoding, callback) {
        const upperCaseChunk = chunk.toString().toUpperCase();
        this.push(upperCaseChunk);
        callback();
    }
}

const inputData = 'hello world';
const uppercaseTransform = new UppercaseTransform();
uppercaseTransform.on('data', (chunk) => {
    console.log(chunk.toString());
});
uppercaseTransform.write(inputData);
uppercaseTransform.end();

在这个例子中,我们定义了一个 UppercaseTransform 转换流,在 _transform() 方法中,将输入的字符串转换为大写并通过 push() 方法输出。当我们向转换流写入数据 'hello world' 时,转换后的大写数据会通过 data 事件输出。

Stream 流与异步数据处理的关系

在 Node.js 中,异步编程是其核心特性之一,而 Stream 流在异步数据处理中扮演着至关重要的角色。

减少内存占用

传统的同步数据处理方式在处理大量数据时,通常需要一次性将所有数据加载到内存中进行操作。例如,读取一个大文件,如果使用同步方式,文件的全部内容会被读入内存,这对于内存有限的系统来说可能会导致内存溢出。而 Stream 流采用渐进式处理数据的方式,每次只处理一小部分数据,大大减少了内存的占用。

以读取一个非常大的文本文件并统计单词出现次数为例,如果使用传统方式可能如下:

const fs = require('fs');
const data = fs.readFileSync('largeFile.txt', 'utf8');
const words = data.split(/\s+/);
const wordCount = {};
words.forEach((word) => {
    if (!wordCount[word]) {
        wordCount[word] = 1;
    } else {
        wordCount[word]++;
    }
});
console.log(wordCount);

这种方式会将整个大文件读入内存,对于大文件可能会引发内存问题。而使用 Stream 流的方式则不同:

const fs = require('fs');
const ReadableStream = fs.createReadStream('largeFile.txt', 'utf8');
const wordCount = {};
ReadableStream.on('data', (chunk) => {
    const words = chunk.split(/\s+/);
    words.forEach((word) => {
        if (!wordCount[word]) {
            wordCount[word] = 1;
        } else {
            wordCount[word]++;
        }
    });
});
ReadableStream.on('end', () => {
    console.log(wordCount);
});

在这个 Stream 流的实现中,数据是以小块的形式逐步读取并处理的,不会一次性占用大量内存。

提高 I/O 性能

在涉及到文件系统 I/O 或网络 I/O 操作时,Stream 流能够充分利用 Node.js 的异步 I/O 特性,提高性能。由于 I/O 操作通常是比较耗时的,在传统的同步方式下,主线程会被阻塞,直到 I/O 操作完成。而 Stream 流基于事件驱动,在 I/O 操作进行时,主线程可以继续执行其他任务,当有数据可读或可写时,通过事件通知来处理数据。

例如,在网络通信中,当服务器从客户端接收大量数据时,使用 Stream 流可以避免阻塞服务器的主线程,使得服务器能够同时处理多个客户端的请求。以下是一个简单的 HTTP 服务器使用 Stream 流处理请求体数据的示例:

const http = require('http');
const server = http.createServer((req, res) => {
    let body = '';
    req.on('data', (chunk) => {
        body += chunk.toString();
    });
    req.on('end', () => {
        res.end(`You sent: ${body}`);
    });
});
const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

在这个例子中,req 对象是一个可读流,通过监听 data 事件逐步接收客户端发送的数据,当数据接收完毕,end 事件被触发,服务器可以对完整的数据进行处理并响应。

支持管道操作

Stream 流的一个强大特性是支持管道(pipe)操作。管道操作可以将多个流连接在一起,数据从一个流自动流向另一个流,形成一个数据处理链。这使得数据的处理流程更加清晰和高效。

例如,我们可以将一个可读流(如文件读取流)通过管道连接到一个可写流(如文件写入流),实现文件的复制:

const fs = require('fs');
const readableStream = fs.createReadStream('source.txt');
const writableStream = fs.createWriteStream('destination.txt');
readableStream.pipe(writableStream);

在上述代码中,readableStream 读取 source.txt 文件的数据,并通过 pipe 方法将数据直接写入到 writableStream,即 destination.txt 文件中。管道操作内部会自动处理数据的流动、背压等问题,使得代码简洁且高效。

背压(Backpressure)处理

在 Stream 流的异步数据处理中,背压是一个重要的概念。当可读流产生数据的速度快于可写流处理数据的速度时,就会出现背压问题。如果不妥善处理背压,可能会导致内存泄漏或数据丢失等问题。

背压产生的场景

例如,在一个网络应用中,服务器从网络套接字(可读流)接收数据,并将数据写入到文件(可写流)。如果网络速度较快,而磁盘 I/O 速度相对较慢,就可能出现可读流不断产生数据,而可写流来不及处理的情况,从而引发背压。

处理背压的方法

Node.js 的 Stream 模块提供了一些机制来处理背压。

对于可读流,在暂停模式下,当 write() 方法返回 false 时,说明可写流的缓冲区已满,此时可读流应该暂停读取数据。可以通过监听 drain 事件,当 drain 事件触发时,表示可写流已经处理了一些数据,缓冲区有了空闲空间,可读流可以继续读取数据。

以下是一个处理背压的示例:

const fs = require('fs');
const readableStream = fs.createReadStream('largeFile.txt');
const writableStream = fs.createWriteStream('output.txt');

let paused = false;
readableStream.on('data', (chunk) => {
    const writeResult = writableStream.write(chunk);
    if (!writeResult &&!paused) {
        console.log('Write buffer is full, pausing readable stream.');
        readableStream.pause();
        paused = true;
    }
});

writableStream.on('drain', () => {
    console.log('Write buffer has been drained, resuming readable stream.');
    readableStream.resume();
    paused = false;
});

readableStream.on('end', () => {
    writableStream.end();
});

在这个例子中,当 write() 方法返回 false 时,我们暂停可读流,并设置 paused 标志。当 drain 事件触发时,我们恢复可读流的读取。

对于转换流,处理背压也类似。转换流在处理数据时,如果输出端(可写部分)的缓冲区已满,需要暂停输入端(可读部分)的数据读取,直到输出端有空间。

高级应用场景

处理大型 JSON 文件

在处理大型 JSON 文件时,由于 JSON 文件通常需要一次性解析成内存中的对象,如果文件过大,会导致内存不足。使用 Stream 流结合 JSON 解析器可以有效地解决这个问题。

例如,我们可以使用 JSONStream 模块来逐行解析大型 JSON 文件:

const fs = require('fs');
const JSONStream = require('json-stream');

const readableStream = fs.createReadStream('largeJsonFile.json', 'utf8');
const jsonStream = JSONStream.parse('*');

readableStream.pipe(jsonStream);

jsonStream.on('data', (obj) => {
    // 处理每个 JSON 对象
    console.log(obj);
});

在这个例子中,JSONStream.parse('*') 创建了一个转换流,它可以逐行解析 JSON 文件中的对象,并通过 data 事件传递给监听器。这样,我们就可以在不将整个 JSON 文件加载到内存的情况下处理其中的每个对象。

实时数据处理与推送

在实时应用场景中,如实时监控系统、实时数据分析等,Stream 流可以实时处理和推送数据。例如,通过 WebSocket 接收客户端的实时数据,并进行实时分析和处理,然后将结果推送给其他客户端。

以下是一个简单的 WebSocket 服务器使用 Stream 流处理实时数据的示例:

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
    ws.on('message', (message) => {
        // 假设 message 是 JSON 格式的数据
        const data = JSON.parse(message);
        // 这里可以进行实时数据分析
        const result = data.value * 2;
        // 将结果推送给其他客户端
        wss.clients.forEach((client) => {
            if (client!== ws && client.readyState === WebSocket.OPEN) {
                client.send(JSON.stringify({ result }));
            }
        });
    });
});

在这个例子中,WebSocket 连接的 message 事件相当于一个可读流,每次接收到客户端的消息时,我们进行实时处理,并将结果推送给其他客户端。

分布式系统中的数据传输与处理

在分布式系统中,Stream 流可以用于节点之间的数据传输和处理。例如,在一个分布式文件系统中,文件可以通过 Stream 流在不同节点之间进行传输和同步。

假设我们有一个简单的分布式文件传输系统,其中一个节点作为文件的发送端,另一个节点作为接收端。发送端代码如下:

const net = require('net');
const fs = require('fs');
const server = net.createServer((socket) => {
    const readableStream = fs.createReadStream('fileToSend.txt');
    readableStream.pipe(socket);
});
server.listen(3000, () => {
    console.log('Server listening on port 3000');
});

接收端代码如下:

const net = require('net');
const fs = require('fs');
const client = net.connect({ port: 3000 }, () => {
    const writableStream = fs.createWriteStream('receivedFile.txt');
    client.pipe(writableStream);
});

在这个例子中,发送端通过 net.createServer() 创建一个服务器,将文件读取流通过管道连接到网络套接字,发送文件数据。接收端通过 net.connect() 连接到服务器,并将网络套接字通过管道连接到文件写入流,接收文件数据。

总结

Node.js 的 Stream 流为异步数据处理提供了强大而灵活的工具。通过可读流、可写流、双工流和转换流的协同工作,我们可以高效地处理大量数据,减少内存占用,提高 I/O 性能。同时,合理处理背压是保证流处理稳定性的关键。在实际应用中,Stream 流在处理大型文件、实时数据处理、分布式系统等场景中都有着广泛的应用。掌握 Stream 流的原理和使用方法,对于开发高性能、可扩展的 Node.js 应用程序至关重要。无论是初学者还是有经验的开发者,都应该深入理解并熟练运用 Stream 流来优化自己的代码。