Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

在上一篇文章中讲到了 Node 中的一个卧龙 Buffer,那么这篇文章我们就来讲讲 stream(流)。

什么是 Stream

stream 翻译为中文是流的意思,它是对收入如输出设备的抽象,这里的设备可以是文件、网络、内存等。

流是有方向的,当程序从某个数据源读入数据,会开启一个输入流,这里的数据源可以上面讲到的设备,例如我们从 moment.md 文件读入数据。相反的当我们程序需要写出数据到指定源时,则开启一个输出流。当有一些大文件操作时,我们就需要 Stream 像管道一样,一点一点的就数据流出。

流是在 Node.js 中处理流数据的抽象接口,stream 模块提供了一个用于实现流接口的API。Node.js 提供了许多流对象。例如,对HTTP服务器和进程的请求,Stdout都是流实例。流可以是可读的,可写的,或者两者都是,所有流都是 EventEmitter 的实例。

为什么要流

为什么要用到流,我们来看这么一样的例子就很好懂了,我们这里有一个视频文件,大小在 1.4G 大小左右,当我们使用 readFileSync 去读取的时候具体代码如下所示:

import { readFileSync } from "fs";
import { createServer } from "http";

const server = createServer();

server.on("request", (req, res) => {
  const result = readFileSync("./moment.mp4");

  res.end(result);
});
console.log(process.pid);
server.listen(3000);

windows 要查看 pid 也就是进行 ID 很烦,且很难找,当我们启动服务时,占用的内存为 7MB,具体如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

当我们对 localhost:3000 发送请求时,内存变成了很大很大,具体请看如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

wao~,这内存一直都是 100MB 以上的,如果是做成服务器的话,同时几个人访问我的电脑岂不是要冒烟了,想想都害怕啊!!!

因此我们在这里使用 stream 的方法试试会不会有不同的结果,具体代码如下示例所示:

import { createReadStream } from "fs";
import { createServer } from "http";

const server = createServer();

server.on("request", (req, res) => {
  const result = createReadStream("./moment.mp4");

  result.pipe(res);
});
process.title = "moment";
console.log(process.pid);
server.listen(3000);

记住了,每次服务启动的时候 pid 都是不同的,当我们再次发起请求时,发现内存只是占用了 10MB - 40MB 作用,相比前面的例子已经大幅减少了

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

从上面的例子来看,一次性读取大文件,内存和网络都很难承受,说不准电脑还会爆炸!!!

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

stream 的本质

所有的流都是 EventEmitter 的实例,在源码中有如下定义,如下所示:

const EE = require('events');

function Stream(opts) {
  EE.call(this, opts);
}

ObjectSetPrototypeOf(Stream.prototype, EE.prototype);
ObjectSetPrototypeOf(Stream, EE);

流的种类

Node.Js 中,流的种类可分为四种基本流类型,它们分别是:

  • Writable: 可以向其写入数据的流,例如 fs.createWriteStream();
  • Readable: 可以从中读取数据的流,例如 fs.createReadStream();
  • Duplex: 同时为 ReadableWritable;
  • Transform: Duplex 可以在写入和读取数据时修改或转换数据的流;

Readable

Readable 为可读流,它是对提供数据的源头的抽象 它是对提供数据的源头的抽象,所有的 ReadableWritable 都是如下代码的实现:

const stream = require('stream');
const Readable = stream.Readable;
const Writable = stream.Writable;

createReadStream()

其中一个典型的可读流的使用例子有 fs.createReadStream(),该方法接收两个参数,一个是文件的路径,另外一个是可选参数,它可以是字符串或者是对象,第二个参数具体如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

createReadStream 方法的源码中可以看出该方法就是调用的 ReadStream 类,如下代码所示:

function createReadStream(path, options) {
  lazyLoadStreams();
  return new ReadStream(path, options);
}

该方法最终返回一个可读流,具体实例代码如下所示:

import { createReadStream } from "fs";

const reader = createReadStream("./moment.md", {
  start: 0,
  highWaterMark: 3,
});

reader.on("open", () => {
  console.log("文件开始读取~~~");
});

reader.on("data", (data) => {
  console.log(data.toString());
});

reader.on("close", () => {
  console.log("文件关闭~~~");
});

该实例代码如下所示,在开始读之前会打开文件,当数据读取完成时会关闭文件:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

我们还可以对流进行暂停和继续,具体示例代码如下所示:

import { createReadStream } from "fs";

const reader = createReadStream("./moment.md", {
  start: 0,
  highWaterMark: 6,
});

reader.on("open", () => {
  console.log("文件开始读取~~~");
});

reader.on("data", (data) => {
  console.log(data.toString());

  reader.pause();

  setTimeout(() => {
    console.log("一秒过去了~~~");
    reader.resume();
  }, 1000);
});

reader.on("close", () => {
  console.log("文件关闭~~~");
});

代码运行的结果如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

可读流原理

通过查看 Node.Js 源码可以知道它的原理有如下定义:

Readable.prototype.read = function (n) {
  n = parseInt(n, 10);
  var state = this._readableState;
  // 计算可读的大小
  n = howMuchToRead(n, state);
  var ret;
  // 需要读取的大于0,则取读取数据到ret返回
  if (n > 0) ret = fromList(n, state);
  else ret = null;
  // 减去刚读取的长度
  state.length -= n;
  /*
    如果缓存里没有数据或者读完后小于阈值了,
    则可读流可以继续从底层资源里获取数据  
  */
  if (state.length === 0 || state.length - n < state.highWaterMark) {
    this._read(state.highWaterMark);
  }
  // 触发data事件
  if (ret !== null) this.emit("data", ret);
  return ret;
};

用户可以通过 read 函数或者监听 data 时间来从可读流中获取数据,该方法首先主要的功能有如下几个方面:

  • 计算有该文件内容或者其他内容有多少数据可读;
  • 根据用户需要的数据大小,然后返回给用户,并触发 data 事件;
  • 如果数据还没有达到阈值,则触发可读流从底层资源中获取数据;
  • 直到数据读取完成时结束;

在前面的调用暂停事件中,实际上调用的 emitpause 事件来进行暂停的。

Wriable

Wriable 为可写流,是对数据写入目的地的一种抽象,是用来消费上游流过来的数据,通过可写流把数据写入设备。

createWriteStream()

其中一个典型的可读流的使用例子有 fs.createWriteStream(),该方法接收两个参数,一个是文件的路径,另外一个是可选参数,它可以是字符串或者是对象,第二个参数具体如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

该方法的具体使用示例有如下代码所示:

import { createReadStream, createWriteStream } from "fs";

const reader = createReadStream("./moment.md", {
  start: 0,
  highWaterMark: 3,
});

const write = createWriteStream("./test.md", {
  highWaterMark: 3,
  start: 0,
});

reader.on("data", (data) => {
  write.write(data.toString());

  reader.pause();

  setTimeout(() => {
    reader.resume();
  }, 1000);
});

在上面的这段代码中 moment.md 文件里面的内容被写到了 test.md 文件中。

wraite() 方法原理

可写流提供 write() 函数给用户实现数据的写入,写入的方式有两种方式,一个是逐个写,一个是批量写,批量是可选的,该函数的具体实现如下代码所示:

Writable.prototype.write = function (chunk, encoding, cb) {
  var state = this._writableState;
  // 告诉用户是否还可以继续调用write
  var ret = false;
  // 数据格式
  var isBuf = !state.objectMode && Stream._isUint8Array(chunk);
  // 是否需要转成buffer格式
  if (isBuf && Object.getPrototypeOf(chunk) !== Buffer.prototype) {
    chunk = Stream._uint8ArrayToBuffer(chunk);
  }
  // 参数处理,传了数据和回调,没有传编码类型
  if (typeof encoding === "function") {
    cb = encoding;
    encoding = null;
  }
  // 是buffer类型则设置成buffer,否则如果没传则取默认编码
  if (isBuf) encoding = "buffer";
  else if (!encoding) encoding = state.defaultEncoding;

  if (typeof cb !== "function") cb = nop;
  // 正在执行end,再执行write,报错
  if (state.ending) writeAfterEnd(this, cb);
  else if (isBuf || validChunk(this, state, chunk, cb)) {
    // 待执行的回调数加一,即cb
    state.pendingcb++;
    // 写入或缓存,见该函数
    ret = writeOrBuffer(this, state, isBuf, chunk, encoding, cb);
  }
  /// 还能不能继续写
  return ret;
};

该函数主要做的事情主要有以下几个方面:

  • 首先对一些参数处理和数据转换,然后再判断流是否已经结束了;
  • 如果流已经结束了再执行写的操作,会报错;
  • 如果流没有结束则执行写入或者缓存处理,最后通知用户是否还可以继续调用 write 写入数据;

在这里还有两个函数值得我们注意的,它就是 corkuncork,它类似于 TCP 中的 negal 算法,主要街垒数据后一次性写入目的地,而不是有一块就实现写入比如在 Tcp 中,每次发送一个字节,而协议头远远大于一字节,有效数据占比非常低。

我们来看看前面的例子,具体如下图所示:

Node中的 Stream 有多重要,正儿八经的写个项目你就知道了!!!

在上面的输出可以看出它并不是一个一个写的,而是积累到一定程度上再一次写入的。

end()

值得注意的是,可写流和可读流不同的是,它不会自动关闭文件,需要你去手动调用 end() 函数去结束可写流。

流结束首先会把当前缓存的数据写入目的地,并且允许再执行额外的一次写操作,然后把可写流置为不可写和结束状态,并且触发一系列事件。

该函数涉及的代码比较多,具体做了什么可自行查看,该文件定义的地方在 Node 源码中的 node\lib\internal\streams\writable.js 文件,该方法为 Writable.prototype.end = function(chunk, encoding, cb){}

Duplex

Duplex 意为双向数据流,是继承可读,可写的流,在 Node 源码中有如下的定义:

function Duplex(options) {
  if (!(this instanceof Duplex))
    return new Duplex(options);

  Readable.call(this, options);
  Writable.call(this, options);

  if (options) {
    this.allowHalfOpen = options.allowHalfOpen !== false;

    if (options.readable === false) {
      this._readableState.readable = false;
      this._readableState.ended = true;
      this._readableState.endEmitted = true;
    }

    if (options.writable === false) {
      this._writableState.writable = false;
      this._writableState.ending = true;
      this._writableState.ended = true;
      this._writableState.finished = true;
    }
  } else {
    this.allowHalfOpen = true;
  }
}

管道 pipe

pipe 的使用的例子在前面最开始的内容已经有涉及了,这里就不再写了,它是连接两段的管道。

参考资料

总结

学会了 NodeJs 中的 BufferStream 这两个模块,相信你会对 Node.js 会有更深的了解了,因为在它的内部里,很多都是基于这两个实现的,在我们目前这些前端比较火的前端打包工具中也都能看到它们的身影,学起来吧。

原文链接:https://juejin.cn/post/7223409228026757157 作者:Moment

(0)
上一篇 2023年4月19日 上午10:36
下一篇 2023年4月19日 上午10:47

相关推荐

发表评论

登录后才能评论