Node与Web Streams完全指南

3,272 阅读7分钟

Stream(流)算是任何一个编程语言里最基础的API之一,但又是听起来简单又晦涩难懂的API之一;本文主要介绍JS里面关于流的所有你想要了解的基本知识,学习并能熟练使用或开发自定义流是每一个想要成为JS开发老司机的必备技能。

Stream基本介绍

  • Stream相当于数据流,无需等待数据全部到达,可获取到一部分数据即可开始提前处理;
  • 主要解决大数据量时的分块处理,以chunk为单位处理,减少内存使用,提升效率;
  • 几乎所有的地方间接或直接的使用Stream, e.g: 命令行管道链 a | b | c | d
  • 可以理解为I/O操作的标准或抽象,文件I/O、网络I/O等都基于stream来做处理;

Why Stream?

一个读取大文件(400+MB)不用与用Stream的区别

  • 可以看到下图1直接内存使用达到400多MB,如果请求多的话,每一次都全部读到内存,Node进程可能会很快挂掉;
  • 而下图2在使用Stream的情况下,内存会维持在很低的值,大大减少内存占用;

Screen Shot 2022-08-27 at 11.43.13 AM.png

Why Web Stream?

🎉 Web Stream API在Node 18已可直接使用,也包括fetch等其他Web端的一些API 🎉

然而为什么Node已经有Stream的API了,还要搞一个Web Stream出来?且看下面国外大佬们的吐槽😂

Streams are Node’s best and most misunderstood idea.

— Dominic Tarr

Stream的流向过程

  • 下图可比做一个可读流
  • 上面的水龙头及再往前相当于流的数据源,来自于水厂(underlying source),通过管道流过来
  • 打开水龙头相当于流变为readable,读取到tank中(类似水池),相当于流的内部缓冲区buffer
  • 下面的水龙头(下水口)相当于消费上面的可读流
  • 上下进出水的速率不一样,缓冲区会慢慢被放满,继续放会导致水溢出甚至丢失
  • 水槽接近高水位时(highWaterMark),需要通知上游关掉或调小上面水龙头开关以避免水流失(backpressure机制)
  • 等下水道流的差不多时,水位低于highWaterMark,再通知上游打开水龙头继续放水
  • 重复上面流程直到结束Stream结束

接下来介绍Stream各个类型:

Readable Stream(可读流)

  • 可读流从一个数据源(如文件)不断读取数据,放入buffer,供消费者读取
  • Node里常用可读流如:process.stdinfs.createReadStream()、httpServer的IncomingMessage;
  • Web端:fetch的response.body

Writable Stream(可写流)

  • 可写流对应一个目的地,通过程序不断的writechunk, 进入buffer, 再把buffer里的chunk写到最终的目的地(如文件)
  • Node里常用可写流如:process.stdoutfs.createWriteStream()、httpServer的ServerResponse

Duplex Stream(双工流)

  • 既可以往里写数据,又可以从里面读取数据
  • 读写流buffer相互独立
  • Node里常用双工流如:net.Socket

http.createServer()流程:

import http from 'node:http'
const server = http.createServer((req, res) => {
    // 此处
    // req 为readable stream
    // res 为 writable stream 
    // 如下图,相当于在Socket的基础上又封装了一层
})
server.listen(3000)

Transform Stream(转换流)

  • 转换流也算双工流的特殊的一种
  • 转换流输出端(readable)由输入端(writable)经过transform后得到
  • Node里常用转换流如:zlibcrypto模块、stream.PassThrough
  • Web端:TextEncoderStreamTextDecoderStreamCompressionStreamDecompressionStream

Backpressure(数据流积压)

  • Stream默认会有highWaterMark(可自定义大小)来作为一个临界参考值
  • 当消费数据与生产数据速率不一致时(e.g: 读取本地文件速度大于把文件内容上传到服务器的速度),内部buffer大小会达到或超过该值时,此时会触发backpressure机制
  • 该机制作为一个信号来通知上游需要停止写入(进入pause mode),来给下游更多的时间消费掉buffer里的数据
  • 当buffer数据消耗完后,通知上游可以继续生产数据(重新进入flowing mode)
  • 管道相关方法(如pipe)会自动处理backpressure机制

消费Streams

我们与Stream打交道的大多数情况下是消费已经存在的Stream(系统自带或第三方库),消费流的方式可以简单分为自动消费与手动消费:

自动消费(通过管道)

pipe、pipeline(node)

pipepipeline属于Node Stream的方法:

import { pipeline } from 'node:stream'
// pipe
readableSrc
  .pipe(transformStream1)
  .pipe(transformStream2)
  .pipe(finalWrtitableDest);

// pipeline替换pipe
pipeline(
  fs.createReadStream('archive.tar'),
  zlib.createGzip(),
  fs.createWriteStream('archive.tar.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline failed.', err);
    } else {
      console.log('Pipeline succeeded.');
    }
  }
);

pipeThrough、pipeTo(web)

此处2个为Web Stream里的方法:

const res = await fetch('https://www.baidu.com')
await res.body
    .pipeThrough(transformStream)
    .pipeTo(writableStream);

消费多次: pipe(node)

如果一个流想要被多个消费者同时消费数据,在node stream里可以pipe给多个Transform流或Writable流,
下图相当于把一个文件copy了2份:

消费多次: Teeing一分为二(web)

在web stream中,一个流同时只能被一个reader消费,如果想要2个消费者同时消费,可通过const [r1, r2] = readableStream.tee()一分为二:

Async Iteration

通过管道形式我们可以方便的消费stream中的数据,在js中,也可以通过异步迭代(for await ... of语法)来快速消费数据

const res = await fetch('https://www.baidu.com')
// nodejs only, 目前只在node端可以直接这样用
for await (const chunk of res.body) {
  // Uint8Array(16384)[]
  console.log(chunk)
}

// web前端可通过async generator包装一层
async function* getAsyncIterableFor(readableStream) {
  const reader = readableStream.getReader()
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) return
      yield value
    }
  } finally {
    reader.releaseLock()
  }
}
for await (const chunk of getAsyncIterableFor(res.body)) {
  console.log(chunk)
}

手动读取

监听data等事件(node)

import { Readable } from 'node:stream'

const res = await fetch('https://www.baidu.com')
const nodeReadableStream = Readable.fromWeb(res.body)
// 自动进入flowing mode
nodeReadableStream.on('data', (chunk) => {
    console.log(chunk);
})
nodeReadableStream.on('end', () => {
    console.log('end.');
});

// paused mode情况下手动read
nodeReadableStream.on('readable', function() {
  let data;
  // 返回null,当前读取结束,等待下一次readable事件
  while ((data = this.read()) !== null) {
    console.log(data);
  }
});

调用read等方法(web)

const webReadableStream = res.body
const reader = webReadableStream.getReader();

try {
  while (true) {
    const {done, value: chunk} = await reader.read();
    // Use `chunk`
    console.log(chunk);
  }
} finally {
  reader.releaseLock();
}

手动写入

使用WritableStream,通过writer.write(chunk)写入

import { Writable } from 'node:stream'
// web WritableStream
const writer = webWritableStream.getWriter();
try {
  await writer.write('line1 \n');
  await writer.write('line2 \n');
  await writer.close();
} finally {
  writer.releaseLock()
}
// node Writable stream
const nodeWritableStream = Writable.fromWeb(webWritableStream)
function write(data, cb) {
  // 写入数据, 返回false说明buffer满了,等待清空
  if (!nodeWritableStream.write(data)) {
    // buffer已清空,可以继续写入
    nodeWritableStream.once('drain', cb);
  } else {
    process.nextTick(cb);
  }
}
write('hello', () => {
  console.log('上一波写入完毕,可继续下一波写入');
  write('more data', () => console.log('end'))
});

实现自定义Streams

上面主要讲了如何使用已存在的Stream,

下面主要介绍如何实现一些简单的Stream能让别人去使用(去消费你写的Stream)

自定义ReadableStream(web)

const readableStream = new ReadableStream({
  start(controller) {
    controller.enqueue('line1 \n');
    controller.enqueue('line2 \n');
  },
  pull(controller) {
    controller.enqueue('more data: ', Math.random())  
  },
  cancel(reason) {
    console.log('canceled:', reason)
  }
});

// consume
for await (const chunk of readableStream) {
  console.log(chunk);
}

自定义WritableStream(web)

const writableStream = new WritableStream({
    start(controller) {
      console.log('write start...')
      this.ret = ''
    },
    write(chunk, controller) {
      console.log('write chunk: ', chunk)
      this.ret += chunk
    },
    close() {
      console.log('writable stream close, ret:')
      console.log(this.ret)
    },
    abort(err) {
      console.log('writable stream about: ', err)
    }
})

// use it 
const writer = writableStream.getWriter()
writer.write('a')
await writer.ready
writer.write('b')
await writer.ready
writer.write('c')
await writer.close()
console.log('done write')
writer.releaseLock()

自定义TransformStream(web)

new TransformStream({
    start(controller) {
      console.log('transform start...')
      controller.enqueue('start chunk...\n')
    },
    async transform(chunk, controller) {
      console.log('transform chunk...\n')
      // 会等待当前transform resolve,才会执行下一个transform
      await sleep(1000)
      // 转为大写
      controller.enqueue(String(chunk).toUpperCase() + '\n')
    },
    flush(controller) {
      controller.enqueue('final chunk...')
    }
})

Pipe chain 合起来

const rs = createReadableStream()
const ts = createTransformStream()
const ws = createWritableStream()
// const ws = Writable.toWeb(process.stdout)
rs.pipeThrough(ts)
  .pipeTo(ws)

// node
Readable.fromWeb(rs)
    .pipe(Transform.fromWeb(ts))
    .pipe(Writable.fromWeb(ws))

Node与Web Streams对比

下面简单罗列了2种Stream API的区别

Node StreamWeb Stream
基本机制基于事件,继承EventEmitter基于Promise
内部buffer- 默认string或Buffer; - 使用objectMode来push JS对象;- 默认任意JS对象;- 使用Byte streams(type: 'bytes)来使用二进制流;
highWaterMark- 默认16KB;- objectMode时为写入的对象数量;- new CountQueuingStrategy({ highWaterMark: 1 });- 二进制时为0;
处理backpresurepush(chunk): booleanwrite(chunk): boolean返回值判断, false时pause;监听drain事件,触发时resumecontroller.desiredSize <= 0时暂停写入;await writer.readyfullfill时,可以继续写入数据
相互转换(node only)Readable.toWeb(),Readable.fromWeb(),WritableTransform一样

Streams Demo

命令行pipe

ll -a | node node-stream.js

// node-stream.js
function upperCaseStream() {
  let chunkCount = 0
  let totalSize = 0
  // process.stdin 即为命令行标准输入,是个可读流
  const out = process.stdin
    .pipe(
      // node stream, 
      // 把stdin的输入数据转成大写,随便记录中间的chunk数量
      new Transform({
        transform(chunk, encoding, callback) {
          console.log('chunk encoding: ', encoding)
          chunkCount++
          totalSize += chunk.length
          // 写入转换后的数据
          // 在web stream里是controler.enqueue(chunk)
          callback(null, chunk.toString().toUpperCase())
        },
        flush(callback) {
          console.log('flush chunk count: ', chunkCount, ', total size: ', totalSize)
          callback(null, 'done...')
        }
      })
    )
    .pipe(process.stdout)
  out.on('close', () => {
    console.log('chunk count: ', chunkCount)
  })
}

读写压缩文件

通过浏览器自带的一些流实现一个不依赖第三方库实现压缩能力,
选择本地文件压缩后再写回文件

<input type="file"> -> file.stream()-> comrepss -> download

let file
async function onFileChangeAndGzipFile(e) {
  file = e.target.files[0]
  console.log('file: ', file)
  // File继承自Blob, 调用stream获得一个可读流
  const fileStream = file.stream()
  // pipe给压缩转换流
  const gzippedStream = fileStream.pipeThrough(new CompressionStream('gzip'))
  // 通过可读流创建一个Response
  const res = new Response(gzippedStream)
  const resBlob = await res.blob()
  const url = URL.createObjectURL(resBlob)

  const link = document.createElement('a')
  link.download = 'browser_gzipped.txt.gz'
  link.href = url
  link.click()
}

// 上面的例子在gzippedStream之后,就没有利用流来写入文件了,还是得把压缩后的数据全部放到内存里
// 下面通过fs文件系统使用WritableStream写回文件, chrome>=86
async function gzipAndSave() {
  if(!file) return
  //
  const newHandle = await window.showSaveFilePicker();
  // 得到写文件流
  const writableStream = await newHandle.createWritable();
  await file.stream()
    .pipeThrough(new CompressionStream('gzip'))
    .pipeTo(writableStream)
  console.log('write done')
}

fetch demo

fetch gzip file -> gunzip -> decode -> text/json

async function fetchAndGunzipFile() {
  const url = 'https://abc.com/gzipped-file'

  let ret = ''
  const res = await fetch(url)
  await res.body
    // 解压缩
    .pipeThrough(new DecompressionStream('gzip'))
    // 解码
    .pipeThrough(new TextDecoderStream())
    // 得到实际文本
    .pipeTo(
      new WritableStream({
        start() {
          this.count = 0
        },
        write(chunk) {
          this.count++
          // console.log(chunk.length)
          ret += chunk
        },
        close() {
          console.log('count:', this.count)
        }
      })
    )
  return ret
}

总结

本文介绍了Stream的相关概念以及Node Stream与较新的Web Stream之间的对比,及在前端已经可以使用的实例,希望能对各位在Stream的学习之路上有所帮助🙌

参考文档

本文部分图片收集至网络