深入理解流式传输数据(一)

介绍

我们将发现如何使用 Node 从文件或其他来源中提取数据,然后使用 Node 进行读取、写入和操作,就像使用 Node 一样容易。最终,我们将学习如何使用 Node 开发具有快速 I/O 接口的网络服务器,支持高并发应用程序,同时在成千上万的客户端之间共享实时数据。

为什么使用流

面对一个新的语言特性、设计模式或软件模块,一个新手开发者可能会开始使用它,因为它是新的和花哨的。另一方面,一个有经验的开发者可能会问,为什么需要这个?

文件很大,所以需要流。一些简单的例子可以证明它们的必要性。首先,假设我们想要复制一个文件。在 Node 中,一个天真的实现看起来像这样:

javascript 复制代码
// First attempt
console.log('Copying...');
let block = fs.readFileSync("source.bin");
console.log('Size: ' + block.length);
fs.writeFileSync("destination.bin", block);
console.log('Done.');

这非常简单。

调用readFileSync()时,Node 会将source.bin的内容(一个与脚本相同文件夹中的文件)复制到内存中,返回一个名为blockByteBuffer

一旦我们有了block,我们可以检查并打印出它的大小。然后,代码将block交给writeFileSync,它将内存块复制到一个新创建或覆盖的文件destination.bin的内容中。

这段代码假设以下事情:

  • 阻塞事件循环是可以的(不是!)
  • 我们可以将整个文件读入内存(我们不能!)

正如你在上一章中所记得的,Node 会一个接一个地处理事件,一次处理一个事件。良好的异步设计使得 Node 程序看起来好像同时做了各种事情,既对连接的软件系统又对人类用户来说,同时还为代码中的开发者提供了一个易于理解和抵抗错误的逻辑呈现。这一点尤为真实,尤其是与可能编写来解决相同任务的多线程代码相比。你的团队甚至可能已经转向 Node,以制作一个改进的替代品来解决这样一个经典的多线程系统。此外,良好的异步设计永远不会阻塞事件循环。

阻塞事件循环是不好的,因为 Node 无法做其他事情,而你的一个阻塞代码行正在阻塞。前面的例子,作为一个简单的脚本,从一个地方复制文件到另一个地方,可能运行得很好。它会在 Node 复制文件时阻塞用户的终端。文件可能很小,等待的时间很短。如果不是,你可以在等待时打开另一个 shell 提示符。这样,它与cpcurl等熟悉的命令并没有什么不同。

然而,从计算机的角度来看,这是相当低效的。每个文件复制不应该需要自己的操作系统进程。

此外,将之前的代码合并到一个更大的 Node 项目中可能会使整个系统不稳定。

你的服务器端 Node 应用程序可能同时让三个用户登录,同时向另外两个用户发送大文件。如果该应用程序执行之前的代码,两个下载将会停滞,三个浏览器会一直旋转。

所以,让我们一步一步地来修复这个问题:

javascript 复制代码
// Attempt the second
console.log('Copying...');
fs.readFile('source.bin', null, (error1, block) => {
  if (error1) {
    throw error1;
  }
  console.log('Size: ' + block.length);
  fs.writeFile('destination.bin', block, (error2) => {
    if (error2) {
      throw error2;
    }
    console.log('Done.');
  });
});

至少现在我们不再使用在它们标题中带有Sync的 Node 方法。事件循环可以再次自由呼吸。

但是:

  • 大文件怎么办?(大爆炸)
  • 你那里有一个相当大的金字塔(厄运)

尝试使用一个 2GB(2.0 x 2³⁰,或 2,147,483,648 字节)的源文件来运行之前的代码:

javascript 复制代码
RangeError: "size" argument must not be larger than 2147483647
 at Function.Buffer.allocUnsafe (buffer.js:209:3)
 at tryCreateBuffer (fs.js:530:21)
 at Object.fs.readFile (fs.js:569:14)
 ...

如果你在 YouTube 上以 1080p 观看视频,2GB 的流量大约可以让你看一个小时。之前的RangeError发生是因为2,147,483,647在二进制中是1111111111111111111111111111111,是最大的 32 位有符号二进制整数。Node 在内部使用这种类型来调整和寻址ByteBuffer的内容。

如果你交给我们可怜的例子会发生什么?更小,但仍然非常大的文件是不确定的。当它工作时,是因为 Node 成功地从操作系统获取了所需的内存。在复制操作期间,Node 进程的内存占用量会随着文件大小而增加。鼠标可能会变成沙漏,风扇可能会嘈杂地旋转起来。承诺会有所帮助吗?:

javascript 复制代码
// Attempt, part III
console.log('Copying...');
fs.readFileAsync('source.bin').then((block) => {
  console.log('Size: ' + block.length);
  return fs.writeFileAsync('destination.bin', block);
}).then(() => {
 console.log('Done.');
}).catch((e) => {
  // handle errors
});

不,本质上不是。我们已经扁平化了金字塔,但大小限制和内存问题仍然存在。

我们真正需要的是一些既是异步的,又是逐步的代码,从源文件中获取一小部分,将其传送到目标文件进行写入,并重复该循环,直到完成,就像古老的灭火队一样。

这样的设计会让事件循环在整个时间内自由呼吸。

这正是流的作用:

javascript 复制代码
// Streams to the rescue
console.log('Copying...');
fs.createReadStream('source.bin')
.pipe(fs.createWriteStream('destination.bin'))
.on('close', () => { console.log('Done.'); });

在实践中,规模化的网络应用通常分布在许多实例中,需要将数据流的处理分布到许多进程和服务器中。在这里,流文件只是一个数据流,被分成片段,每个片段可以独立查看,而不受其他片段的可用性的影响。你可以写入数据流,或者监听数据流,自由动态分配字节,忽略字节,重新路由字节。数据流可以被分块,许多进程可以共享块处理,块可以被转换和重新插入,数据流可以被精确发射和创造性地管理。

回顾我们在现代软件和模块化规则上的讨论,我们可以看到流如何促进独立的共享无事务的进程的创建,这些进程各自完成一项任务,并且组合起来可以构成一个可预测的架构,其复杂性不会妨碍对其行为的准确评估。如果数据接口是无争议的,那么数据映射可以准确建模,而不考虑数据量或路由的考虑。

在 Node 中管理 I/O 涉及管理绑定到数据流的数据事件。Node Stream 对象是EventEmitter的一个实例。这个抽象接口在许多 Node 模块和对象中实现,正如我们在上一章中看到的那样。让我们首先了解 Node 的 Stream 模块,然后讨论 Node 中如何通过各种流实现处理网络 I/O;特别是 HTTP 模块。

探索流

根据 Bjarne Stoustrup 在他的书《C++程序设计语言》(第三版)中的说法:

"为编程语言设计和实现通用的输入/输出设施是非常困难的... I/O 设施应该易于使用、方便、安全;高效、灵活;最重要的是完整。"

让人不惊讶的是,一个专注于提供高效和简单 I/O 的设计团队,通过 Node 提供了这样一个设施。通过一个对称和简单的接口,处理数据缓冲区和流事件,使实现者不必关心,Node 的 Stream 模块是管理内部模块和模块开发人员异步数据流的首选方式。

在 Node 中,流只是一系列字节。在任何时候,流都包含一个字节缓冲区,这个缓冲区的长度为零或更大:

流中的每个字符都是明确定义的,因为每种类型的数字数据都可以用字节表示,流的任何部分都可以重定向或管道到任何其他流,流的不同块可以发送到不同的处理程序,等等。这样,流输入和输出接口既灵活又可预测,并且可以轻松耦合。

Node 还提供了第二种类型的流:对象流。对象流不是通过流动内存块,而是通过 JavaScript 对象传输。字节流传输序列化数据,如流媒体,而对象流适用于解析的结构化数据,如 JSON 记录。

数字流可以用流体的类比来描述,其中个别字节(水滴)被推送通过管道。在 Node 中,流是表示可以异步写入和读取的数据流的对象。

Node 的哲学是非阻塞流,I/O 通过流处理,因此 Stream API 的设计自然地复制了这一一般哲学。事实上,除了以异步、事件方式与流交互外,没有其他方式------Node 通过设计阻止开发人员阻塞 I/O。

通过抽象流接口暴露了五个不同的基类:ReadableWritableDuplexTransformPassThrough 。每个基类都继承自EventEmitter,我们知道它是一个可以绑定事件监听器和发射器的接口。

正如我们将要学习的,并且在这里强调的,流接口是一个抽象接口。抽象接口充当一种蓝图或定义,描述了必须构建到每个构造的流对象实例中的特性。例如,可读流实现需要实现一个public read方法,该方法委托给接口的internal _read方法。

一般来说,所有流实现都应遵循以下准则:

  • 只要存在要发送的数据,就向流写入,直到该操作返回false,此时实现应等待drain事件,表示缓冲的流数据已经清空。
  • 继续调用读取,直到收到null值,此时等待可读事件再恢复读取。
  • 几个 Node I/O 模块都是以流的形式实现的。网络套接字、文件读取器和写入器、stdinstdout、zlib 等都是流。同样,当实现可读数据源或数据读取器时,应该将该接口实现为流接口。

重要的是要注意,在 Node 的历史上,Stream 接口在某些根本性方面发生了变化。Node 团队已尽最大努力实现兼容的接口,以便(大多数)旧程序可以继续正常运行而无需修改。在本章中,我们不会花时间讨论旧 API 的具体特性,而是专注于当前的设计。鼓励读者查阅 Node 的在线文档,了解迁移旧程序的信息。通常情况下,有一些模块会用方便、可靠的接口包装 流。一个很好的例子是:github.com/rvagg/through2.

可读流

产生数据的流,另一个进程可能感兴趣的,通常使用Readable流来实现。Readable流保存了实现者管理读取队列、处理数据事件的发射等所有工作。

要创建一个Readable流,请使用以下方法:

php 复制代码
const stream = require('stream');
let readable = new stream.Readable({
  encoding: "utf8",
  highWaterMark: 16000,
  objectMode: true
});

如前所述,Readable作为一个基类暴露出来,可以通过三种选项进行初始化:

  • encoding:将缓冲区解码为指定的编码,默认为 UTF-8。
  • highWaterMark:在停止从数据源读取之前,保留在内部缓冲区中的字节数。默认为 16 KB。
  • objectMode:告诉流以对象流而不是字节流的方式运行,例如以 JSON 对象流而不是文件中的字节流。默认为false

在下面的示例中,我们创建一个模拟的Feed对象,其实例将继承Readable流接口。我们的实现只需要实现Readable的抽象_read方法,该方法将向消费者推送数据,直到没有更多数据可以推送为止,然后通过推送null值来触发Readable流发出一个end事件:

ini 复制代码
const stream = require('stream');

let Feed = function(channel) {
   let readable = new stream.Readable({});
   let news = [
      "Big Win!",
      "Stocks Down!",
      "Actor Sad!"
   ];
   readable._read = () => {
      if(news.length) {
         return readable.push(news.shift() + "\n");
      }
      readable.push(null);
   };
   return readable;
};

现在我们有了一个实现,消费者可能希望实例化流并监听流事件。两个关键事件是readableend

只要数据被推送到流中,readable事件就会被触发。它会提醒消费者通过Readableread方法检查新数据。

再次注意,Readable实现必须提供一个private _read方法,为消费者 API 公开的public read方法提供服务。

当我们向Readable实现的push方法传递null值时,end事件将被触发。

在这里,我们看到一个消费者使用这些方法来显示新的流数据,并在流停止发送数据时提供通知:

javascript 复制代码
let feed = new Feed();

feed.on("readable", () => {
   let data = feed.read();
   data && process.stdout.write(data);
});
feed.on("end", () => console.log("No more news"));
// Big Win!
// Stocks Down!
// Actor Sad!
// No more news

同样,我们可以通过使用objectMode选项来实现对象流:

ini 复制代码
const stream = require('stream');

let Feed = function(channel) {
   let readable = new stream.Readable({
      objectMode : true
   });
   let prices = [{price : 1},{price : 2}];
   readable._read = () => {
      if(prices.length) {
         return readable.push(prices.shift());
      }
      readable.push(null);
   };
   return readable;
};

在设置为 objectMode 后,每个推送的块都预期是一个对象。因此,该流的读取器可以假定每个read()事件将产生一个单独的对象:

javascript 复制代码
let feed = new Feed();
feed.on("readable", () => {
   let data = feed.read();
   data && console.log(data);
});
feed.on("end", () => console.log("No more news"));
// { price: 1 }
// { price: 2 }
// No more news

在这里,我们看到每个读取事件都接收一个对象,而不是缓冲区或字符串。

最后,Readable流的read方法可以传递一个参数,指示从流的内部缓冲区中读取的字节数。例如,如果希望逐字节读取文件,可以使用类似于以下的例程来实现消费者:

ini 复制代码
let Feed = function(channel) {
   let readable = new stream.Readable({});
   let news = 'A long headline might go here';
   readable._read = () => {
      readable.push(news);
      readable.push(null);
   };
   return readable;
};

请注意,我们将整个新闻推送到流中,并以 null 终止。流已经准备好了整个字节字符串。现在消费者:

javascript 复制代码
feed.on('readable', () => {
   let character;
   while(character = feed.read(1)) {
      console.log(character.toString());
   }
});
// A
// 
// l
// o
// n
// ...
// No more bytes to read

在这里,应该清楚的是Readable流的缓冲区一次性填满了许多字节,但是却是离散地读取。

推送和拉取

我们已经看到Readable实现将使用push方法来填充用于读取的流缓冲区。在设计这些实现时,重要的是考虑如何管理流的两端的数据量。向流中推送更多数据可能会导致超出可用空间(内存)的复杂情况。在消费者端,重要的是要保持对终止事件的意识,以及如何处理数据流中的暂停。

我们可以将通过网络传输的数据流的行为与水流经过软管进行比较。

与水流经过软管一样,如果向读取流中推送的数据量大于消费者端通过read方法有效排出的数据量,就会产生大量背压,导致数据在流对象的缓冲区中开始积累。由于我们正在处理严格的数学限制,read方法根本无法通过更快地读取来释放这种压力------可用内存空间可能存在硬性限制,或者其他限制。因此,内存使用可能会危险地增加,缓冲区可能会溢出,等等。

因此,流实现应该意识到并响应push操作的响应。如果操作返回false,这表明实现应该停止从其源读取(并停止推送),直到下一个_read请求被发出。

与上述内容相结合,如果没有更多数据可以推送,但将来预期会有更多数据,实现应该push一个空字符串(""),这不会向队列中添加任何数据,但确保将来会触发一个readable事件。

虽然流缓冲区最常见的处理方式是向其push(将数据排队),但有时您可能希望将数据放在缓冲区的前面(跳过队列)。对于这些情况,Node 提供了一个unshift操作,其行为与push相同,除了在缓冲区放置数据的差异之外。

可写流

Writable流负责接受某个值(一系列字节,一个字符串)并将数据写入目标。将数据流入文件容器是一个常见的用例。

创建Writable流:

ini 复制代码
const stream = require('stream');
let readable = new stream.Writable({
  highWaterMark: 16000,
  decodeStrings: true
});

Writable流构造函数可以用两个选项实例化:

  • highWaterMark:在写入时流缓冲区将接受的最大字节数。默认值为 16 KB。
  • decodeStrings:是否在写入之前将字符串转换为缓冲区。默认为true

Readable流一样,自定义的Writable流实现必须实现_write处理程序,该处理程序将接收发送给实例的write方法的参数。

你应该将Writable流视为一个数据目标,比如你正在上传的文件。在概念上,这与Readable流中push的实现类似,其中一个推送数据直到数据源耗尽,并传递null来终止读取。例如,在这里,我们向流写入了 32 个"A"字符,它将把它们记录下来:

ini 复制代码
const stream = require('stream');

let writable = new stream.Writable({
   decodeStrings: false
});

writable._write = (chunk, encoding, callback) => {
   console.log(chunk.toString());
   callback();
};

let written = writable.write(Buffer.alloc(32, 'A'));
writable.end();

console.log(written);

// AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
// true

这里有两个关键点需要注意。

首先,我们的_write实现在写入回调后立即触发callback函数,这个回调函数始终存在,无论实例的write方法是否直接传递了callback。这个调用对于指示写入尝试的状态(失败或成功)非常重要。

其次,调用 write 返回了true。这表明在执行请求的写操作后,Writable实现的内部缓冲区已经被清空。如果我们发送了大量数据,足以超过内部缓冲区的默认大小,会怎么样呢?

修改前面的例子,以下将返回false

ini 复制代码
let written = writable.write(Buffer.alloc(16384, 'A'));
console.log(written); // Will be 'false'

write返回false的原因是它已经达到了highWaterMark选项的默认值 16 KB(16 * 1,024)。如果我们将这个值改为16383write将再次返回true(或者可以简单地增加它的值)。

write返回false时,你应该怎么做?你肯定不应该继续发送数据!回到我们水管的比喻:当流满时,应该等待它排空后再发送更多数据。Node 的流实现会在安全写入时发出drain事件。当write返回false时,在发送更多数据之前监听drain事件。

综合我们所学到的知识,让我们创建一个highWaterMark值为 10 字节的Writable流。然后设置一个模拟,我们将推送一个大于highWaterMark的数据字符串到stdout,然后等待缓冲区溢出并在发送更多数据之前等待drain事件触发:

javascript 复制代码
const stream = require('stream');

let writable = new stream.Writable({
   highWaterMark: 10
});

writable._write = (chunk, encoding, callback) => {
   process.stdout.write(chunk);
   callback();
};

function writeData(iterations, writer, data, encoding, cb) {
   (function write() {

      if(!iterations--) {
         return cb()
      }

      if (!writer.write(data, encoding)) {
         console.log(` <wait> highWaterMark of ${writable.writableHighWaterMark} reached`);
         writer.once('drain', write);
      }
   })()
}

writeData(4, writable, 'String longer than highWaterMark', 'utf8', () => console.log('finished'));

每次写入时,我们都会检查流写入操作是否返回 false,如果是,我们会在再次运行我们的write方法之前等待下一个drain事件。

你应该小心实现正确的流管理,尊重写事件发出的"警告",并在发送更多数据之前正确等待drain事件的发生。

Readable 流中的流体数据可以很容易地重定向到 Writable 流。例如,以下代码将接收终端发送的任何数据(stdin 是一个 Readable 流)并将其回显到目标 Writable 流(stdout):process.stdin.pipe(process.stdout)。当将 Writable 流传递给 Readable 流的 pipe 方法时,将触发 pipe 事件。类似地,当将 Writable 流从 Readable 流的目标中移除时,将触发 unpipe 事件。要移除 pipe,使用以下方法:unpipe(destination stream)

双工流

双工流 既可读又可写。例如,在 Node 中创建的 TCP 服务器公开了一个既可读又可写的套接字:

ini 复制代码
const stream = require("stream");
const net = require("net");

net.createServer(socket => {
  socket.write("Go ahead and type something!");
  socket.setEncoding("utf8");
  socket.on("readable", function() {
    process.stdout.write(this.read())
  });
})
.listen(8080);

执行时,此代码将创建一个可以通过 Telnet 连接的 TCP 服务器:

yaml 复制代码
telnet 127.0.0.1 8080

在一个终端窗口中启动服务器,打开一个单独的终端,并通过 telnet 连接到服务器。连接后,连接的终端将打印出 Go ahead and type something! ------写入套接字。在连接的终端中输入任何文本(按下 ENTER 后)将被回显到运行 TCP 服务器的终端的 stdout(从套接字读取),创建一种聊天应用程序。

这种双向(双工)通信协议的实现清楚地展示了独立进程如何形成复杂和响应灵敏的应用程序的节点,无论是在网络上通信还是在单个进程范围内通信。

构造 Duplex 实例时发送的选项将合并发送到 ReadableWritable 流的选项,没有额外的参数。实际上,这种流类型简单地承担了两种角色,并且与其交互的规则遵循所使用的交互模式的规则。

Duplex 流假定了读和写两种角色,任何实现都需要实现 ­_write_read 方法,再次遵循相关流类型的标准实现细节。

转换流

有时需要处理流数据,通常在写入某种二进制协议或其他 即时 数据转换的情况下。Transform 流就是为此目的而设计的,它作为一个位于 Readable 流和 Writable 流之间的 Duplex 流。

使用与初始化典型 Duplex 流相同的选项初始化 Transform 流,Transform 与普通的 Duplex 流的不同之处在于其要求自定义实现仅提供 _transform 方法,而不需要 _write_read 方法。

_transform 方法将接收三个参数,首先是发送的缓冲区,然后是一个可选的编码参数,最后是一个回调函数,_transform 期望在转换完成时调用。

ini 复制代码
_transform = function(buffer, encoding, cb) {
  let transformation = "...";
  this.push(transformation);
  cb();
};

让我们想象一个程序,它可以将 ASCII(美国信息交换标准代码) 代码转换为 ASCII 字符,从 stdin 接收输入。您输入一个 ASCII 代码,程序将以对应该代码的字母数字字符作出响应。在这里,我们可以简单地将输入传输到 Transform 流,然后将其输出传输回 stdout

ini 复制代码
const stream = require('stream');
let converter = new stream.Transform();

converter._transform = function(num, encoding, cb) {
   this.push(String.fromCharCode(new Number(num)) + "\n");
   cb();
};

process.stdin.pipe(converter).pipe(process.stdout);

与此程序交互可能会产生类似以下的输出:

css 复制代码
65 A
66 B
256 Ā
257 ā

在本章结束时,将演示一个更复杂的转换流示例。

使用 PassThrough 流

这种流是 Transform 流的一个简单实现,它只是将接收到的输入字节传递到输出流。如果不需要对输入数据进行任何转换,只是想要轻松地将 Readable 流传输到 Writable 流,这是很有用的。

PassThrough流具有类似于 JavaScript 的匿名函数的好处,使得可以轻松地断言最小的功能而不需要太多的麻烦。例如,不需要实现一个抽象基类,就像对Readable流的_read方法所做的那样。考虑以下使用PassThrough流作为事件间谍的用法:

javascript 复制代码
const fs = require('fs');
const stream = require('stream');
const spy = new stream.PassThrough();

spy
.on('error', (err) => console.error(err))
.on('data', function(chunk) {
    console.log(`spied data -> ${chunk}`);
})
.on('end', () => console.log('\nfinished'));

fs.createReadStream('./passthrough.txt').pipe(spy).pipe(process.stdout);

通常,Transform 或 Duplex 流是你想要的(在这里你可以设置_read_write的正确实现),但在某些情况下,比如测试中,可以将"观察者"放在流上是有用的。

总结

到此,Node中的流基础内容就已经全部描写清楚,后续将描述htpp相关的内容。

相关推荐
we19a0sen6 小时前
npm 常用命令及示例和解析
前端·npm·node.js
weixin_7488770013 小时前
【在Node.js项目中引入TypeScript:提高开发效率及框架选型指南】
javascript·typescript·node.js
去看日出17 小时前
Node.js多版本共存管理工具NVM(最新版本)详细使用教程(附安装包教程)
node.js·nvm·node.js多版本管理工具
小镇学者18 小时前
【js】nvm1.2.2 无法下载 Node.js 15及以下版本
开发语言·javascript·node.js
Mintopia20 小时前
深入理解与使用 Node.js 的 http-proxy-middleware
javascript·node.js·express
打野赵怀真1 天前
如何使用jQuery实现一个图片轮播效果?
node.js·php
vue10001 天前
一些TypeORM 相关的NPM包,升开发效率和代码质量
node.js
zooKevin1 天前
浅谈前端开发中的 npm、cnpm、pnpm、yarn各自特点
前端·npm·node.js
小鱼计算机1 天前
【2】安装Nodejs-Nodejs开发入门
前端·javascript·node.js
我是聂可2 天前
Node.js 下载与安装(图文)
node.js