NODE Stream流總結(1)

Desmonddai583發表於2018-03-30

Stream簡介

流(stream)在 Node.js 中是處理流資料的抽象介面(abstract interface). Stream模組提供了基礎的API, 使用這些API可以很容易地來構建實現流介面的物件. 在Node.js中有請求流、響應流、檔案流等等, 這些流的底層都是使用stream模組封裝的, 流是可讀可寫的, 並且所有流都是EventEmitter的例項, 流的特點就是有序並且有方向的.

流(stream)提供了四種型別的流:

  • Stream.Readable(建立可讀流)
  • Stream.Writable(建立可寫流)
  • Stream.Duplex(建立可讀可寫流也就是我們經常說的雙工流, 我們所熟知的TCP sockets就是Duplex流的例項)
  • Stream.Transform(變換流,也是一種Duplex流,可寫端寫入的資料經變換後會自動新增到可讀端)

可讀流(Readable Stream)

可讀流(Readable Stream)是對提供資料的源頭(source)的抽象. 所有的 可讀流都實現了 stream.Readable 類定義的介面. 例如:

  • HTTP responses, on the client
  • HTTP requests, on the server
  • fs read streams
  • TCP sockets
  • process.stdin

可讀流分為兩種模式, 流動模式(flowing)和暫停模式(paused), 預設情況下我們在建立讀流物件之後它會處於暫停模式, 在暫停模式下, 我們必須顯式呼叫stream.read()方法來從流中讀取資料片段, 而流動模式下資料就會不斷的被讀出, 當然這裡要注意的是流動模式下資料並不是直接流向至應用, 背後其實還存在一個快取池, 而池的大小是在我們建立讀流物件時定義的, 每次讀取時最多讀取的位元組數不會超過池的大小, 打個比方, 如果有9個位元組要讀取, 池的大小為3位元組, 那麼就會分三次讀入, 在流動模式下, 可以呼叫pause方法回到暫停模式, resume方法再次回到流動模式。

使用流動模式下的讀流可以參考一下這段程式碼:

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

// 返回的是一個可讀流物件
let rs = fs.createReadStream(path.join(__dirname, '1.txt'), {
    flags: 'r', // 檔案的操作是讀取操作
    encoding: 'utf8', // 預設是null null代表的是buffer
    autoClose: true, // 讀取完畢後自動關閉
    highWaterMark: 3, // 預設是64k  64*1024b
    start: 0,
    end: 3 // 包前又包後
});

rs.setEncoding('utf8');

rs.on('open', function() {
    console.log('檔案開啟了');
});

rs.on('close', function() {
    console.log('關閉');
});

rs.on('error',function (err) {
    console.log(err);
});

rs.on('data',function(data) { // 暫停模式 -> 流動模式
    console.log(data);
    rs.pause(); // 暫停方法 表示暫停讀取,暫停data事件觸發
});

setInterval(function() {
   rs.resume(); //恢復data時間的觸發
}, 3000);

rs.on('end',function() {
    console.log('end')
});
複製程式碼

下面讓我們來看一下讀流的暫停模式,在暫停模式中,我們可以監聽一個readable事件,它將會在流有資料可供讀取時觸發,但是這不是一個自動讀取的過程,它需要我們自己去呼叫read方法來讀取資料,在監聽這個事件時,預設會將快取區先填滿一次,每當快取區讀完時,這個readable事件就會再被觸發,並且每次呼叫read方法是都會去檢測一次快取區的長度是否小於水口(HighWaterMark)大小,如果小於的話再讀取一段水口大小的資料放入快取區中。另外要注意一點的是有時候我們呼叫read時讀取的大小可能會超過快取區的大小,這個時候預設就會更改快取區的大小到一個適合現在讀取量的大小,然後再重新觸發readable事件。

使用暫停模式的讀流可以參考一下這段程式碼:

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

let rs = fs.createReadStream(path.join(__dirname,'./1.txt'), {
    flags: 'r',
    autoClose: true,
    encoding: 'utf8',
    start: 0,
    highWaterMark: 3 
});

// 預設會先讀滿
// 當快取區為空時 會預設再去觸發readable事件
// 不滿快取區就再去讀取
rs.on('readable',function() {
    // 我想讀五個 快取區只有3個 它會更改快取區的大小再去讀取
    let result =  rs.read(5);
    console.log(result);
});
複製程式碼

可讀流實現

現在就讓我們來實現一個簡單的可讀流,從上面例子呼叫的on方法就可以猜出流其實是繼承自EventEmitter模組,所以讓我們先建立一個繼承自EventEmitter模組的讀流類並且定義一個建構函式及一些屬性引數

let EventEmitter = require('events');
let fs = require('fs');

class ReadStream extends EventEmitter {
    constructor(path,options) {
        super();
        this.path = path;
        this.flags = options.flags || 'r';
        this.autoClose = options.autoClose || true;
        this.highWaterMark = options.highWaterMark|| 64*1024;
        this.start = options.start||0;
        this.end = options.end;
        this.encoding = options.encoding || null
        
        // 要建立一個buffer池 這個buffer就是要一次讀多少
        this.buffer = Buffer.alloc(this.highWaterMark);
        
        this.pos = this.start; // pos 讀取的位置 可變 start不變的
    }
}

module.exports = ReadStream;
複製程式碼

接著在我們需要定義一個open方法來開啟檔案獲取到檔案描述符(fd),並在建構函式中呼叫open方法,同時,我們需要加一個事件監聽的判斷來檢測是否監聽了data事件,如果監聽就要變成流動模式。然後我們還需要定義一個destroy事件,在檔案操作出錯或者讀完之後呼叫。

constructor(path,options) {
    ...
    this.open(); //開啟檔案 fd
    this.flowing = null; // null就是暫停模式
    this.on('newListener', (eventName, callback) => {
        if (eventName === 'data') {
            // 相當於使用者監聽了data事件
            this.flowing  = true;
            // 監聽了 就去讀
            this.read(); // 去讀內容了
        }
    })
}

destroy() {
    // 先判斷有沒有fd 有關閉檔案 觸發close事件
    if (typeof this.fd === 'number') {
        fs.close(this.fd, () => {
            this.emit('close');
        });
        return;
    }
    this.emit('close'); // 銷燬
};
    
open() {
    // copy 先開啟檔案
    fs.open(this.path, this.flags, (err, fd) => {
        if (err) {
            this.emit('error', err);
            if (this.autoClose) { // 是否自動關閉
                this.destroy();
            }
            return;
        }
        this.fd = fd; // 儲存檔案描述符
        this.emit('open'); // 檔案開啟了
    });
}
複製程式碼

接下來就要實現read方法來讀取內容了,當然這裡因為open是非同步操作,所以我們read一定要在open的回撥觸發並且獲取到fd之後才可以真正開始讀的流程,讀取時每次做多隻能讀取快取池的最大值,然後當讀取位置大於末尾或者讀到的位元組數為0時就代表檔案內容已經讀取完畢,否則,就會判斷是否為流動模式,如果是就遞迴呼叫read方法。

read() {
    // 此時檔案還沒開啟
    if(typeof this.fd !== 'number') {
        // 當檔案真正開啟的時候 會觸發open事件,觸發事件後再執行read,此時fd肯定有了
        return this.once('open', () => this.read())
    }
    let howMuchToRead = this.end ? Math.min(this.highWaterMark,this.end-this.pos+1) : this.highWaterMark;
    fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (err,bytesRead) => {
        // 讀到了多少個 累加
        if (bytesRead > 0) {
            this.pos += bytesRead;
            let data = this.encoding ? this.buffer.slice(0, bytesRead).toString(this.encoding) : this.buffer.slice(0, bytesRead);
            this.emit('data', data);
            // 當讀取的位置 大於了末尾 就是讀取完畢了
            if (this.pos > this.end) {
                this.emit('end');
                this.destroy();
            }
            if (this.flowing) { // 流動模式繼續觸發
                this.read(); 
            }
        }else{
            this.emit('end');
            this.destroy();
        }
    });
}
複製程式碼

接著,我們再來實現下pause和resume方法

resume() {
    this.flowing = true;
    this.read();
}
pause() {
    this.flowing = false;
}
複製程式碼

最後附上完整的程式碼

let EventEmitter = require('events');
let fs = require('fs');

class ReadStream extends EventEmitter {
    constructor(path, options) {
        super();
        this.path = path;
        this.flags = options.flags || 'r';
        this.autoClose = options.autoClose || true;
        this.highWaterMark = options.highWaterMark|| 64*1024;
        this.start = options.start||0;
        this.end = options.end;
        this.encoding = options.encoding || null

        this.open();//開啟檔案 fd

        this.flowing = null; // null就是暫停模式
        // 看是否監聽了data事件,如果監聽了 就要變成流動模式

        // 要建立一個buffer 這個buffer就是要一次讀多少
        this.buffer = Buffer.alloc(this.highWaterMark);

        this.pos = this.start; // pos 讀取的位置 可變 start不變的
        this.on('newListener', (eventName,callback) => {
            if (eventName === 'data') {
                // 相當於使用者監聽了data事件
                this.flowing  = true;
                // 監聽了 就去讀
                this.read(); // 去讀內容了
            }
        })
    }
    
    read(){
        // 此時檔案還沒開啟呢
        if (typeof this.fd !== 'number') {
            // 當檔案真正開啟的時候 會觸發open事件,觸發事件後再執行read,此時fd肯定有了
            return this.once('open', () => this.read())
        }
        let howMuchToRead = this.end ? Math.min(this.highWaterMark, this.end - this.pos+1) : this.highWaterMark;
        fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (err,bytesRead) => {
            // 讀到了多少個 累加
            if (bytesRead > 0) {
                this.pos += bytesRead;
                let data = this.encoding ? this.buffer.slice(0, bytesRead).toString(this.encoding) : this.buffer.slice(0, bytesRead);
                this.emit('data', data);
                // 當讀取的位置 大於了末尾 就是讀取完畢了
                if(this.pos > this.end){
                    this.emit('end');
                    this.destroy();
                }
                if(this.flowing) { // 流動模式繼續觸發
                    this.read(); 
                }
            }else{
                this.emit('end');
                this.destroy();
            }
        });
    }
    
    resume() {
        this.flowing = true;
        this.read();
    }
    
    pause() {
        this.flowing = false;
    }
    
    destroy() {
        // 先判斷有沒有fd 有關閉檔案 觸發close事件
        if(typeof this.fd === 'number') {
            fs.close(this.fd, () => {
                this.emit('close');
            });
            return;
        }
        this.emit('close'); // 銷燬
    };
    
    open() {
        // copy 先開啟檔案
        fs.open(this.path, this.flags, (err,fd) => {
            if (err) {
                this.emit('error', err);
                if (this.autoClose) { // 是否自動關閉
                    this.destroy();
                }
                return;
            }
            this.fd = fd; // 儲存檔案描述符
            this.emit('open'); // 檔案開啟了
        });
    }
}
module.exports = ReadStream;
複製程式碼

到這裡我們就大致上完成了流動模式的實現,現在就讓我們來實現一下暫停模式,同流動模式類似我們也需要先建立一個建構函式,初始化定義一些基本選項屬性,然後呼叫一個open放法開啟檔案,並且有一個destroy方法來處裡關閉邏輯。

let fs = require('fs');
let EventEmitter = require('events');

class ReadStream extends EventEmitter {
    constructor(path, options) {
        super();
        this.path = path;
        this.highWaterMark = options.highWaterMark || 64 * 1024;
        this.autoClose = options.autoClose || true;
        this.start = 0;
        this.end = options.end;
        this.flags = options.flags || 'r';
    
        this.buffers = []; // 快取區 
        this.pos = this.start;
        this.length = 0; // 快取區大小
        this.emittedReadable = false;
        this.reading = false; // 不是正在讀取的
        this.open();
        this.on('newListener', (eventName) => {
            if (eventName === 'readable') {
                this.read();
            }
        })
    }
    destroy() {
        if (typeof this.fd !== 'number') {
            return this.emit('close')
        }
        fs.close(this.fd, () => {
            this.emit('close')
        })
    }
    open() {
        fs.open(this.path, this.flags, (err, fd) => {
            if (err) {
                this.emit('error', err);
                if (this.autoClose) {
                    this.destroy();
                }
                return
            }
            this.fd = fd;
            this.emit('open');
        });
    }
}
複製程式碼

接著我們就來實現一下read方法,首先如上面所說的如果快取區的大小小於水口大小那我們就要讀取一次水口大小的資料進去快取區,並且在快取區為空時我們需要觸發readable事件,這個迴圈一直到檔案中的資料全部被讀取完之後才結束並觸發end事件

read(n) {
    // 當前快取區 小於highWaterMark時在去讀取
    if (this.length == 0) {
        this.emittedReadable = true;
    }
    if (this.length < this.highWaterMark) {
        if(!this.reading) {
            this.reading = true;
            this._read(); // 非同步的
        }
    }
}

_read() {
    // 當檔案開啟後在去讀取
    if (typeof this.fd !== 'number') {
        return this.once('open', () => this._read());
    }
    // 上來我要喝水 先倒三升水 []
    let buffer = Buffer.alloc(this.highWaterMark);
    fs.read(this.fd, buffer, 0, buffer.length, this.pos, (err, bytesRead) => {
        if (bytesRead > 0) {
            // 預設讀取的內容放到快取區中
            this.buffers.push(buffer.slice(0, bytesRead));
            this.pos += bytesRead; // 維護讀取的索引
            this.length += bytesRead;// 維護快取區的大小
            this.reading = false;
            // 是否需要觸發readable事件
            if (this.emittedReadable) {
                this.emittedReadable = false; // 下次預設不觸發
                this.emit('readable');
            }
        } else {
            this.emit('end');
            this.destroy();
        }
    })
}
複製程式碼

接下來就是要實現在read方法中讀取快取區的buffer並且返回的邏輯了,這裡比較複雜的是因為我們的快取區陣列buffers中的每個元素其實存放著的是每次讀取一個水口大小的buffer串,所以我們需要根據呼叫者傳入的讀取長度來取出對應的buffer數量,然後如果有剩餘還要放回原本陣列中等待下次讀取

read(n) { 
    // 如果n>0 去快取區中取吧
    let buffer=null;
    let index = 0; // 維護buffer的索引的
    let flag = true;
    if (n > 0 && n <= this.length) { // 讀的內容 快取區中有這麼多
        // 在快取區中取 [[2,3],[4,5,6]]
        buffer = Buffer.alloc(n); // 這是要返回的buffer
        let buf;
        while (flag && (buf = this.buffers.shift())) {
            for (let i = 0; i < buf.length; i++) {
                buffer[index++] = buf[i];
                if (index === n) { // 拷貝夠了 不需要拷貝了
                    flag = false;
                    this.length -= n;
                    let bufferArr = buf.slice(i + 1); // 取出留下的部分
                    // 如果有剩下的內容 在放入到快取中
                    if (bufferArr.length > 0) {
                        this.buffers.unshift(bufferArr);
                    }
                    break;
                }
            }
        }
    }
    // 當前快取區 小於highWaterMark時在去讀取
    if (this.length == 0) {
        this.emittedReadable = true;
    }
    if (this.length < this.highWaterMark) {
        if(!this.reading) {
            this.reading = true;
            this._read(); // 非同步的
        }
    }
    return buffer
}
複製程式碼

最後在read中我們還需要處理前面提到的呼叫read時傳入讀取長度可能大於快取區現有的資料長度,這裡我們會先去更改快取區的大小到一個適合現在讀取量的大小,然後讀入符合這個大小的buffer到快取區中,再重新觸發readable事件

function computeNewHighWaterMark(n) {
  n--;
  n |= n >>> 1;
  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;
  n++;
 return n;
}

read(n) { // 想取1個
    if (n > this.length) {
        // 更改快取區大小  讀取五個就找 2的幾次放最近的
        this.highWaterMark = computeNewHighWaterMark(n)
        this.emittedReadable = true;
        this._read();
    }

    // 如果n>0 去快取區中取吧
    let buffer = null;
    let index = 0; // 維護buffer的索引的
    let flag = true;
    if (n > 0 && n <= this.length) { // 讀的內容 快取區中有這麼多
        // 在快取區中取 [[2,3],[4,5,6]]
        buffer = Buffer.alloc(n); // 這是要返回的buffer
        let buf;
        while (flag && (buf = this.buffers.shift())) {
            for (let i = 0; i < buf.length; i++) {
                buffer[index++] = buf[i];
                if (index === n) { // 拷貝夠了 不需要拷貝了
                    flag = false;
                    this.length -= n;
                    let bufferArr = buf.slice(i+1); // 取出留下的部分
                    // 如果有剩下的內容 在放入到快取中
                    if (bufferArr.length > 0) {
                        this.buffers.unshift(bufferArr);
                    }
                    break;
                }
            }
        }
    }
    // 當前快取區 小於highWaterMark時在去讀取
    if (this.length == 0) {
        this.emittedReadable = true;
    }
    if (this.length < this.highWaterMark) {
        if (!this.reading) {
            this.reading = true;
            this._read(); // 非同步的
        }
    }
    return buffer;
}
複製程式碼

最後附上完整的程式碼

let fs = require('fs');
let EventEmitter = require('events');

function computeNewHighWaterMark(n) {
      n--;
      n |= n >>> 1;
      n |= n >>> 2;
      n |= n >>> 4;
      n |= n >>> 8;
      n |= n >>> 16;
      n++;
     return n;
}
  
class ReadStream extends EventEmitter {
    constructor(path, options) {
        super();
        this.path = path;
        this.highWaterMark = options.highWaterMark || 64 * 1024;
        this.autoClose = options.autoClose || true;
        this.start = 0;
        this.end = options.end;
        this.flags = options.flags || 'r';

        this.buffers = []; // 快取區 
        this.pos = this.start;
        this.length = 0; // 快取區大小
        this.emittedReadable = false;
        this.reading = false; // 不是正在讀取的
        this.open();
        this.on('newListener', (eventName) => {
            if (eventName === 'readable') {
                this.read();
            }
        })
    }
    
    read(n) { 
        if (n > this.length){
            // 更改快取區大小  讀取五個就找 2的幾次放最近的
            this.highWaterMark = computeNewHighWaterMark(n)
            this.emittedReadable = true;
            this._read();
        }

        // 如果n>0 去快取區中取吧
        let buffer = null;
        let index = 0; // 維護buffer的索引的
        let flag = true;
        if (n > 0 && n <= this.length) { // 讀的內容 快取區中有這麼多
            // 在快取區中取 [[2,3],[4,5,6]]
            buffer = Buffer.alloc(n); // 這是要返回的buffer
            let buf;
            while (flag && (buf = this.buffers.shift())) {
                for (let i = 0; i < buf.length; i++) {
                    buffer[index++] = buf[i];
                    if(index === n){ // 拷貝夠了 不需要拷貝了
                        flag = false;
                        this.length -= n;
                        let bufferArr = buf.slice(i+1); // 取出留下的部分
                        // 如果有剩下的內容 在放入到快取中
                        if(bufferArr.length > 0) {
                            this.buffers.unshift(bufferArr);
                        }
                        break;
                    }
                }
            }
        }
        // 當前快取區 小於highWaterMark時在去讀取
        if (this.length == 0) {
            this.emittedReadable = true;
        }
        if (this.length < this.highWaterMark) {
            if(!this.reading){
                this.reading = true;
                this._read(); // 非同步的
            }
        }
        return buffer;
    }
    
    // 封裝的讀取的方法
    _read() {
        // 當檔案開啟後在去讀取
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._read());
        }
        // 上來我要喝水 先倒三升水 []
        let buffer = Buffer.alloc(this.highWaterMark);
        fs.read(this.fd, buffer, 0, buffer.length, this.pos, (err, bytesRead) => {
            if (bytesRead > 0) {
                // 預設讀取的內容放到快取區中
                this.buffers.push(buffer.slice(0, bytesRead));
                this.pos += bytesRead; // 維護讀取的索引
                this.length += bytesRead;// 維護快取區的大小
                this.reading = false;
                // 是否需要觸發readable事件
                if (this.emittedReadable) {
                    this.emittedReadable = false; // 下次預設不觸發
                    this.emit('readable');
                }
            } else {
                this.emit('end');
                this.destroy();
            }
        })
    }
    destroy() {
        if (typeof this.fd !== 'number') {
            return this.emit('close')
        }
        fs.close(this.fd, () => {
            this.emit('close')
        })
    }
    open() {
        fs.open(this.path, this.flags, (err, fd) => {
            if (err) {
                this.emit('error', err);
                if (this.autoClose) {
                    this.destroy();
                }
                return
            }
            this.fd = fd;
            this.emit('open');
        });
    }
}

module.exports = ReadStream;
複製程式碼

LineReader

最後,結合上面所說的暫停模式readable,我們來實現一個行讀取器的例子,我們先定義好一個行讀取器類和它的測試程式碼,它實現的功能就是我們通過建立一個LineReader物件並傳入要讀取的檔案,然後監聽line事件,在每次讀取到一行資料時就會觸發line的回撥函式。

// LineReader 行讀取器
let fs = require('fs');
let EventEmitter = require('events');
let path = require('path');

class LineReader extends EventEmitter {

}

let lineReader = new LineReader(path.join(__dirname, './2.txt'));
lineReader.on('line', function (data) {
    console.log(data); // abc , 123 , 456 ,678
})
複製程式碼

接下來我們就可以來實現LineReader的建構函式了,我們首先要建立一個檔案的可讀流,然後定義在物件開始監聽line事件時我們就會監聽可讀流的readable事件,並且我們需要定義一個叫buffer的臨時陣列,用來每次讀取一行時記錄當前行的buffer資料,然後在呼叫line的回撥函式時傳入,接著在readable事件的回撥中我們就會一個字元一個字元的讀取內容,如果判斷已經到一行資料的結尾我們就會觸發一次line事件,要注意的是,對於判斷是否是新一行資料的邏輯在windows和mac下是不同的,在window下 換行回車是\r\n,在mac下只是\n,同時在判斷到\r之後我們都需要讀取多一個位元組看下是不是\n,如果不是的話那它就是一個正常的內容,我們就需要將他放入buffer陣列中等到下一次輸出,最後我們需要監聽end事件即當讀流讀取完所有資料之後將最後的buffer也就是最後一行資料傳入line回撥函式中

constructor(path) {
    super();
    this.RETURN = 0x0d;
    this.LINE = 10;
    this.buffer = [];
    this._rs = fs.createReadStream(path); // 預設情況下會先讀highWaterMark
    this.on('newListener', (eventName) => {
        if (eventName === 'line') {
            this._rs.on('readable', () => {
                let char;
                // 讀出來的內容都是buffer型別
                while (char = this._rs.read(1)) {
                    let current = char[0];
                    switch (current) {
                        // 當碰到\r時表示這一行ok了
                        case this.RETURN:
                            this.emit('line', Buffer.from(this.buffer).toString());
                            this.buffer.length = 0;
                            let c = this._rs.read(1);
                            // 讀取\r後 看一下下一個是不是\n 如果不是就表示他是一個正常的內容
                            if (c[0] !== this.LINE) {
                                this.buffer.push(c[0]);
                            }
                            break;
                        case this.LINE: // mac只有\r 沒有\n
                            this.emit('line', Buffer.from(this.buffer).toString());
                            this.buffer.length = 0;
                        default:
                            this.buffer.push(current);
                    }
                }
            });
            this._rs.on('end', () => {
                this.emit('line', Buffer.from(this.buffer).toString());
                this.buffer.length = 0
            });
        }
    })
}
複製程式碼

相關文章