node.js中的流(stream)

a3156lyk發表於2018-08-22

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
複製程式碼

在實際開發中用的多才能加深流的理解和作用。

相關文章