本章节我们讨论nodejs中另一项非常基本和核心的技术: stream(流)。
概述
stream并不是nodejs独有的概念和技术。实际上笔者觉得本质上stream是一种通用和抽象的数据和信息处理的"程序模式",和特定的语言和技术都没有关系,也就是说任何编程语言都可以实现使用流技术来进行大规模信息的处理。
在笔者的理解中,stream的基本思想是: 对于某些非常大型或者无法预测的数据对象和信息,如果无法从整体上进行处理,就应该先将其分割成为一个个的小型数据块(chunk),依次处理。一次处理一小块数据,这时对于内存和计算资源的占用都比较小,这一部分处理完成后,再处理下一部分,直到所有的数据都处理完成。所以,stream的本质就是数据切分+顺序处理。
使用stream模式来处理信息,主要是工程和实现方面的考虑,如:
- 可以以相对比较少的计算资源,来处理原本无法处理的大规模数据
- 可以处理无法预测或事先确定的信息和数据
- 可以控制信息处理的过程和节奏
- 可以实现信息传输和处理的异步化
根据上面的思想,设计对应处理的通用的stream模式的程序也可以是模式化的。在最高的抽象级别来看,任何程序或者函数,都可以分为输入、处理和输出三个部分,我们也可以将所有流处理的方式分为读取流(输入)、转换流(转换)和写入流(输出)三种类型。
虽然流计算的方式是语言无关的,但由于JS本身就是基于事件驱动的程序模型,stream在js中的实现和应用显得更加自然和流畅。我们下面对流计算方式实现的分析就是以nodejs的实现作为示例和基础。从这些分析中,我们还可以看到,nodejs中的stream机制提供了对很好的数据流式访问和处理的支持,并很好地契合了nodejs的非阻塞I/O模型,是其异步编程理念的重要抽象与实现。而且,正确的理解和使用stream可以简化代码,提高程序执行的性能和效率。
读取流(Readable)
读取流对象,可以将一个数据读取或者加载的操作,分解称为一个个小型的数据块读取和处理的过程,避免由于需要一次性加载大规模数据造成的资源迅速消耗。典型的使用场景就是文件的读取,和网络数据传输。
根据这个工作过程和原理,基础的读取流对象的基本设计应该如下:
对于读取流而言,它可以基于某种数据来源,创建一个读取流的程序对象,这个对象可以"侦听"三种类型的事件或者在相应的状态变化时触发:
- 读取状态就绪,一般是初始化或者读取开始,这时可以有机会主动开始读取数据
- 读取事件,由对象自动开始读取并传入读取的内容(数据片段)
- 读取结束,在这个事件中,有机会可以做结束和收尾的工作
为了更好的理解这一点,我们来看一个实例,这是nodejs http对象标准的处理请求数据的方法,笔者做了简单的改进,特别是数据分片写入和读取的部分:
js
const
http = require("http"),
PORT = 8181;
http.createServer((request, response) => {
console.log('Now we have a http message with headers but no data yet.');
let rdata,data= [];
request
.on('data', chunk => {
console.log('A chunk of data has arrived: ', chunk, chunk.toString());
data.push(chunk);
})
.on('end', () => {
rdata = Buffer.concat(data).toString();
console.log("request end:", rdata);
// do response
response.statusCode = 200;
response.write("Data1");
response.write("Data2");
response.write(rdata);
response.end("OK");
});
}).listen(PORT);
const doRequest =()=>{
console.log("request:", Date.now());
const
pData1 = "China中国",
pData2 = "Japan日本";
const option = {
method: "POST",
port: PORT,
headers: {
'Content-Type': 'text/plain',
'Content-Length': Buffer.byteLength(pData1+pData2),
},
};
const req = http.request(option, (res) => {
// console.log(`STATUS: ${res.statusCode}`);
// console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res
.setEncoding('utf8')
.on('data', (chunk) => {
console.log(`Chunk: ${chunk}`);
})
.on('end', () => {
console.log('response end.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// 将数据写入请求正文
req.write(pData1);
req.write(pData2);
req.end();
};
setTimeout(doRequest, 1000);
setTimeout(()=> process.exit(1), 2000);
这里的request对象,就是一个读取流对象,在http请求到来时,由server创建并且作为参数传入处理方法。作为开发者,只需要按照业务需求,实现对应的事件处理程序就可以了,在这里就是onData事件和onEnd事件。
nodejs和其生态中,常见的读取流有:
- HTTP作为服务端的请求对象,或者客户端的响应对象,模式基本相同
- 文件读取流
- zlib输入流
- crypto输入流
- TCP sockets
- child process stdout and stderr
- process.stdin
对于nodejs的stream,默认情况下数据块的格式是buffer,但通常也可以指定使用某种编码格式如utf-8,数据块会自动编码成对应的格式。
写入流(Writable)
类似的,我们也可以大体规划写入流的实现规范。
对于写入流而言,它也可以基于其输出的目标,创建一个写入流的程序对象。这个对象应该有一个write方法,让程序可以按需调用,并传入需要写入的小型数据块,从而输出到写入的目标;然后提供一个end或者finish方法,可以关闭这个写入流。
前一章节读取流的实例里面,其实也说明了写入流的使用方式。http的response对象,也实现了write接口,可以调用实现chunk化的数据写入,它们会依次的传输到客户端上。
在nodejs和其生态中,常见的写入流包括:
- HTTP作为服务端的响应对象
- 文件写入
- zlib的输出流
- crypto的输出流
- TCP sockets
双工(Duplex)和转换(Transform)
双工指一个流对象,同时实现了Readable和Writable两个接口;而转换流指在双工的基础上,可以使用或者定义某种方式,将输入流和输出流联系起来,从而形成一种可以在流化信息中处理和转换的效果。
常见的转换流实例包括:
- zlib streams,可以对流信息进行"实时的"压缩和解压缩
- crypto streams,可以对流信息进行"实时的"加解密
笔者理解,这个转换流一般不会单独存在,而需要配合输入或输出流一起的。这些数据片转换的功能,当然可以在读取流的onData,或者写入流的write方法中实现,但那样就将处理耦合在流中了,无法做到组件化。而使用转换流,可以基于通用的流处理规范,编写可插入和方便解耦的功能模块,从而很好的实现了程序和功能的模块化。
流水线(Pipeline)
我们可以看到,无论是哪种流处理方式,它们在一个事件中,都是处理和转换一小块数据。那么我们就可以基于这一个数据片,将输入、转换和输出串联起来,完成一个灵活而完整的信息处理过程。这种串联结合的方式,可以称为管道或者流水线(pipeline)。
nodejs官方文档中,有一个代码示例,让我们清晰的了解流水线的构造和工作方式:
js
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const zlib = require('node:zlib');
async function run() {
await pipeline(
fs.createReadStream('archive.tar'),
zlib.createGzip(),
fs.createWriteStream('archive.tar.gz'),
);
console.log('Pipeline succeeded.');
}
run().catch(console.error);
pipeline函数的定义如下:
stream.pipeline(source[, ...transforms], destination[, options])
stream.pipeline(streams[, options])
这个定义和结构非常直观。如果是可变参数,第一个是源数据(读取)流对象,中间的是转换流对象,最后的是结果(写入)流对象,也可以是一个流对象的数组。从定义中我们也可以看到,使用pipeline来进行多个基于数据流的信息处理,非常简洁方便。
再来看一个使用pipeline的经典和常用场景,就是静态文件Web服务,在nodejs中的实现(同样来自nodejs官方技术文档):
js
const server = http.createServer((req, res) => {
const fileStream = fs.createReadStream('./fileNotExist.txt');
pipeline(fileStream, res, (err) => {
if (err) {
console.log(err); // No such file
// this message can't be sent once `pipeline` already destroyed the socket
return res.end('error!!!');
}
});
});
如果没有stream,我们需要将文件内容全部读取后,再使用响应对象将其发送出去;使用读取流的方式,可以分片的读取文件,分片发出,但需要编写代码将读取流的onData方法和写入流的Write方法连接起来;而这里直接使用流水线方法,来连接读取流和写入流(response),确实是最简洁高效的方法。
如果不使用转换流对象或者pipeline,也可以使用读取流的pipe方法,实现类似的功能。如下面的代码:
js
// 不使用文件流
fs.readFile('src.txt', 'utf8', (err, data) => {
fs.writeFile('dest.txt', data);
});
// 使用文件流
const src = fs.createReadStream('src.txt', 'utf8');
const dest = fs.createWriteStream('dest.txt', 'utf8');
// 不使用pipe
src.on('data', chunk => dest.write(chunk));
src.on('end', () => dest.end());
// 使用pipe
src.pipe(dest);
这部分代码用于读取一个文件,并写入另一个文件。代码中分别示例了不使用文件流,使用文件流的一般方式和pipe方式。可以看到,使用管道处理,直观而又方便。但显然这种方式只限于非常简单的信息直接处理的场合,如果要在过程中进行相应的处理或转换,或者希望监控和管理数据处理过程,还是要在数据片的层面上进行处理。
主动的流过程控制
有些业务场景,需要对数据流的处理过程和状态有更加精细的控制。这方面,nodejs的stream也提供了相应的机制,它可以提供給开发者可以主动控制信息流处理的过程,包括按需启动、读取内容的大小、暂停恢复、错误处理、结束处理、主动退出等等。它是通过一系列逻辑关联的事件组合和处理实现的。
流读取
对于读取流的过程控制,我们通过一段示例代码(官方技术文档)简单的分析和理解一下:
js
const readable = getReadableStreamSomehow();
// 'readable' may be triggered multiple times as data is buffered in
readable.on('readable', () => {
let chunk;
console.log('Stream is readable (new data received in buffer)');
// Use a loop to make sure we read all currently available data
while (null !== (chunk = readable.read())) {
console.log(`Read ${chunk.length} bytes of data...`);
}
});
// 'end' will be triggered once when there is no more data available
readable.on('end', () => {
console.log('Reached end of stream.');
});
以读取流为例,这段代码的理解要点如下:
- stream本身就是一个readable对象实例
- 如果流正确打开,并且可以使用,则会触发readable事件
- 在readable事件中,可以调用read方法,自行读取流内容,并可以控制读取长度
- 读取方法可以循环调用,直到没有新的读取内容
- 显然在这个循环中,也可以控制读取的节奏速度和取消读取
- end事件表明读取结束,在这里做收尾处理
- 虽然代码中没有,但应该也有error事件,可以做错误处理
查阅技术文档,readable实现和提供的事件类型和主要方法包括:
- close: 关闭流事件
- data: 读取数据事件
- end: 结束读取事件
- error: 错误事件
- pause/resume: 读取暂停/恢复事件
- readable: 可以读取事件
- read: 读取方法
- pause/resume: 暂停/恢复读取方法
- pipe/unpipe: 挂载/取消挂载写入流
- setEncoding: 设置读取流编码方式
- ....
流写入
写入流的控制相对比较简单,因为本身就一个主动操作。相关的示例代码如下:
js
// Write 'hello, ' and then end with 'world!'.
const fs = require('node:fs');
const file = fs.createWriteStream('example.txt');
file.write('hello, ');
file.end('world!');
// Writing more now is not allowed!
写入流相关的事件和主要方法包括:
- close: 写入流关闭事件
- drain: 无可写数据事件
- error: 写入错误事件
- finish: 写入结束事件
- pipe/unpipe: 关联写入流的挂载/取消挂载事件
- write: 写数据方法
- cork/uncork:批量写入声明/实际写入方法
- end: 结束写入方法
- setDefaultEncoding: 设置写入流编码方式
- destory: 消耗写入流
- ...
合并写入
nodejs写入流还支持一种"合并写入"的机制,名为cork。笔者理解有点批处理的意思,就是可以将多次小型的写入过程,可以合并称为一个大的真实的写入操作,可以提高写入性能。比如下面的示例代码:
js
stream.cork();
stream.write('some ');
stream.cork();
stream.write('data ');
process.nextTick(() => {
stream.uncork();
// The data will not be flushed until uncork() is called a second time.
stream.uncork();
});
相关要点是:
- 调用cork来声明一批写入数据
- cork中的write并没有真正写入目标,而是暂存在缓冲区
- 调用uncork来实现写入
- cork和uncork必须是成对出现
cork的英文原意是"塞子",形象的理解就是使用一个塞子来控制水管中的水流,满了之后一次性释放。
当然,这些过程涉及很多细节方面的处理,开发中需要仔细阅读和理解相关的设定和实现,并在实践中确认能够正确的使用并且满足业务的需求。
小结
由于篇幅和笔者理解水平的限制,关于nodejs stream技术的细节,包括升级到stream这种程序思维和方法论,本文还是无法详尽深入的涵盖,肯定留有遗珠之恨。但笔者觉得,除了是一种编程和数据处理技术之外,stream还是一种很精秒重要的思维和系统架构方式,值得努力深入的学习、体会、理解和实践。
最后,总结一下关于nodejs中stream的一些重要的概念和理解:
- nodejs中的Stream是一个非常重要的概念,可以把它抽象为数据的流动管道
- Stream是一个抽象接口,nodejs中提供了很多Stream类实现了这个接口
- Stream对数据的传输进行了封装,我们基本上只需要关注数据的输入(读取)和输出(写入)
- 常见的Stream 类型有可读流(readable)、可写流(writable)、转换流(transform)等
- Stream 的工作方式是事件驱动的,通过监听不同的事件可以对传输的数据进行处理
- Stream 使用了管道原理, 可以将不同的 Stream 链在一起,构成流水线,数据会按次序通过整个管道,管道就是功能化的模块,可以灵活的组合和拆分,以满足业务对数据高效实时处理的需求
- Stream 可以处理不同格式的数据,如文本、二进制、对象等
- Stream 有很多实际应用,如文件访问、网络请求、数据压缩等