NODE Stream流總結(2)

Desmonddai583發表於2018-03-30

上一篇中我們詳細介紹了Node中的可讀流(ReadStream),今天我們繼續介紹Node中其他的流。

可寫流(Writable Stream)

可寫流是對資料流向裝置的抽象,用來消費上游流過來的資料,通過可寫流程式可以把資料寫入裝置,常見的是本地磁碟檔案或者 TCP、HTTP 等網路響應,所有 Writable 流都實現了 stream.Writable 類定義的介面。例如:

  • HTTP requests, on the client
  • HTTP responses, on the server
  • fs write streams
  • zlib streams
  • crypto streams
  • TCP sockets
  • child process stdin
  • process.stdout, process.stderr

可寫流的原理其實與可讀流類似,當資料過來的時候會寫入快取池,當寫入的速度很慢或者寫入暫停時候,資料流便會進入到佇列池快取起來,當然即使快取池滿了,剩餘的資料也是存在記憶體中,不會丟失,當快取池中滿了並且最後快取中的資料全部寫入之後,可寫流會傳送一個drain訊息。

使用可以參考一下這段程式碼

let fs = require('fs');

let ws = fs.createWriteStream('./1.txt', {
    flags: 'w',
    mode: 0o666,
    autoClose: true,
    highWaterMark: 3, // 預設寫是16k
    encoding: 'utf8',
    start: 0
});

// 寫入的資料必須是字串或者buffer
// flag代表是否能繼續寫,但是返回false也不會丟失,就是會把內容放到記憶體中,當檔案被清空的時候才會改成true
let flag = ws.write(1 + '', 'utf8', () => {}); // 非同步的方法
console.log(flag);
flag = ws.write(1 + '', 'utf8', () => {}); // 非同步的方法
console.log(flag);

ws.end('ok'); // 當寫完後 就不能再繼續寫了
ws.write('123'); // write after end

// 抽乾方法 當都寫入完後會觸發drain事件
// 必須快取區滿了 滿了後被清空了才會出發drain
ws.on('drain', function() {
    console.log('drain')
});
複製程式碼

可寫流實現

現在就讓我們來實現一個簡單的可寫流,可寫流很多部分可以複用可讀流的邏輯,都需要有一個建構函式來定義一些基本選項屬性,然後呼叫一個open放法開啟檔案,並且有一個destroy方法來處理關閉邏輯

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

class WriteStream extends EventEmitter {
    constructor(path,options) {
        super();
        this.path = path;
        this.highWaterMark = options.highWaterMark || 16 * 1024;
        this.autoClose = options.autoClose || true;
        this.mode = options.mode;
        this.start = options.start || 0;
        this.flags = options.flags || 'w';
        this.encoding = options.encoding || 'utf8';

        // 可寫流 要有一個快取區,當正在寫入檔案是,內容要寫入到快取區中
        // 在原始碼中是一個連結串列 => []
        this.buffers = [];

        // 標識 是否正在寫入
        this.writing = false;

        // 是否滿足觸發drain事件
        this.needDrain = false;

        // 記錄寫入的位置
        this.pos = 0;

        // 記錄快取區的大小
        this.length = 0;
        this.open();
    }
    
    destroy() {
        if (typeof this.fd !== 'number') {
            return this.emit('close');
        }
        fs.close(this.fd, () => {
            this.emit('close')
        });
    }
    
    open() {
        fs.open(this.path, this.flags, this.mode, (err,fd) => {
            if (err) {
                this.emit('error', err);
                if (this.autoClose) {
                    this.destroy();
                }
                return;
            }
            this.fd = fd;
            this.emit('open');
        })
    }
}

module.exports = WriteStream;
複製程式碼

接著我們就需要實現一個write函式來讓可寫流物件呼叫,在write方法中我們首先將資料轉化為buffer,接著判斷傳入的資料是否大於快取區大小,如果大於的話則代表我們已經達到了drain事件的第一個條件,接著就要判斷現在是否正在將資料寫入檔案中,如果並沒寫入進行狀態的話那麼我們就定義一個_write方法去做寫入的動作,否則則代表檔案正在寫入,那我們就將流傳來的資料先放在快取區中,這樣做就是為了確保不會出現同時間往檔案寫資料的情況,保證了寫入資料的有序性

write(chunk, encoding=this.encoding, callback=()=>{}) {
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
    // write 返回一個boolean型別 
    this.length += chunk.length; 
    let ret = this.length < this.highWaterMark; // 比較是否達到了快取區的大小
    this.needDrain = !ret; // 是否需要觸發needDrain
    // 判斷是否正在寫入 如果是正在寫入 就寫入到快取區中
    if (this.writing) {
        this.buffers.push({
            encoding,
            chunk,
            callback
        });
    } else {
        // 專門用來將內容 寫入到檔案內
        this.writing = true;
        this._write(chunk, encoding, () => {
            callback();
            this.clearBuffer();
        });
    }
    return ret;
}
複製程式碼

接著我們就來實現_write方法,這個方法類似於讀流中的read方法,我們需要先判斷是否獲取到了檔案描述符fd,確保獲取之後再呼叫fs模組的寫入方法,而在寫入之後的回撥中我們會呼叫傳入_write方法中的一個回撥函式clearBuffer,這個方法會去buffers中繼續遞迴地把資料取出,然後呼叫_write方法去寫入,直到全部buffer中的資料取出後,首先我們需要將正在寫入狀態改成否,這樣之後再有write呼叫就會直接往檔案寫入,接著我們就需要根據前面drain事件的第一個條件觸發與否來決定是否要觸發drain事件,如果前面條件滿足,即快取區被填滿過,那麼此時當我們清空完快取區之後就需要觸發drain事件

clearBuffer() {
    let buffer = this.buffers.shift();
    if (buffer) {
        this._write(buffer.chunk, buffer.encoding, () => {
            buffer.callback();
            this.clearBuffer()
        });
    } else {
        this.writing = false;
        if (this.needDrain) { // 是否需要觸發drain 需要就發射drain事件
            this.needDrain = false;
            this.emit('drain');
        }
    }
}

_write(chunk, encoding, callback) {
    if (typeof this.fd !== 'number') {
        return this.once('open', () => this._write(chunk, encoding, callback));
    }
    fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err,byteWritten) => {
        this.length -= byteWritten;
        this.pos += byteWritten;
        
        callback(); // 清空快取區的內容
    });
}
複製程式碼

最後附上完整的程式碼

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

class WriteStream extends EventEmitter {
    constructor(path, options) {
        super();
        this.path = path;
        this.highWaterMark = options.highWaterMark || 16*1024;
        this.autoClose = options.autoClose || true;
        this.mode = options.mode;
        this.start = options.start || 0;
        this.flags = options.flags || 'w';
        this.encoding = options.encoding || 'utf8';

        // 可寫流 要有一個快取區,當正在寫入檔案是,內容要寫入到快取區中
        // 在原始碼中是一個連結串列 => []
        this.buffers = [];

        // 標識 是否正在寫入
        this.writing = false;

        // 是否滿足觸發drain事件
        this.needDrain = false;

        // 記錄寫入的位置
        this.pos = 0;

        // 記錄快取區的大小
        this.length = 0;
        this.open();
    }
    
    destroy() {
        if (typeof this.fd !== 'number') {
            return this.emit('close');
        }
        fs.close(this.fd, () => {
            this.emit('close');
        })
    }
    
    open() {
        fs.open(this.path, this.flags, this.mode, (err,fd) => {
            if (err) {
                this.emit('error', err);
                if (this.autoClose) {
                    this.destroy();
                }
                return;
            }
            this.fd = fd;
            this.emit('open');
        })
    }
    
    write(chunk, encoding=this.encoding, callback=()=>{}) {
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
        // write 返回一個boolean型別 
        this.length += chunk.length; 
        let ret = this.length < this.highWaterMark; // 比較是否達到了快取區的大小
        this.needDrain = !ret; // 是否需要觸發needDrain
        // 判斷是否正在寫入 如果是正在寫入 就寫入到快取區中
        if (this.writing) {
            this.buffers.push({
                encoding,
                chunk,
                callback
            });
        } else {
            // 專門用來將內容 寫入到檔案內
            this.writing = true;
            this._write(chunk, encoding, () => {
                callback();
                this.clearBuffer();
            });
        }
        return ret;
    }
    
    clearBuffer() {
        let buffer = this.buffers.shift();
        if (buffer) {
            this._write(buffer.chunk, buffer.encoding, () => {
                buffer.callback();
                this.clearBuffer()
            });
        } else {
            this.writing = false;
            if(this.needDrain){ // 是否需要觸發drain 需要就發射drain事件
                this.needDrain = false;
                this.emit('drain');
            }
        }
    }
    
    _write(chunk, encoding, callback){
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._write(chunk, encoding, callback));
        }
        fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err,byteWritten) => {
            this.length -= byteWritten;
            this.pos += byteWritten;
            
            callback(); // 清空快取區的內容
        });
    }
}

module.exports = WriteStream;
複製程式碼

Pipe

在瞭解了可讀流與可寫流之後,那我們在實際使用流的流程又是怎麼樣的呢,其實就是在讀流不斷讀取檔案內容的同時,我們的寫流會將讀流讀到的資料寫入到另一個檔案中,這樣做的好處就是避免了直接用fs的讀寫檔案時會佔用大量記憶體來存放中間轉化的資料。另外在Node的socket和http中也會用到這種概念,所以在流中就會有一個pipe方法幫助我們來實現這種讀一點寫一點的情況。

使用可以參考一下這段程式碼

let fs = require('fs');
let path = require('path');
let ReadStream = require('./ReadStream');
let WriteStream = require('./WriteStream');

let rs = new ReadStream(path.join(__dirname, './1.txt'), {
    highWaterMark: 4
});
let ws = new WriteStream(path.join(__dirname, './2.txt'), {
    highWaterMark: 1
});
// 4 1
rs.pipe(ws); 
複製程式碼

接下來就讓我們在我們自己實現的讀流模組中實現一下pipe這個方法,背後就是我們會監聽讀流的data事件來持續獲取檔案中的資料,然後我們就會去呼叫寫流的write方法,如前面所說,每次呼叫write方法都會返回一個標記告訴我們寫流快取區是否已滿,那麼當我們得到快取區已滿的訊號後就會呼叫讀流的pause方法來暫停讀取,然後等到寫流的快取區已經全部寫入並且觸發drain事件時,我們就會呼叫resume重新開啟讀取的流程

pipe(ws) {
    this.on('data', (chunk) => {
        let flag = ws.write(chunk);
        if (!flag) {
            this.pause();
        }
    });
    ws.on('drain', () => {
        this.resume();
    })
}
複製程式碼

自定義流

Node自帶的讀流和寫流模組其實都是繼承於stream模組中的介面,讀流繼承於Readable介面,寫流則繼承於Writable介面,所以我們其實是可以自定義一個流模組,只要繼承stream模組對應的介面即可。

  1. 自定義讀流

    如果我們要自定義讀流的話,那我們就需要繼承Readable,Readable裡面有一個read()方法,預設呼叫_read(),所以我們只要複寫了_read()方法就可實現讀取的邏輯,同時Readable中也提供了一個push方法,呼叫push方法就會觸發data事件,push中的引數就是data事件回撥函式的引數,當push傳入的引數為null的時候就代表讀流停止

    自定義的方法可以參考如下程式碼

    let { Readable } = require('stream');
    
    // 想實現什麼流 就繼承這個流
    // Readable裡面有一個read()方法,預設掉_read()
    // Readable中提供了一個push方法你呼叫push方法就會觸發data事件
    let index = 9;
    class MyRead extends Readable {
        _read() {
            // 可讀流什麼時候停止呢? 當push null的時候停止
            if (index-- > 0) return this.push('123');
            this.push(null);
        }
    }
    
    let mr = new MyRead();
    mr.on('data', function(data) {
        console.log(data);
    });
    複製程式碼
  2. 自定義寫流

    與自定義讀流類似,自定義寫流需要繼承Writable介面,並且實現一個_write()方法,這裡注意的是_write中可以傳入3個引數,chunk, encoding, callback,chunk就是代表寫入的資料,通常是一個buffer,encoding是編碼型別,通常不會用到,最後的callback要注意,它並不是我們用這個自定義寫流呼叫write時的回撥,而是我們上面講到寫流實現時的clearBuffer函式。

    自定義的方法可以參考如下程式碼

    let { Writable } = require('stream');
    
    // 可寫流實現_write方法
    // 原始碼中預設呼叫的是Writable中的write方法
    class MyWrite extends Writable {
        _write(chunk, encoding, callback) {
            console.log(chunk.toString());
            callback(); // clearBuffer
        }
    }
    
    let mw = new MyWrite();
    mw.write('珠峰', 'utf8', () => {
        console.log(1);
    })
    mw.write('珠峰', 'utf8', () => {
        console.log(1);
    });
    複製程式碼

Duplex 雙工流

雙工流其實就是結合了上面我們說的自定義讀流和自定義寫流,它既能讀也能寫,同時可以做到讀寫之間互不干擾

使用方法可以參考如下程式碼

let { Duplex } =  require('stream');

// 雙工流 又能讀 又能寫,而且讀取可以沒關係(互不干擾)
let d = Duplex({
    read() {
        this.push('hello');
        this.push(null);
    },
    write(chunk, encoding, callback) {
        console.log(chunk);
        callback();
    }
});

d.on('data', function(data) {
    console.log(data);
});
d.write('hello');
複製程式碼

Transform 轉換流

轉換流的本質就是雙工流,唯一不同的是它並不需要像上面提到的雙工流一樣實現read和write,它只需要實現一個transform方法用於寫資料給可讀流,但是對於讀取來說可讀流的資料會經過處理後自動放入可寫流中,這點上不需要我們去實現

使用方法可以參考如下程式碼

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

// 它的引數和可寫流一樣
let tranform1 = Transform({
    transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase()); // 將輸入的內容放入到可讀流中
        callback();
    }
});
let tranform2 = Transform({
    transform(chunk, encoding, callback){
        console.log(chunk.toString());
        callback();
    }
});

// 等待你的輸入
// rs.pipe(ws);
// 希望將輸入的內容轉化成大寫在輸出出來
process.stdin.pipe(tranform1).pipe(tranform2);
// 物件流 可讀流裡只能放buffer或者字串 物件流裡可以放物件
複製程式碼

物件流

預設情況下,流處理的資料是Buffer/String型別的值。物件流的特點就是它有一個objectMode標誌,我們可以設定它讓流可以接受任何JavaScript物件。

使用方法可以參考如下程式碼

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

let fs = require('fs');
let rs = fs.createReadStream('./users.json');

rs.setEncoding('utf8');

let toJson = Transform({
    readableObjectMode: true,
    transform(chunk, encoding, callback) {
        this.push(JSON.parse(chunk));
        callback();
    }
});

let jsonOut = Transform({
    writableObjectMode: true,
    transform(chunk, encoding, callback) {
        console.log(chunk);
        callback();
    }
});
rs.pipe(toJson).pipe(jsonOut);
複製程式碼

相關文章