nodejs api 学习4:流stream

0 阅读11分钟

在学习流之前,我们先回顾下迭代器的基本知识。

迭代器(Iterator)

迭代器就是一个能按顺序遍历数据的对象,它有固定规则:

  • 必须有一个 next() 方法
  • 每次调用 next() 返回 { value: 值, done: 是否结束 }

最简单的迭代器例子:

const iterator = {
  current: 1,
  next() {
    if (this.current <= 3) {
      return { value: this.current++, done: false }
    } else {
      return { value: undefined, done: true }
    }
  },
}

console.log(iterator.next()) // { value:1, done:false }
console.log(iterator.next()) // { value:2, done:false }
console.log(iterator.next()) // { value:3, done:false }
console.log(iterator.next()) // { value:undefined, done:true }

什么是可迭代对象呢?

一个对象只要有 Symbol.iterator 方法,且返回迭代器,就是可迭代对象,就能用 for...of

const obj = {
  [Symbol.iterator]() {
    let current = 1
    return {
      next() {
        if (current <= 3) {
          return { value: current++, done: false }
        } else {
          return { done: true }
        }
      },
    }
  },
}

for (const v of obj) {
  console.log(v) // 1,2,3
}

数组、Set、Map、字符串都是可迭代对象。

那什么是异步迭代器(Async Iterator)?

规则和迭代器几乎一样,只有两点不同:

  1. 方法名是 Symbol.asyncIterator
  2. next() 返回 Promise
  3. 遍历必须用 for await...of
const asyncIterable = {
  [Symbol.asyncIterator]() {
    let current = 1
    return {
      async next() {
        // 返回 Promise
        await new Promise((resolve) => setTimeout(resolve, 500))
        if (current <= 3) {
          return { value: current++, done: false }
        } else {
          return { done: true }
        }
      },
    }
  },
}

;(async () => {
  for await (const v of asyncIterable) {
    console.log(v)
  }
})()

生成器

迭代器(Iterator)是最终的一个结果,成品,是一个固定规则的对象:必须有 .next() 方法,每次返回:{ value: 产出值, done: 有没有结束 }

但是,原生手写迭代器很麻烦:

// 这就是【迭代器成品】
const myIterator = {
  count: 1,
  next() {
    if(this.count <=3) return {value: this.count++, done:false}
    return {done:true}
  }
}

你自己要手动写状态、写next、写判断,很累。

生成器(Generator)是一个工厂、工具、语法糖。

function* 就是专门帮你自动生成迭代器的函数:你只需要写 yield 丢值,不用手写 next、不用管状态。

// 生成器:造迭代器的工厂
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

调用生成器,直接产出一个迭代器

const it = gen(); 
// it 就是【标准迭代器】,自带 next()
console.log(it.next()); // {value:1, done:false}
console.log(it.next()); // {value:2, done:false}

Symbol

Symbol 是 JS 里一种全新的、独一无二的值类型。它的作用只有一个:用来做绝对不会重复的 “唯一标识”

const s1 = Symbol();
const s2 = Symbol();

console.log(s1 === s2); // false

最常用场景:做对象的 “私有键”,以前对象键都是字符串,容易重名冲突:

const obj = {
  name: '张三',
  name: '李四' // 覆盖了
};

用 Symbol 就绝对不会冲突:

const id1 = Symbol('id');
const id2 = Symbol('id');

const obj = {
  [id1]: 100,
  [id2]: 200
};

console.log(obj[id1]); // 100
console.log(obj[id2]); // 200

JS 内置了一些特殊 Symbol,用来表示内部行为接口

比如,上面的Symbol.iteratorSymbol.asyncIterator。它们就是JS 规定的 “特殊标记”

const obj = {
  [Symbol.iterator]() { ... }
};

意思就是告诉 JS:我这个对象实现了迭代接口,你可以用 for...of 遍历我。

stream(流)

先看一个简单的例子:

import http from 'node:http';
import fs from 'node:fs';

const server = http.createServer(async function (req, res) {
    const data = fs.readFileSync(import.meta.dirname + '/index.html', 'utf-8');
    res.end(data);
});

server.listen(8000);

我们跑了个 http 服务,用 fs.readFileSync 读取 data.txt 的内容返回。

然后用 curl 访问下:

curl -i http://localhost:8000

image.png

因为是全部读完返回的,所以可以知道 Content-Length,也就是响应体的长度。

当文件比较小的时候,这样读取、返回没啥问题。

那如果文件非常大呢?

比如有好几百 M,这时候全部读取完再返回是不是就合适了?

因为要等好久才能读取完文件,之后才有响应。

这就需要用到流了:

const readStream = fs.createReadStream(import.meta.dirname + '/data.txt', 'utf-8');
readStream.pipe(res);

image.png

结果一样,但是因为现在是流式返回的,并不知道响应体的 Content-Length。

所以是用 Transfer-Encoding: chunked 的方式返回流式内容。

这个是面试常考题。

从服务器下载一个文件的时候,如何知道文件下载完了呢?

有两种方式:

一种是 header 里带上 Content-Length,浏览器下载到这个长度就结束。

image.png

另一种是设置 transfer-encoding:chunked,它是不固定长度的,服务器不断返回内容,直到返回一个空的内容代表结束。

比如这样:

5
Hello
1
,
5
World
1
!
0

这里分了 “Hello” “,” “World”“!” 这 4 个块,长度分别为 5、1、5、1

最后以一个长度为 0 的块代表传输结束。

这样,不管内容多少都可以分块返回,就不用指定 Content-Length 了。

这就是大文件的流式传输的原理,就是 transfer-encoding:chunked。

当然,这是 http 传输时的流,在用 shell 命令的时候,也经常会用到流:

ls | grep pack

image.png

ls 命令的输出流,作为 grep 命令的输入流。

当然,我们也可以把 grep 命令的输出流,作为 node 脚本的输入流。

process.stdin.on('readable', function () {
    const buf = process.stdin.read();
    console.log(buf?.toString('utf-8'));
});

process.stdin 就是输入流,监听 readable 事件,用 read 读取数据。

ls | grep pack | node src/read.mjs

image.png

可以看到,我们的 node 脚本接收到了 grep 的输出流作为输入流。

这就是管道 pipe 的含义。

综上,可以小结下我们对流的认识:

流就是分段的传输内容,比如从服务端向浏览器返回响应数据的流,读取文件的流等。

流和流之间可以通过管道 pipe 连接,上个流的输出作为下个流的输入。

stream 流的分类

在 node 里,流一共有 4 种:可读流 Readable、可写流 Writable、双工流 Duplex、转换流 Transform:

import stream from 'node:stream';

// 可读流
const Readable = stream.Readable;
// 可写流
const Writable = stream.Writable;
// 双工流
const Duplex = stream.Duplex;
// 转换流
const Transform = stream.Transform;

其余的流都是基于这 4 种流封装出来的。

Readable

Readable 要实现 _read 方法,通过 push 返回具体的数据。

import { Readable } from 'node:stream';

const readableStream = new Readable();

readableStream._read = function() {
    this.push('阿门阿前一棵葡萄树,');
    this.push('阿东阿东绿的刚发芽,');
    this.push('阿东背着那重重的的壳呀,');
    this.push('一步一步地往上爬。')
    this.push(null);
}

readableStream.on('data', (data)=> {
    console.log(data.toString())
});

readableStream.on('end', () => {
    console.log('done');
});

当 push 一个 null 时,就代表结束流。

image.png

创建 Readable 流也可以通过继承的方式:

import { Readable } from 'node:stream';

class ReadableDong extends Readable {

    _read() {
        this.push('阿门阿前一棵葡萄树,');
        this.push('阿东阿东绿的刚发芽,');
        this.push('阿东背着那重重的的壳呀,');
        this.push('一步一步地往上爬。')
        this.push(null);
    }

}

const readableStream = new ReadableDong();

readableStream.on('data', (data)=> {
    console.log(data.toString())
});

readableStream.on('end', () => {
    console.log('done');
});

可读流是生成内容的,那么很自然可以和生成器结合:

import { Readable } from 'node:stream';

class ReadableDong extends Readable {

    constructor(iterator) {
        super();
        this.iterator = iterator;
    }

    _read() {
        const next = this.iterator.next();
        if(next.done) {
            return this.push(null);
        } else {
            this.push(next.value)
        }
    }

}

function *songGenerator() {
    yield '阿门阿前一棵葡萄树,';
    yield '阿东阿东绿的刚发芽,';
    yield '阿东背着那重重的的壳呀,';
    yield '一步一步地往上爬。';
}

const songIterator = songGenerator();

const readableStream = new ReadableDong(songIterator);

readableStream.on('data', (data)=> {
    console.log(data.toString())
});

readableStream.on('end', () => {
    console.log('done');
});
  • 和 yield 是 js 的 generator 的语法,它是返回 yield 后的内容,通过 iterator 的 next 来取下一个。

image.png

我们封装个工厂方法:

function createReadStream(interator) {
    return new ReadableDong(interator);
}

const readableStream = createReadStream(songIterator)

readableStream.on('data', (data)=> {
    console.log(data.toString())
});

readableStream.on('end', () => {
    console.log('done');
});

是不是就和 fs.createReadStream 很像了?

import fs from 'node:fs';

const readStream = fs.createReadStream(import.meta.dirname + '/data.txt', 'utf-8');

readStream.on('data', (data)=> {
    console.log(data.toString())
});

readStream.on('end', () => {
    console.log('done');
});

其实文件的 ReadStream 就是基于 stream 的 Readable 封装出来的。

这就是可读流。

http 服务的 request 就是 Readable 的实例:

image.png

所以我们可以这样写:

import http from 'node:http';
import fs from 'node:fs';

const server = http.createServer(async function (req, res) {
    const writeStream = fs.createWriteStream('aaa.txt', 'utf-8');
    req.pipe(writeStream);
    res.end('done');
});

server.listen(8000);

Node.js 可读流有两种工作模式,本质是数据要不要主动往外推

  • 流动模式(Flowing) :流主动疯狂吐数据,自动发 data 事件
  • 暂停模式(Paused) :流把数据存在缓冲区,你不喊,它不给,要自己 .read() 拿。

满足任意一个就行就是进入流动模式:

  1. 监听了 data 事件
  2. 调用了 .resume()
  3. 用了 .pipe() 导给可写流

适用简单读、不用精细控制、一次性拿数据的场景。

缺点是数据来得太快,容易背压(下游处理不过来,内存爆)

暂停模式 Paused Mode(默认模式):

  • 是可读流初始化默认状态

  • 数据先塞进内部缓冲区,憋着不发

  • 你想读:等 readable 事件触发,手动调用 .read() 捞数据

怎么进入暂停模式:

  1. 新建可读流默认就是
  2. 调用 .pause() 可从流动切回暂停

适合需要精细控制读取速度、自己处理背压、自定义读取逻辑的场景。

Writable

Readable 是实现 _read 方法,通过 this.push 返回内容

Writable 则要实现 _write 方法,接收写入的内容。

import { Writable } from 'node:stream';

class WritableDong extends Writable {

    constructor(iterator) {
        super();
    }

    _write(data, enc, next) {
        console.log(data.toString());
        // 模拟慢消费:延迟1秒才告诉流“我处理完了,可以收下一条”
        setTimeout(() => {
            next();
        }, 1000);
    }
}

function createWriteStream() {
    return new WritableDong();
}

const writeStream = createWriteStream();

writeStream.on('finish', () => console.log('done'));

writeStream.write('阿门阿前一棵葡萄树,');
writeStream.write('阿东阿东绿的刚发芽,');
writeStream.write('阿东背着那重重的的壳呀,');
writeStream.write('一步一步地往上爬。');
writeStream.end();

Writable 的特点是可以自己控制消费数据的频率,只有调用 next 方法的时候,才会处理下一部分数据。

我们每 1s 处理一次写入。

其实我们常用的 fs.createWriteStream 就是这样封装出来的。

import fs from 'node:fs';

const writeStream = fs.createWriteStream('tmp.txt', 'utf-8');

writeStream.on('finish', () => console.log('done'));

writeStream.write('阿门阿前一棵葡萄树,');
writeStream.write('阿东阿东绿的刚发芽,');
writeStream.write('阿东背着那重重的的壳呀,');
writeStream.write('一步一步地往上爬。');
writeStream.end();

http 服务的 response 就是 Writable 的实例。

Duplex

Duplex 是可读可写,同时实现 _read_write 就可以了,也就是双工流。

import { Duplex } from 'node:stream';

class DuplexStream extends Duplex {

    _read() {
        this.push('阿门阿前一棵葡萄树,');
        this.push('阿东阿东绿的刚发芽,');
        this.push('阿东背着那重重的的壳呀,');
        this.push('一步一步地往上爬。')
        this.push(null);
    }

    _write(data, enc, next) {
        console.log(data.toString());
        setTimeout(() => {
            next();
        }, 1000);
    }
}

const duplexStream = new DuplexStream();

duplexStream.on('data', data => {
    console.log(data.toString())
});
duplexStream.on('end', data => {
    console.log('read done')
});

duplexStream.write('阿门阿前一棵葡萄树,');
duplexStream.write('阿东阿东绿的刚发芽,');
duplexStream.write('阿东背着那重重的的壳呀,');
duplexStream.write('一步一步地往上爬。');
duplexStream.end();

duplexStream.on('finish', data => {
    console.log('write done')
});

注意:两个流程互不干扰。

这个 duplex 现在同时在跑两套独立逻辑

  1. 读流程(自动输出)_read → push 歌词 → on ('data') 直接打印,这是流自己 “生产数据往外吐”;

  2. 写流程(你手动输入)你 .write () → 进 _write → 延迟 1s 打印,这是流 “接收你塞进去的数据”;

✅ 两边数据不互通、互不影响,各跑各的。

image.png

创建 src/socket-server.mjs

import net from 'node:net';

const server = net.createServer(function(clientSocket){
    console.log('新的客户端 socket 连接');

    clientSocket.on('data', function(data){
        console.log(data.toString());

        clientSocket.write('hello');
    });

    clientSocket.on('end', function(){
        console.log('连接中断');
    });
});

server.listen(6666, 'localhost', function(){
    const address = server.address();

    console.log('被监听的地址为:%j', address);
});

我们创建了一个 tcp 的服务。然后再创建个 tcp 的客户端;

import net from 'node:net';

const socket = net.createConnection({ 
    host: 'localhost',
    port: 6666 
}, () => {
  console.log('连接到了服务端!');

  socket.write('world!\n');

  setTimeout(()=> {
    socket.end();
  }, 2000);
});

socket.on('data', (data) => {
  console.log(data.toString());
});

socket.on('end', () => {
  console.log('断开连接');
});

连上 tcp 服务端,发送条消息,然后 2s 后断开连接。

这种双向通信就是基于 Duplex 做的。

image.png

看下这些方法:write 是可写流的, data、end 事件是可读流的。

Socket 就是 Duplex 的实现。

Transform

Transform 也是 Duplex 双工流,只不过它会对写入的内容做一些转换之后提供给消费者来读。

Transform 流要实现 _transform 的 api。

import { Transform } from 'node:stream';

class ReverseStream extends Transform {

  _transform(buf, enc, next) {
    const res = buf.toString().split('').reverse().join('');
    this.push(res);

    next()
  }
}

var transformStream = new ReverseStream();

transformStream.on('data', data => console.log(data.toString()))
transformStream.on('end', data => console.log('read done'));

transformStream.write('阿门阿前一棵葡萄树');
transformStream.write('阿东阿东绿的刚发芽');
transformStream.write('阿东背着那重重的的壳呀');
transformStream.write('一步一步地往上爬');
transformStream.end()

transformStream.on('finish', data => console.log('write done'));

image.png

在 _transform 方法里用 push 来产生可读流数据,然后 next 是消费下一个写入的数据。

push 和 next 方法在前面的 Readable、Writable 里都用过。

这就是转换流,它也是一种双工流。

那在 node.js 的内置模块里,有哪些 api 是转换流呢?

zlib

import {
    createReadStream,
    createWriteStream,
} from 'node:fs';
import { createGzip } from 'node:zlib';
  
const gzip = createGzip();
const source = createReadStream(import.meta.dirname + '/data.txt');
const destination = createWriteStream('data.txt.gz');

source.pipe(gzip).pipe(destination);

从文件的 ReadStream,pipe 到 Gzip 转换流,然后 pipe 到文件的 WriteStream。

这感觉是不是和我们写 shell 命令时一样?

image.png

这里的多次 pipe 也可以用 stream 的 pipeline 的 api 简化:

image.png

这条 pipeline 的特点是 Readable 产生数据,然后经过任意多个 Duplex(包括 Transform),最后传入 Writable。

image.png

总结

stream 是 Node.js 的非常常用 API,也是面试必问的点。

我们每天敲的 shell 命令,就是基于流的概念,上个进程的输出可以做为下个进程的输入。

写 Node.js 代码的时候,文件读写、网络通信等都是基于流。

虽然各种流有很多,但底层的 stream 只有 4 种:

  • Readable:实现 _read 方法,通过 push 传入内容
  • Writable:实现 _write 方法,通过 next 消费内容
  • Duplex:实现 _read_write,可读可写
  • Transform:实现 _transform,对写入的内容做转换再传出去,继承自 Duplex

面试问的话,除了说出这 4 种 stream 外,最好举一个具体的 api 来说明。

比如 fs.createReadStream、http 的 request 是 Readable 的实现,fs.createWriteStream、http 的 response 是 Writable 的实现,net 的 Socket 是 Duplex 的实现,zlib.createGzip 是 Transform 的实现。

理解这 4 种 stream,能自己实现,也能知道哪些 api 是哪种流,就算掌握的差不多了。