說說Node.js中 流 的一些原理

花樣前端發表於2018-06-01

流是一組有序的,有起點和終點的位元組資料傳輸手段,它不關心檔案的整體內容,只關注是否從檔案中讀到了資料,以及讀到資料之後的處理。 流是一個抽象介面,被 Node 中的很多物件所實現。比如HTTP 伺服器requestresponse物件都是流。

今天我們來學習這塊知識,怎麼學,寫原始碼唄。這篇文章主要是去實現一個可讀流,實現一個可寫流,就不介紹流的基礎API和用法了。

如果你還不熟悉,建議移步到這裡

1、前置知識

學習流之前,我們需要掌握事件機制。原始碼中採用的是events模組,你也可以自己寫一個,只要有釋出訂閱這兩個API就可以了。 釋出訂閱這種機制可以完成資訊的互動功能,同時還能解耦模組。這種機制在好多原始碼中都有體現,比如:webpack原始碼中的事件流、vueMVVM模式也用了釋出訂閱機制。

2、可讀流

可讀流是一個實現了stream.Readable介面的物件,將物件資料讀取為流資料。 如何建立一個可讀流呢,非常簡單,看以下程式碼

//引入fs模組
let fs = require('fs'); 

//呼叫api獲得一個可讀流rs。msg.text是一個檔案
let rs = fs.createReadStream('./msg.txt');
複製程式碼

rs就是一個可讀流,它身上有一些方法和事件。比如:

rs.pause();
rs.resume();
rs.on('data', function () { })
...
複製程式碼

rs可讀流身上的方法以及事件我們一會兒會都去實現的,這裡就是簡單回憶一下。

2-1、可讀流的兩種模式

2-1-1、flowing模式

當可讀流處在 flowing 模式下, 可讀流自動從系統底層讀取資料,並通過EventEmitter 介面的事件儘快將資料提供給應用。也就是說,當我們監聽可讀流的data事件時,底層介面就開始不停的讀取資料,觸發data事件,直到資料被讀取完畢。 看以下程式碼:

let fs = require('fs');
let rs = fs.createReadStream('./msg.txt');
//只要以監聽data事件,底層介面就會去讀取資料並且不停的觸發回撥函式,將資料返回
rs.on('data', function (data) { })
複製程式碼

那麼如何切換當前可讀流到flowing 模式下呢。有三種途徑切換到 flowing 模式:

  • 監聽data事件
  • 呼叫 stream.resume()方法
  • 呼叫 stream.pipe()方法將資料傳送到 Writable

注意: 如果 Readable 切換到 flowing 模式,且沒有消費者處理流中的資料,這些資料將會丟失。 比如, 呼叫了 readable.resume() 方法卻沒有監聽 'data' 事件,或是取消了 'data' 事件監聽,就有可能出現這種情況

2-1-2、paused模式

當可讀流處在paused模式下,必須顯式呼叫rs.read()方法來從流中讀取資料片段。 看下面的程式碼:

let fs = require('fs');
let rs = fs.createReadStream('./msg.txt');
rs.on('readable', function () {
    let result = rs.read(5);
})
複製程式碼

當監聽readable事件時,底層介面會讀取資料並將快取區填滿,然後暫停讀取資料,等待快取區的資料被消費。程式碼中let result = rs.read(5);就是消費了5個資料。

如何切換可讀流到paused模式呢,可通過下面途徑切換到 paused 模式:

  • 如果不存在管道目標(pipe destination),可以通過呼叫 rs.pause()方法實現。
  • 如果存在管道目標,可以通過取消 data事件監聽,並呼叫 rs.unpipe() 方法移除所有管道目標來實現。

2-2、實現一個可讀流

原始碼當中,fs.createReadStream()ReadStream的一個例項,而ReadStream是繼承了stream.Readable介面。摘取原始碼中的部分程式碼,方便我們理解。

const { Readable, Writable } = require('stream');

function ReadStream() { }

util.inherits(ReadStream, Readable);

fs.createReadStream = function (path, options) {
    return new ReadStream(path, options);
};
複製程式碼

瞭解了這幾個類之間的關係,那我們就開始實現一個自己的ReadStream類。

2-2-1、flowing模式的實現

這種模式下,我們需要做到事情是,當可讀流監聽data事件後,就開始讀取資料,並且不停的觸發data事件,並將資料返回。 我們先把ReadStream類的骨架畫出來,如下

let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
    constructor(path, options) {
        this.path = path;
        this.flowing = false;
        ...
    }
    read() { }
    open() { }
    end() { }
    destroy() { }
    pipe() { }
    pause() { }
    resume() { }
}
複製程式碼

this上掛載的引數比較多,現單獨列舉出來:

屬性 作用
path 記錄要讀取檔案的路徑
fd 檔案描述符
flowing flowing模式的標誌
encoding 編碼
flag 檔案操作許可權
mode 檔案模式,預設為0o666
start 開始讀取位置,預設為0
pos 當前讀取位置
end 結束讀取位置
highWaterMark 最高水位線,預設64*1024
buffer 資料存放區
autoClose 自動關閉
length 資料存放區的長度

建構函式裡還應該有這幾部分:

 this.on('newListener', (type, listener) => {
            if (type === 'data') {
                this.flowing = true;
                this.read();
            }
        });
        this.on('end', () => {
            if (this.autoClose) {
                this.destroy();;
            }
        });
        this.open();
複製程式碼

著重看第一個監聽事件,它實現了,只要使用者監聽data事件,我們就開始呼叫this.read()方法,也就是去讀取資料了。

接下來,我們寫主要的read()方法,該方法主要作用是讀取資料,發射data事件。依賴一個方法,fs.read(),不熟悉的同學點這裡

 read() {
        //當檔案描述符沒有回去到時的處理
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this.read())
        }
        //處理邊界值
        let n = this.end ? Math.min(this.end - this.pos, this.highWaterMark) : this.highWaterMark;
        //開始讀取資料
        fs.read(this.fd, this.buffer, 0, n, this.pos, (err, bytesRead) => {
            if (err) return;
            if (bytesRead) {
                let data = this.buffer.slice(0, bytesRead);
                data = this.encoding ? data.toString(this.encoding) : data;
                //發射事件,將讀取到的資料返回。
                this.emit('data', data);
                this.pos += bytesRead;
                if (this.end && this.pos > this.end) {
                    return this.emit('end');
                }
                //flowing模式下,不停的讀取資料
                if (this.flowing) {
                    this.read();
                }

            } else {
                this.emit('end');
            }
        })
    }
複製程式碼

實現open方法,該方法就是獲取檔案描述符的。比較簡單

open() {
        //開啟檔案
        fs.open(this.path, this.flag, this.mode, (err, fd) => {
            //如果開啟檔案失敗,發射error事件
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                    return this.emit('error', err);
                }
            }
            //獲取到檔案描述符
            this.fd = fd;
            this.emit('open', fd);
        })
    }
複製程式碼

實現pipe方法,該方法的思路如下:

  • 監聽data事件,拿到資料
  • 將資料寫入可寫流,當快取區滿時,就暫停寫入。未滿時,恢復寫入
  • 寫完資料,觸發end事件
pipe(des) {
        //監聽data事件,拿到資料
        this.on('data', (data) => {
            //flag為true時表示快取區未滿,可以繼續寫入。
            let flag = des.write(data);
            if (!flag) {
                this.pause();
            }
        });
        //drain事件表示快取區的資料已經全部寫入,可以繼續讀取資料了
        des.on('drain', () => {
            this.resume();
        });
        this.on('end', () => {
            des.end();
        })
    }
複製程式碼

其他方法,實現比較簡單。

end() {
    if (this.autoClose) {
        this.destroy();
    }
}
destroy() {
    fs.close(this.fd, () => {
        this.emit('close');
    })
}
pause() {
    this.flowing = fasle;
}
resume() {
    this.flowing = true;
    this.read();
}
複製程式碼

至此,一個flowing模式的可讀流就實現了。

2-2-2、paused模式的實現

paused模式的可讀流和flowing模式的可讀流的區別是,當流處在paused模式時,底層介面不會一口氣把資料讀完並返回,它會先將快取區填滿,然後就不讀了,當快取區資料為空時,或者低於最高水位線了,才會再次去讀取資料

paused模式下,我們重點關注的是read()方法的實現,此時我們不再是儘快的將資料讀取出來,通過觸發data事件將資料返回給消費者。而是,當使用者監聽readable事件時,我們將快取區填滿,然後就不再讀取資料了。直到快取區的資料被消費了,並且資料小於highWaterMark時,再去讀取資料將快取區填滿,如此周而復始,直到資料全部讀取完畢。這種模式使得讀取資料這個過程變的可控制,按需讀取。

來看看read()方法如何實現。

read(n){
    let ret;
    //邊界值檢測
    if (n > 0 && n < this.length) {
        //建立buffer,read方法的返回值
        ret = Buffer.alloc(n);
        let b;
        let index = 0;
        while (null != (b = this.buffers.shift())) {
            for (let i = 0; i < b.length; i++) {
                //取出要消費的資料
                ret[index++] = b[i];
                if (index === ret.length) {
                    this.length -= n;
                    b = b.slice(i + 1);
                    //將沒有消費的資料放回快取區
                    this.buffers.unshift(b);
                    break;
                }
            }
        }
        //處理編碼問題
        if (this.encoding) {
            ret = ret.toString(this.encoding);
        }
    }
    //當快取區小於highWaterMark時,就去讀取資料,將快取區填滿
    if (this.length === 0 || (this.length < this.highWaterMark)) {
        _read(0);
    }
    return ret;
}
複製程式碼

這裡,我把主要程式碼貼出來了,大家可以看看,只是拋磚引玉。read()方法主要是操作快取區的,而_read()方法是真正去檔案中讀取資料的。來看看_read()方法。

let _read = () => {
            let m = this.end ? Math.min(this.end - this.pos + 1, this.highWaterMark) : this.highWaterMark;
            fs.read(this.fd, this.buffer, 0, m, this.pos, (err, bytesRead) => {
                if (err) return
                let data;
                if (bytesRead > 0) {
                    data = this.buffer.slice(0, bytesRead);
                    this.pos += bytesRead;
                    if (this.end && this.pos > this.end) {
                        if (this.needReadable) {
                            this.emit('readable');
                        }
                        this.emit('end');
                    } else {
                        this.buffers.push(data);
                        if (this.needReadable) {
                            this.emit('readable');
                            this.needReadable = false;
                        }
                    }
                } else {
                    if (this.needReadable) {
                        this.emit('readable');
                    }
                    return this.emit('end');
                }
            })
        }
複製程式碼

至此,paused模式的可讀流模式就完成了。

3、可寫流

實現了stream.Writable介面的物件來將流資料寫入到物件中。相比較可讀流來說,可寫流簡單一些。可寫流主要是write()_write()clearBuffer()這三個方法。

3-1、實現一個可寫流

write()方法的實現

 write(chunk, encoding, cb) {
        //引數判斷
        if (typeof encoding === 'function') {
            cb = encoding;
            encoding = null;
        }
        //處理傳入的資料
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, this.encoding || 'utf8');
        let len = chunk.length;
        this.length += len;
        let ret = this.length < this.highWaterMark;
        //當資料正在寫入時,將新任務新增到任務佇列中
        if (this.writing) {
            this.buffers.push({
                chunk,
                encoding,
                cb
            })
            //寫入資料
        } else {
            this.writing = true;
            this._write(chunk, encoding, this.clearBuffer.bind(this));
        }
        return ret;
    }
複製程式碼

_write()方法的實現

_write()方法的主要功能是呼叫底層API來將資料寫入到檔案中

 _write(chunk, encoding, cb) {
        //當檔案描述符沒有拿到時的處理
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._write(chunk, encoding, cb));
        }
        //寫入資料,執行回撥函式
        fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, written) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                }
                return this.emit('error', err);
            }
            this.length -= written;
            //更新寫入資料後,下一次該從哪個位置寫入的變數
            this.pos += written;
            //執行回撥函式
            cb && cb();
        })
    }
複製程式碼

clearBuffer()方法的實現

 clearBuffer(cb) {
        //從任務佇列中拿出一個任務
        let data = this.buffers.shift();
        //如果任務有值,那麼就將資料寫入檔案中
        if (data) {
            this._write(data.chunk, data.encoding, this.clearBuffer.bind(this));
        } else {
            this.writing = false;
            this.emit('drain');
        }
    }
複製程式碼

至此,一個可寫流就實現了。

4、雙工流

雙工流( Duplex )是同時實現了 ReadableWritable 介面的流。有了雙工流,我們可以在同一個物件上同時實現可讀和可寫,就好像同時繼承這兩個介面。 重要的是雙工流的可讀性和可寫性操作完全獨立於彼此。這僅僅是將兩個特性組合成一個物件。

const {Duplex} = require('stream');
const inoutStream = new Duplex({
    //實現一個write方法
    write(chunk, encoding, callback) {
        console.log(chunk.toString());
        callback();
    },
    //實現一個read方法
    read(size) {
        this.push((++this.index)+'');
        if (this.index > 3) {
            this.push(null);
        }
    }
});
複製程式碼

5、轉換流

對於轉換流,我們不必實現readwrite的方法,我們只需要實現一個transform方法,將兩者結合起來。它有write方法的意思,我們也可以用它來push資料。

const {Transform} = require('stream');

const upperCase = new Transform({
	//實現一個transform方法
    transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase());
        callback();
    }
});

process.stdin.pipe(upperCase).pipe(process.stdout);
複製程式碼

相關文章