node.js中的流(stream)

1.流的概念

  • 流是一组有序的,有起点和终点的字节数据传输手段
  • 它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理
  • 流是一个抽象接口,被 Node 中的很多对象所实现。比如HTTP 服务器request和response对象都是流。

2.四种基本的流类型

  1. Readable-可读流(例如 fs.createReadSteam(),http的request)
  2. Writable 可写流(例如 fs.createWriteStream(),http的response)
  3. Duplex-双工流(例如 net.Socket)
  4. Transform-转换流(压缩流) 在读写过程中可以修改和变换数据的 Duplex 流(例如 zlib.createGzip)

3 可读流 fs.createReadStream

可读流的作用就是通过buffer从文件中的指定位置开始读,每次读取多少个字节,然后存到buffer内存中,然后从buffer内存中每次取出多少个字节读出来,直到读取完为止。
复制代码
  • 可读流内部定义了一些参数来实现读一点取一点的功能

  • path: 读取文件的路径 (必须)

  • options: 参数的集合(对象) (可选)

    1. flags 文件标识位 默认r读取文件
    2. encoding 编码格式 默认使用buffer来读取文件
    3. mode 文件操作权限 默认0o666(可读可写)
    4. start 文件读取开始位置 默认0
    5. end 文件读取结束位置(包后) 默认null 读取到最后为止
    6. highWaterMark 文件一次读取多少个字节 默认64*1024
    7. autoClose 是否自动关闭 默认true
  • 默认创建一个文件是非流动模式,默认不会读取数据,我们接受数据是基于事件的,通过监听data事件来读取数据,数据从非流动模式变为流动模式。读取之前可读流内部先要使用fs.open打开文件然后触发open事件,中途操作文件出现错误我们要发射一个error事件来处理错误 最后文件读取完毕触发end方法,读取完成之后文件关闭的时候发射close方法

  • 如果我们想要暂停数据的读取,可读流内部为我们提供了一个pause方法将流动模式变为非流动模式实现暂停的功能,相反有个resume方法将非流动模式变为流动模式实现恢复的功能

  • 1.txt文件中的内容是 xx0123456789

  • 我要通过可读流实现读取1.txt文件中0-9的连续字符串

代码如下

let fs = require("fs");

// 创建可读流   默认是非流动模式
let rs = fs.createReadStream("./1.txt"),{
    flags : "r", 
    encoding : "utf8",
    mode : 0o666, 
    start : 2,     
    end : 11,       
    highWaterMark : 10, 
    autoClose : true
})
// 默认什么都不干 结果默认是不会读取的
rs.on("open",()=>{
    console.log("open");
})
rs.on("data",data=>{    // 非流动模式转换为流动模式
    console.log(data);
    // rs.pause();  暂停
    // rs.resume(); 恢复
});
rs.on("end",()=>{    // 文件读取完成执行
    console.log("end");
});
rs.on("error",err=>{    // 错误监控
    console.log(err);
});
rs.on("close",()=>{     // 文件关闭执行
    console.log("close");
});
控制台输出结果
open
0123456789      // highWaterMark的设置,决定每次读取的字节数
end
close
复制代码

可读流实现原理解析

// 可读流的实现原理
let eventEmitter = require("events");
let fs = require("fs");

// 继承eventEmitter原型上的方法 on..
class ReadStream extends eventEmitter{
    constructor(path,options){
        super();    // 继承私有方法
        this.path = path;
        this.flags = options.flags || "r";
        // 读取的编码默认null(buffer)
        this.encoding = options.encoding || null;
        this.mode = options.mode || 0o666;
        this.start = options.start || 0;
        this.end = options.end || null; // 默认null没有限制
        this.highWaterMark = options.highWaterMark || 64 * 1014;
        this.autoClose = options.autoClose || true;
        
        // 用来存放读取到的内容,为了性能好,每次用同一个内存
        this.buffer = Buffer.alloc(this.highWaterMark);
        // 定义一个控制读取的偏移量 默认和start是一样的
        this.pos = this.start; 
        // 是否是流动模式 默认false
        this.flowing = false;  

        this.open(); // 初始化打开文件
        // 监听所有的on方法,每次on都要执行这个方法
        this.on("newListener",type=>{
            if(type === "data"){    // on("data")
                this.flowing = true; // 流动模式
                this.read();    // 读取文件内容 并发射出去emit("data")
            }
        })
    }
    // 打开文件获取fd
    open(){
        fs.open(this.path,this.flags,(err,fd)=>{
            // 打开文件错误
            if(err){
                this.emit("error",err); // 触发error方法
                if(this.autoClose){
                    this.destory();
                }
                return;
            }
            this.fd = fd;               // 保存文件描述符(文件)
            this.emit("open",this.fd);  // 触发open方法
        })
    }
    // 销毁 文件关闭
    destory(){
        // 文件是打开的状态才关闭文件
        if(typeof this.fd === "number"){
            fs.close(this.fd,()=>{
                this.emit("close");
            })
        }else{
            this.emit("close");
        }
    }
    // 读取文件的内容
    read(){
        // 利用发布订阅的模式解决fd获取问题
        // 打开文件获取fd是异步的,data方法是同步的
        // 当某个值fd(文件描述符)有了后通知我继续读取
        if(typeof this.fd !== "number"){
            return this.once("open",()=>this.read());
        }
        // 剩余读取数量 = this.end + 1 - this.pos;
        // actualReadNumber实际读取数量:如果剩余读取数量大于每次从文件中读取的字节数highWaterMark,就读取highWaterMark个字节数,否则就读取剩余读取数量
        let actualReadNumber = this.end ? Math.min(this.highWaterMark, this.end + 1 - this.pos ) : this.highWaterMark;
        fs.read(this.fd, this.buffer, 0, actualReadNumber, this.pos, (err,bytesRead)=>{
            if(bytesRead > 0){
                this.pos += bytesRead;  // 更新pos偏移量
                let r = this.buffer.slice(0,bytesRead);   // 截取有效读取的字节
                r = this.encoding ? r.toString(this.encoding) : r;
                this.emit("data",r);      // 返回读取的数据
                // 如果是流动模式就递归读取内容
                if(this.flowing){
                    this.read();
                }
            }else{
                // 读取完成触发end
                this.emit("end");
                if(this.autoClose){
                    this.destory(); // 关闭文件
                }
            }
        });
    }
    // 暂停读取data
    pause(){
        this.flowing = false;
    }
    // 恢复读取data
    resume(){
        this.flowing = true;
        this.read();
    }
}

module.exports = ReadStream;
复制代码

4 可写流 fs.createWriteStream

  • 可写流的参数和可读流相似

  • 可写流和可读流参数的不同点

    1. 可读流的flags默认是r,可写流flags默认是w
    2. 可读流的encoding默认buffer,可写流默认是utf8
    3. 可读流的highWaterMark默认是64k,可写流highWaterMark默认是16k
    4. 可读流监听data方法来读取数据,可写流通过write方法向文件中写入文件
  • 可写流的主要方法write,每次使用write方法向文件中写入highWaterMark个字节数,第一次写入文件中,后面的数据存到缓存中,等上一次写完之后,清空缓存区的数据(将缓存中的内容写进去),直到数据写完位置。

  • drain事件可以控制文件读一点写一点,管道流的原理就是使用可读流的data时间和可写流的drain方法实现的。

  • 可写流的内部实现原理

    1. 因为在同一个时间操作一个文件会产生冲突,所以第一次调用write 会真的向文件里写入,之后写到缓存中。然后每次取缓存中的第一项写进去,直到写完为止
    2. 写入时,会拿当前写入的内容的长度和hignWaterMark比,如果小于hignWaterMark,会返回true 否则返回false
    3. 如果当前写入的个数大于hignWaterMark会触发drain事件
    4. 当文件中的内容写完后,会清空缓存
    5. end结束(不会触发drain,后面不能再写write,会触发close) 会把end自己的内容写在最后面

向文件中写入9-1的连续字符串,每次向文件中写入3个字节数,分三次写入,每次写完都会调用drain事件,通过drain事件来控制写入 代码如下

let fs = require("fs");
let path = require("path");

// 使用drain来控制写入
let ws = fs.createWriteStream("2.txt"),{
    highWaterMark: 3        // 每次写入的字节数
});

let i = 9;  // 向文件中写入9个字节数
function write(){
    let flag = true;    // 是否能写入的条件
    while(i>0 && flag){ 
        flag = ws.write(i--+"");    // 每次写入一个字节
    }
}
write();
// 写入的字节数大于等于highWaterMark=3就触发drain事件
ws.on("drain",()=>{    
    console.log("抽干");
    write();
});
输出结果
抽干    // 9 8 7
抽干    // 6 5 4
抽干    // 3 2 1
复制代码

可写流实现原理

1. 因为在同一个时间操作一个文件会产生冲突,所以第一次调用write 会真的向文件里写入,之后写到缓存中。然后每次取缓存中的第一项写进去,直到写完为止
2. 写入时,会拿当前写入的内容的长度和hignWaterMark比,如果小于hignWaterMark,会返回true 否则返回false
3. 如果当前写入的个数大于hignWaterMark会触发drain事件
4. 当文件中的内容写完后,会清空缓存
5. end结束(不会触发drain,后面不能再写write,会触发close)    会把end自己的内容写在最后面
    
let eventEmitter = require("events");
let fs = require("fs");
let iconv = require("iconv-lite");  // 解码,编码

class WriteStream extends eventEmitter{
    constructor(path,options){
        super();    // 继承私有方法
        this.path = path;
        this.flags = options.flags || "w";  // 默认w
        this.encoding = options.encoding || "utf8"; // 默认utf8
        this.mode = options.mode || 0o666; // 默认0o666 可读可写
        this.autoClose = options.autoClose || true; // 默认自动关闭
        this.start = options.start || 0;   // 默认从0这个位置开始写入
        this.highWaterMark = options.highWaterMark || 16 * 1024; // 默认16k

        // 写入文件的偏移量
        this.pos = this.start;  
        // 没有写入文件中内容的长度
        this.len = 0;
        // 是否往文件里面写入,默认false往文件里面写入
        this.writing = false;  
        // 当文件正在被写入的时候 要将其他写入的内容放到缓存区中
        this.cache = []; 
        // 默认情况下不会触发drain事件 只有当写入的长度等于highWaterMark时才会触发
        this.needDrain = false;     
        this.needEnd = false;   // 是否触发end事件

        this.open();    // 获取fd

    }
    destroy(){  // 关闭文件
        if(typeof this.fd === "number"){
            fs.close(this.fd,()=>{
                this.emit("close");
            })
        }else{
            this.emit("close");
        }
    }
    open(){ // 打开文件获取fd文件描述符
        fs.open(this.path,this.flags,this.mode,(err,fd)=>{
            // 打开文件有错误
            if(err){
                this.emit("error",err); // 触发error事件
                if(this.autoClose){
                    this.destroy(); 
                }
                return;
            }
            this.fd = fd;
            this.emit("open",fd);   // 触发open事件
        })
    }
    /**
     * 1.没有写入的内容和highWater来判断是否触发drain事件  
     * 2.第一次往文件里面写入,第二次放到缓存区中.
     * @param {*} chunk 当前写入的内容 只能是字符串和buffer
     * @param {*} encoding 当前写入的编码格式默认utf8
     * @param {*} callback 写入成功执行的回调函数默认给个空的函数
     * @returns 返回boolean 会拿没有写入的内容的长度和hignWaterMark比,
     * 如果小于hignWaterMark,会返回true 否则返回false
     */
    write(chunk,encoding="utf8",callback = ()=>{}){
        if(this.needEnd){   // 结束之后就不能再写了,再写就抛出异常
            throw new Error("write after end");
        }
        // 1.先判断没有写入的内容和highWater来比较
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);    // 转成buffer
        this.len += chunk.length;  // 获取还没有写入的内容的字节数
        if(this.len >= this.highWaterMark){
            this.needDrain = true;  // 开启drain事件    步骤3 *****
        }

        // 2.第一次往文件里面写入,第二次放到缓存区中
        // 将后面要写入的文件存到缓存中     步骤1 *****
        if(this.writing){
            this.cache.push({
                chunk,
                encoding,
                callback
            });
        }else{  // 往文件里面写入
            // 下次不往文件里面写入了,每次触发drain事件更新状态值
            this.writing = true;
            this._write(chunk,encoding,()=>{
                callback();
                // 第一次写完,从缓存中取下一项接着写入
                this.clearBuffer(); 
            });
        }
        // 只要没有写入的内容长度要大于最大水位线就返回false 同步的要比异步快
        return this.len < this.highWaterMark;       // 步骤2 *****
    }
    clearBuffer(){  // 清空下一项缓存区
        // 获取缓存中第一项要写入的内容,并删除第一项
        let obj = this.cache.shift();
        if(obj){
            this._write(obj.chunk,obj.encoding,()=>{
                obj.callback();
                this.clearBuffer(); // 接着清楚缓存区中的内容
            });
        }else{  // 缓存区的内容全部写完之后
             // 有end事件就不触发drain事件
            if(this.needEnd){  
                this._writeEnd(this.needEnd);    // 将end事件里面的内容写进去
            }else{
                if(this.needDrain){
                    this.writing = false;   // 触发drain后下次再次写入时 往文件里写入
                    this.emit("drain");     // 触发drain事件
                }
            }
        }
    }
    // 真正往文件里面写入的方法
    _write(chunk,encoding,callback){
        // 利用发布订阅的模式解决fd获取问题
        // 打开文件获取fd是异步的,data方法是同步的
        // 当某个值fd(文件描述符)有了后通知我继续读取
        if(typeof this.fd !== "number"){
            return this.once("open",()=>{this._write(chunk,encoding,callback)});
        }
        /**
         * fd 文件描述符 从3开始
         * chunk 要写入的buffer
         * 0 从buffer哪个位置开始写入
         * len 写到buffer的哪个位置
         * pos  从文件的哪个位置开始写入
         * bytesWrite 实际写入的个数
         */
        // 根据不同的编码格式进行解码返回buffer
        chunk = iconv.encode(chunk,encoding); 
        fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err,bytesWrite)=>{
            this.pos += bytesWrite;  // 移动写入的偏移量
            this.len -= bytesWrite;  // 减少没有写入的个数
            callback(); // 清空缓存区的内容     步骤4 *****
        });
    }
    // 文件结束,并关闭文件
    end(chunk,encoding="utf8",callback = ()=>{}){
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);    // 转成buffer
        this.needEnd = {
            chunk,
            encoding,
            callback
        };
        if(!this.writing){  // 如果没有写入文件就调用end方法了
           this._writeEnd(this.needEnd);
        }
    }
    _writeEnd(obj){ // 将end事件中的内容写入文件,并关闭文件
        this._write(obj.chunk,obj.encoding,()=>{
            obj.callback();         // 执行end的回调
            if(this.autoClose){     
                this.destroy();     // 执行close的回调
            }
        });
    }
}

module.exports = WriteStream;
复制代码

5 管道流 可读流.pipe(可写流)

管道流的原理就是将文件1通过可读流读一点然后通过可写流写一点到文件2,实现读一点写一点的功能
复制代码

下面是node中fs文件操作模块的管道流

let fs = require('fs');
let rs = fs.createReadStream('./1.txt',{
  highWaterMark:3
});
let ws = fs.createWriteStream('./2.txt',{
  highWaterMark:3
});
rs.pipe(ws); // 会控制速率(防止淹没可用内存)
复制代码
  • pipe中文管道,通过可读流和可写流的highWaterMark来控制速率,pipe会把rs的on('data'),读取到的内容通过调用ws.write方法写入进去
  • ws.write方法会返回一个boolean类型 true:写入的字节数没有饱和,false:饱和了
  • 如果返回了false就调用rs.pause()暂停读取
  • 等待可写流写入完毕后在on('drain')事件中再恢复可读流的读取

pipe的原理实现如下

// 在可读流的原型上面加一个pipe方法
// 实现读一点写一点的功能
pipe(dest){
    this.on("data",data=>{
        // 读取的数据超过可写流的最大写入字节数就暂停读取,直到写完为止
        let flag = dest.write(data);
        if(!flag){
            this.pause();   // 暂停
        }
    })
    // 数据写入成功之后恢复读取
    dest.on("drain",()=>{   
        this.resume();      // 恢复
    })
}
复制代码

6 实现双工流 net模块的socket

有了双工流,我们可以在同一个对象上同时实现可读和可写,就好像同时继承这两个接口。 重要的是双工流的可读性和可写性操作完全独立于彼此。这仅仅是将两个特性组合成一个对象

// 所有的流都基于stream这个模块
let {Duplex} = require("stream");
let fs = require("fs");
let iconv = require("iconv-lite");  // 解码,编码

// 继承双工流实体类  实现可读可写  
class MyStream extends Duplex{
  constructor(){
    super();
  }
  _read(){      // 相当于可写流源码中的_read方法
    this.push('1');     // 触发data事件并返回数据
    this.push(null);    // 代表结束读取,否则会一直死循环下去
  }
  // 第一次写是向文件中写入,后面的内容存入缓存区中
  _write(chunk,encoding,callback){  // 相当于可写流源码中的_write方法
    chunk = encoding ? iconv.encode(chunk,encoding) : chunk;    // 解码
    console.log(chunk)
    callback();         // 清楚缓冲区的内容,不执行的话write方法只会执行一次
  }
}
let myStream = new MyStream
myStream.write('ok1');  
myStream.write('ok2');  // 上一次write方法的callback函数不执行就不会调用当前这条代码
myStream.on("data",data=>{
    console.log(data);
})
输出结果
ok1
ok2
1
如果_read方法中没有写this.push(null)和_write方法中没有调用callback方法
ok1
1
1
1
1   死循环下去
复制代码

7 实现转换流 压缩流(zlib.createGzip)

  • 转换流的输出是从输入中计算出来的
  • 对于转换流,我们不必实现read或write的方法,我们只需要实现一个transform方法,将两者结合起来。它有write方法的意思,我们也可以用它来push数据。
  • 转化流 就是在可读流和可写流之间 做转换操作
let {Transform} = require("stream");
let fs = require("fs");

// 转换流 可读流和可写流互相转换
class MyStream extends Transform{
  constructor(){
    super();
  }
  // 将字母转成大写
  _transform(chunk,encoding,callback){  // 可写流
    this.push(chunk.toString().toUpperCase()); // 可读流
    callback();
  }
}
let myStream = new MyStream
// 用户输入的内容通过转换流拦截修改之后再通过管道流输出到控制台
process.stdin.pipe(upperCase).pipe(process.stdout);
控制台
输入    abc
输出    ABC
复制代码

8 readable模式

除了非流动模式和流动模式,还有一个readable模式

readable特点
    1. 默认监听readable后 会执行回调,装满highWaterMark这么多的内容
    1. 自己去读取,如果杯子是空的会继续读取highWaterMark这么多,直到没东西为止
    1. 杯子默认highWaterMark这么多内容,只要杯子倒出水,没有满就往里再添加highWaterMark这么多内容
通过readable模式实现一个行读取器,一行一行的读取数据
let fs = require("fs");
let eventEmitter = require("events");   // 事件发射器

// 自己写的行读取器 遇到换行就读取下一条数据
class LineReader extends eventEmitter{
    constructor(path){
        super();
        this.rs = fs.createReadStream(path);    // 可读流
        const RETURN = 13;  // \r
        const LINE = 10;    // \n
        let arr = [];  // 存取每行读取的内容的数组
        // 监听readable事件 利用readable模式的特点 
        // 特点:自己去读取,如果杯子是空的会继续读取highWaterMark这么多,直到没东西为止
        this.rs.on("readable",()=>{
            let char;
            // this.rs.read(1)返回的是一个buffer
            // this.rs.read(1)[0]自己会默认转换为10进制
            while(char = this.rs.read(1)){
                switch (char[0]) {
                    case RETURN:    // 遇到\r\n就触发newLine返回数据
                        this.emit("newLine",Buffer.concat(arr).toString());
                        arr = [];  
                        // 如果\r下一个不是\n的话也放到数组中
                        if(this.rs.read(1)[0] !== LINE){
                            arr.push(char);
                        }
                        break;
                    case LINE:     // mac 下没有\r 只有\n
                        break;
                    default:
                        arr.push(char);
                        break;
                }
            }
        });
        this.rs.on("end",()=>{  // 将最后一条数据发射出去
            this.emit("newLine",Buffer.concat(arr).toString());
        });
    }
}
// 1.txt 有两行数据 
// 0123456789
// 9876543210
let line = new LineReader("./1.txt");

line.on("newLine",data=>{
    console.log(data);
})
输出结果
0123456789
9876543210
复制代码

在实际开发中用的多才能加深流的理解和作用。

转载于:https://juejin.im/post/5b7d07ce51882543057d8ebd

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值