Node 中的 ReadStream 和 WriteStream

68 篇文章 2 订阅
2 篇文章 0 订阅

之前看别人用 Koa 实现文件下载功能,直接将 Readable Stream 传给 ctx.body

const fs = require('fs');
const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  try {
    ctx.body = fs.createReadStream(resolve(__dirname, 'test.json'));
  } catch (err) {
    ctx.body = err
  }
});

app.listen(3000);

当时觉得这样做很不可思议,因为我之前理解流的概念,就是监听 data 事件,然后拼接 chunk

const http = require('http');

http.createServer((req, res) => {
  const readable = fs.createReadStream(resolve(__dirname, 'test.json'));
  let data = '';
  readable.on('data', (chunk) => {
    data += chunk.toString();
  })
  readable.on('end', () => {
    res.end(data);
  })
}).listen(3000);

但是后来看了下 Koa 源码,发现原来是框架进行了封装:

// https://github.com/koajs/koa/blob/master/lib/application.js:256
function respond(ctx) {
  ...
  let body = ctx.body;
  if (body instanceof Stream) return body.pipe(res);
  ...
}

框架在返回的时候做了层判断,因为 res 是一个可写流对象,如果 body 也是一个 Stream 对象(此时的 Body 是一个可读流),则使用 body.pipe(res) 以流的方式进行响应

那么 pipe 方法的作用是什么,解决了什么问题,Stream 相比 fs.readFile 的优点在哪里?

fs.readFile 的弊端

如果使用 fs.readFile 读取文件,看似没问题,但是存在一个隐患,因为它是将数据一次性读入内存,当数据文件很大的时候会占用大量内存,导致内存泄漏,因此不推荐使用:

const http = require('http');
const path = require('path');
const fs = require('fs').promises;

http.createServer(async (req, res) => {
  // 将数据一次性读入内存
  // 数据文件很大会导致内存泄漏
  const file = await fs.readFile(path.resolve(__dirname, 'test.json'));
  res.end(file);
}).listen(3000);

Stream 的优点

在 Node.js 中有四种基本的流:

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

这边主要介绍 ReadableWritable

流在 Node.js 中几乎随处可见,例如下面是一个简单的 HTTP 服务:

const http = require('http');

http.createServer((req, res) => {
  let body = '';
  req.setEncoding('utf-8');
  
  req.on('data', (chunk) => {
    body += chunk;
  })
  
  req.on('end', () => {
    res.write(body);
    res.end();
  })
}).listen(3000);

在上面的代码中,req 就是一个 Readable ,通过监听 data 事件,读取请求体数据。而 res 则是 Writable ,负责写入数据并响应,write()end() 都是 Writable 提供的 API 。

为什么说用 Stream 会更好呢?原因是 Stream 处理的是 chunk 而不是全部数据,这些 chunk 存储在内部的缓冲队列中,直到被消费,如果缓冲区的大小达到了指定阈值,Stream 将暂停读取或写入数据,直到可以消耗当前缓冲的数据。

下面的代码是一个从 text.txt 复制内容到 dest-text.txt 的例子,可以看到我们并没有一次性全部读取 text.txt 到内存中,而是读取一部分就写入一部分,不断重复这个过程,直到数据复制完毕。这样可以更加高效地利用内存,避免出现内存泄漏。

const fs = require('fs');
const path = require('path');

const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));

readable.on('data', (chunk) => {
  writable.write(chunk);
})

readable.on('end', () => {
  writable.end();
})

Stream 背压及解决方案

前面介绍了 Stream 的优点,避免了一次性加载大量数据导致内存泄漏。

但是上面的代码还是存在一个问题。我们知道数据是以流的形式从 Readable 流向 Writable ,不会全部读入内存,而通常写入磁盘速度是低于读取磁盘速度的,这样上游流速过快下游来不及消费造成数据积压,即“背压”。这个时候就需要一种平衡机制,使得数据平滑流畅的从一端流向另一端。

我们知道 Writable 对象的 write(chunk) 方法可以接受一些数据写入流,当内部缓冲区小于创建 Writable 对象时配置的 highWaterMark 则返回 true ,否则返回 false 表示内部缓冲区已满或溢出,此时就是背压的一种表现。下面我们就来处理背压:

const fs = require('fs');
const path = require('path');

function _write(dest, chunk) {
  return new Promise(resolve => {
    if (dest.write(chunk)) {
      // 缓冲区未满,继续写入
      return resolve(null);
    }
    // 缓冲区空间已满,暂停向流中写入数据
    // 直到 drain 事件触发,表示缓冲区中的数据已经排空,可以继续写入
    dest.once('drain', resolve);
  })
}

function myCopy(src, dest) {
  return new Promise(async (resolve, reject) => {
    dest.on('error', reject);
    
    try {
      for await (const chunk of src) {
        // 结合异步迭代器,不用监听 data 和 end 事件
        // 如果缓冲区空间已满,等待 drain 事件触发调用 resolve
        // 当缓冲区有空间可以继续写了,再次进行读取和写入
        await _write(dest, chunk);
      }
      resolve();
    } catch (err) {
      reject(err);
    }
  })
}

const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));
myCopy(readable, writable);

如果我们使用 write() 方法写入数据,而没有正确的处理背压,会导致缓冲区数据溢出,后面来不及消费的数据不得不驻留在内存中,直到程序处理完毕才会被清除。整个数据积压的过程中,进程会不断消耗系统内存,对其他进程任务也会产生很大影响。

Node.js 的 Stream 模块提供的一些方法 pipe()pipeline() 已经为我们做了这些处理,使用这些 API 方法我们不需要自己考虑去处理“背压”的问题:

const fs = require('fs');
const path = require('path');
const stream = require('stream');
const util = require('util');
// 将 API 转换为 Promise 形式
const pipeline = util.promisify(stream.pipeline);

// readable.pipe() 方法
const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writeable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));
readable.pipe(writeable);

// stream.pipeline() 方法
(async () => {
  await pipeline(
    fs.createReadStream(path.resolve(__dirname, 'text.txt')),
    fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'))
  )
  console.log('Pipeline succeeded');
})()

上面的代码实现了从 ReadableWritable 的复制。此外如果后端需要响应一个文本内容,只需要这样写:

const http = require('http');
const fs = require('fs');
const path = require('path');

http.createServer((req, res) => {
  const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
  readable.pipe(res);
  // 使用 pipe() 方法之后就不需要调用 res.end() 了
  // res.end();
}).listen(3000);

参考

Stream - Node.js 官方文档

注释掉 on(‘data’) 请求为什么一直挂着?— 了解 Node.js Stream 的两种模式

Node.js Stream 模块 pipe 方法使用与实现原理分析

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值