Node Stream 入門與深入

Macchiato發表於2018-02-03

概念

Stream 是Node.js中最重要的元件和模式之一,之前在社群中看到這樣一句格言“Stream all the things(流是一切)”。

具體的來說流是一組有序的,有起點和終點的位元組資料傳輸手段,它是一個抽象的介面。

流是資料的集合 —— 就像陣列或字串一樣。區別在於流中的資料可能不會立刻就全部可用,並且你無需一次性地把這些資料全部放入記憶體。這使得流在操作大量資料或是資料從外部來源逐段傳送過來的時候變得非常有用。

每一個流物件都是EventEmitter類的一個例項,都有相應的on和emit方法,在下文中演示的程式碼示例中我們會看到。

stream 是node的核心模組,引入方式如下:

let Stream = require('stream')
複製程式碼

實現形式

流在node中有多種實現形式,例如:

1. http協議中的 請求req和響應res
2. tcp協議中的套接字物件sockets
3. fs檔案模組中的可讀流和可寫流
4. process程式模組中的stdout stderr
5. zlib 中的streams
   .....
複製程式碼

型別

提供了以下四種型別:

可讀流(Readable)
let Readable = stream.Readable
複製程式碼
可寫流(Writeable)
let Readable = stream.Writeable
複製程式碼
可讀寫流(Duplex)
let Duplex = stream.Duplex
複製程式碼
轉換流(Transform)
let Transform = stream.Transform
複製程式碼

原理

Readable

實現了stream.Readable介面的物件,將物件資料讀取為流資料,當監聽data事件後,開始發射資料.

1. 建立
let rs = fs.createReadStream(path,{
    flags: 'r', // 開啟檔案要做的操作,預設為'r'
    encoding: null, // 預設為null
    start: '0', // 開始讀取的索引位置
    end: '', // 結束讀取的索引位置(包括結束位置)
    highWaterMark: '', // 讀取快取區預設的大小的閥值64kb
})
複製程式碼
2. 方法及作用
// 1.監聽data事件 流自動切換到流動模式
// 2.資料會盡可能快的讀出
rs.on('data', function (data) {
    console.log(data);
});
// 資料讀取完畢後觸發end事件
rs.on('end', function () {
    console.log('讀取完成');
});
// 可讀流開啟事件
rs.on('open', function () {
    console.log(err);
});
// 可讀流關閉事件
rs.on('close', function () {
    console.log(err);
});
// 指定編碼 和上面建立流時的引數encoding意思相同
rs.setEncoding('utf8');


rs.on('data', function (data) {
// 可讀流暫停讀取
    rs.pause();
    console.log(data);
});
setTimeout(function () {
// 可讀流恢復讀取
    rs.resume();
},2000);
複製程式碼
3. 分類

可讀流分為:流動模式和暫停模式

可讀流物件readable中有一個維護狀態的物件,readable._readableState,這裡簡稱為state。 其中有一個標記,state.flowing, 可用來判別流的模式。 它有三種可能值:

true 流動模式。 false 暫停模式。 null 初始狀態。

1) 流動模式(flowing mode)

流動模式下,資料會源源不斷地生產出來,形成“流動”現象。 監聽流的data事件便可進入該模式。

2) 暫停模式(paused mode)

暫停模式下,需要顯示地呼叫read(),觸發data事件。

在初始狀態下,監聽data事件,會使流進入流動模式。 但如果在暫停模式下,監聽data事件並不會使它進入流動模式。 為了消耗流,需要顯示呼叫read()方法。

3)相互轉化

呼叫readable.resume()可使流進入流動模式,state.flowing被設為true。 呼叫readable.pause()可使流進入暫停模式,state.flowing被設為false。

4. 原理

建立可讀流時,需要繼承Readable,並實現_read方法。

Node Stream 入門與深入

  1. _read方法是從底層系統讀取具體資料的邏輯,即生產資料的邏輯。

    在_read方法中,通過呼叫push(data)將資料放入可讀流中供下游消耗。 在_read方法中,可以同步呼叫push(data),也可以非同步呼叫。 當全部資料都生產出來後,必須呼叫push(null)來結束可讀流。 流一旦結束,便不能再呼叫push(data)新增資料。 可以通過監聽data事件的方式消耗可讀流。

    在首次監聽其data事件後,readable便會持續不斷地呼叫_read(),通過觸發data事件將資料輸出。 第一次data事件會在下一個tick中觸發,所以,可以安全地將資料輸出前的邏輯放在事件監聽後(同一個tick中)。 當資料全部被消耗時,會觸發end事件。

  2. 詳解

    Node Stream 入門與深入

doRead

流中維護了一個快取,當快取中的資料足夠多時,呼叫read()不會引起_read()的呼叫,即不需要向底層請求資料。用doRead來表示read(n)是否需要向底層取資料.

var doRead = state.needReadable

if (state.length === 0 || state.length - n < state.highWaterMark) {
  doRead = true
}

if (state.ended || state.reading) {
  doRead = false
}

if (doRead) {
  state.reading = true
  state.sync = true
  if (state.length === 0) {
    state.needReadable = true
  }
  this._read(state.highWaterMark)
  state.sync = false
}

複製程式碼

當快取區的長度為0或者快取區的數量小於state.highWaterMark這個閥值,則會呼叫 _read()去底層讀取資料。 state.reading 標誌上次從底層取資料的操作是否完成,一旦push 被呼叫,就會就會設定為false,表示此次_read()結束。

push

消耗方呼叫read(n)促使流輸出資料,而流通過_read()使底層呼叫push方法將資料傳給流。 如果呼叫push方法時快取為空,則當前資料即為下一個需要的資料。 這個資料可能先新增到快取中,也可能直接輸出。 執行read方法時,在呼叫_read後,如果從快取中取到了資料,就以data事件輸出。

所以,如果_read非同步呼叫push時發現快取為空,則意味著當前資料是下一個需要的資料,且不會被read方法輸出,應當在push方法中立即以data事件輸出。

上圖立即輸出條件

state.flowing && state.length === 0 && !state.sync
複製程式碼
end

由於流是分次向底層請求資料的,需要底層顯示地告訴流資料是否取完。 所以,當某次(執行_read())取資料時,呼叫了push(null),就意味著底層資料取完。 此時,流會設定state.ended。

state.length表示快取中當前的資料量。 只有當state.length為0,且state.ended為true,才意味著所有的資料都被消耗了。 一旦在執行read(n)時檢測到這個條件,便會觸發end事件。 當然,這個事件只會觸發一次。

readable

在呼叫完_read()後,read(n)會試著從快取中取資料。 如果_read()是非同步呼叫push方法的,則此時快取中的資料量不會增多,容易出現資料量不夠的現象。

如果read(n)的返回值為null,說明這次未能從快取中取出所需量的資料。 此時,消耗方需要等待新的資料到達後再次嘗試呼叫read方法。

在資料到達後,流是通過readable事件來通知消耗方的。 在此種情況下,push方法如果立即輸出資料,接收方直接監聽data事件即可,否則資料被新增到快取中,需要觸發readable事件。 消耗方必須監聽這個事件,再呼叫read方法取得資料。

5. 手寫

流動模式

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

class ReadStream extends EventEmitter {
    constructor(path, options) {
        super(path, options);
        
        // 初始化引數
        this.path = path; // 路徑
        this.flags = options.flags || 'r'; // 開啟檔案要做的操作,預設為'r'
        this.mode = options.mode || 0o666; 
        this.pos = this.start = options.start || 0; // 偏移量 預設是開始位置
        this.end = options.end; // 結束為止
        this.encoding = options.encoding; // 編碼形式
        this.highWaterMark = options.highWaterMark || 64 * 1024; // 可讀流快取區的閥值 預設64k
        
        
        this.flowing = null;// 標誌形式 null初始狀態 false 暫停模式 true 流動模式
        this.buffer = Buffer.alloc(this.highWaterMark); // 快取區
        this.open();// //準備開啟檔案讀取
        //當給這個例項新增了任意的監聽函式時會觸發newListener
        this.on('newListener', (type, listener) => {
            if (type == 'data') { // 當可讀流監聽data事件時初始態變成流動模式
                this.flowing = true;
                this.read();
            }
        });
    }
    
    read() {
        // 如果檔案標示符不是數字的話 說明檔案還沒有開啟,這時候我們要監聽 檔案的開啟事件 一旦開啟就開始讀取
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this.read());
        }
        // 每次讀取多少個
        let howMuchToRead = this.end ? Math.min(this.end - this.pos + 1, this.highWaterMark) : this.highWaterMark;
        
        fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (err, bytes) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                }
                return this.emit('error', err);
            }
            if (bytes) {
                let data = this.buffer.slice(0, bytes);
                data = this.encoding ? data.toString(this.encoding) : data;
                this.emit('data', data);
                
                this.pos += bytes;
                
                if (this.end && this.pos > this.end) {
                    return this.endFn();
                } else {
                    if (this.flowing) {
                        this.read();
                    }
                }
            } else {
                return this.endFn();
            }
            
        })
    }
    
    endFn() {
        this.emit('end');
        this.destroy();
    }
    
    open() {
        fs.open(this.path, this.flags, this.mode, (err, fd) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                    return this.emit('error', err);
                }
            }
            this.fd = fd;
            this.emit('open');
        })
    }
    
    destroy() {
        fs.close(this.fd, () => {
            this.emit('close');
        });
    }
    
    pipe(dest) {
        this.on('data', data => {
            let flag = dest.write(data);
            if (!flag) {
                this.pause();
            }
        });
        dest.on('drain', () => {
            this.resume();
        });
    }
    
    pause() {
        this.flowing = false;
    }
    
    resume() {
        this.flowing = true;
        this.read();
    }
}

module.exports = ReadStream;
複製程式碼
測試程式碼
let ReadStream = require('./ReadStream');
let rs = new ReadStream('1.txt',{
   highWaterMark:3,
    encoding:'utf8'
});
//在真實的情況下,當可讀流建立後會立刻進行暫停模式。其實會立刻填充快取區
//快取區大小是可以看到
rs.on('readable',function () {
    console.log(rs.length);//3
    //當你消費掉一個位元組之後,快取區變成2個位元組了
    let char = rs.read(1);
    console.log(char);
    console.log(rs.length);
    //一旦發現緩衝區的位元組數小於最高水位線了,則會現再讀到最高水位線個位元組填充到快取區裡
    setTimeout(()=>{
        console.log(rs.length);//5
    },500)
});
複製程式碼

暫停模式

let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
    constructor(path, options) {
        super(path, options);
     
        this.path = path;
        this.highWaterMark = options.highWaterMark || 64 * 1024;
        this.buffer = Buffer.alloc(this.highWaterMark);
        this.flags = options.flags || 'r';
        this.encoding = options.encoding;
        this.mode = options.mode || 0o666;
        this.start = options.start || 0;
        this.end = options.end;
        this.pos = this.start;
        this.autoClose = options.autoClose || true;
        this.bytesRead = 0;
        this.closed = false;
        this.flowing;
        this.needReadable = false;
        this.length = 0;
        this.buffers = [];
        this.on('end', function () {
            if (this.autoClose) {
                this.destroy();
            }
        });
        this.on('newListener', (type) => {
            if (type == 'data') {
                this.flowing = true;
                this.read();
            }
            if (type == 'readable') {
                this.read(0);
            }
        });
        this.open();
    }
    
    open() {
        fs.open(this.path, this.flags, this.mode, (err, fd) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                    return this.emit('error', err);
                }
            }
            this.fd = fd;
            this.emit('open');
        });
    }
    
    read(n) {
        if (typeof this.fd != 'number') {
            return this.once('open', () => this.read());
        }
        n = parseInt(n, 10);
        if (n != n) {
            n = this.length;
        }
        if (this.length == 0)
            this.needReadable = true;
        let ret;
        if (0 < n < this.length) {
            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;
                    }
                }
            }
            ret = ret.toString(this.encoding);
        }
        
        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;
                    this.length += 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');
                }
            })
        }
        if (this.length == 0 || (this.length < this.highWaterMark)) {
            _read();
        }
        return ret;
    }
    
    destroy() {
        fs.close(this.fd, (err) => {
            this.emit('close');
        });
    }
    
    pause() {
        this.flowing = false;
    }
    
    resume() {
        this.flowing = true;
        this.read();
    }
    
    pipe(dest) {
        this.on('data', (data) => {
            let flag = dest.write(data);
            if (!flag) this.pause();
        });
        dest.on('drain', () => {
            this.resume();
        });
        this.on('end', () => {
            dest.end();
        });
    }
}

module.exports = ReadStream;
複製程式碼
Writable
  1. 建立
let rs = fs.createWriteStream(path,{
    flags: 'w', // 開啟檔案要做的操作,預設為'w'
    encoding: null, // 預設為null
    highWaterMark: '', // 讀取快取區預設的大小的閥值16kb
})
複製程式碼
  1. 方法及作用
let ws = fs.createWriteStream(path,{
    chunk: '',// 寫入的資料buffer/string
    encoding: '', // 編碼格式chunk為字串時有用,可選
    callback: ()=>{} // 寫入成功後的回撥
});
// 返回值為布林值,系統快取區滿時為false,未滿時為true

ws.end(chunk,[encoding],[callback]);
// 表明接下來沒有資料要被寫入 Writable 通過傳入可選的 chunk 和 encoding 引數,可以在關閉流之前再寫入一段資料 如果傳入了可選的 callback 函式,它將作為 'finish' 事件的回撥函式


// 當一個流不處在 drain 的狀態, 對 write() 的呼叫會快取資料塊, 並且返回 false。 一旦所有當前所有快取的資料塊都排空了(被作業系統接受來進行輸出), 那麼 'drain' 事件就會被觸發
建議, 一旦 write() 返回 false, 在 'drain' 事件觸發前, 不能寫入任何資料塊
let fs = require('fs');
let ws = fs.createWriteStream('./2.txt',{
  flags:'w',
  encoding:'utf8',
  highWaterMark:3
});
let i = 10;
function write(){
 let  flag = true;
 while(i&&flag){
      flag = ws.write("1");
      i--;
     console.log(flag);
 }
}
write();
ws.on('drain',()=>{
  console.log("drain");
  write();
});
// 在呼叫了 stream.end() 方法,且緩衝區資料都已經傳給底層系統之後, 'finish' 事件將被觸發。
var writer = fs.createWriteStream('./2.txt');
for (let i = 0; i < 100; i++) {
  writer.write(`hello, ${i}!\n`);
}
writer.end('結束\n');
writer.on('finish', () => {
  console.error('所有的寫入已經完成!');
});

// pipe用法 將資料的滯留量限制到一個可接受的水平,以使得不同速度的來源和目標不會淹沒可用記憶體。
readStream.pipe(writeStream);
var from = fs.createReadStream('./1.txt');
var to = fs.createWriteStream('./2.txt');
from.pipe(to);

// pipe方法的原理
var fs = require('fs');
var ws = fs.createWriteStream('./2.txt');
var rs = fs.createReadStream('./1.txt');
rs.on('data', function (data) {
    var flag = ws.write(data);
    if(!flag)
    rs.pause();
});
ws.on('drain', function () {
    rs.resume();
});
rs.on('end', function () {
    ws.end();
});
複製程式碼

3.原理

const Writable = require('stream').Writable

const writable = Writable()
// 實現`_write`方法
// 這是將資料寫入底層的邏輯
writable._write = function (data, enc, next) {
  // 將流中的資料寫入底層
  process.stdout.write(data.toString().toUpperCase())
  // 寫入完成時,呼叫`next()`方法通知流傳入下一個資料
  process.nextTick(next)
}

// 所有資料均已寫入底層
writable.on('finish', () => process.stdout.write('DONE'))

// 將一個資料寫入流中
writable.write('a' + '\n')
writable.write('b' + '\n')
writable.write('c' + '\n')

// 再無資料寫入流時,需要呼叫`end`方法
writable.end()
複製程式碼
上游通過呼叫writable.write(data)將資料寫入可寫流中。write()方法會呼叫_write()將data寫入底層。
在_write中,當資料成功寫入底層後,必須呼叫next(err)告訴流開始處理下一個資料。
next的呼叫既可以是同步的,也可以是非同步的。
上游必須呼叫writable.end(data)來結束可寫流,data是可選的。此後,不能再呼叫write新增資料。
在end方法呼叫後,當所有底層的寫操作均完成時,會觸發finish事件。
複製程式碼

4.手寫

WriteStream

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

class WriteStream extends EventEmitter {
    constructor(path, options) {
        super(path, options);
        this.path = path;
        this.flags = options.flags || 'w';
        this.mode = options.mode || 0o666;
        this.start = options.start || 0;
        this.pos = this.start;
        this.encoding = options.encoding || 'utf8';
        this.autoClose = options.autoClose;
        this.highWaterMark = options.highWaterMark || 16 * 1024;
        
        this.buffers = [];//快取區
        this.writing = false;//表示內部正在寫入資料
        this.length = 0;//表示快取區位元組的長度
        this.open();
    }
    
    open() {
        fs.open(this.path, this.flags, this.mode, (err, fd) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                }
                return this.emit('error', err);
            }
            this.fd = fd;
            this.emit('open');
        });
    }
    
    
    write(chunk, encoding, cb) {
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, this.encoding);
        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());
        }
        return ret;
    }
    
    clearBuffer() {
        let data = this.buffers.shift();
        if (data) {
            this._write(data.chunk, data.encoding, () => this.clearBuffer())
        } else {
        
            this.writing = false;
            this.emit('drain');
        }
    }
    
    _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, bytesWritten) => {
            if (err) {
                if (this.autoClose) {
                    this.destroy();
                    this.emit('error', err);
                }
            }
            this.pos += bytesWritten;
            
            this.length -= bytesWritten;
            
            cb && cb();
        })
    }
    
    destroy() {
        fs.close(this.fd, () => {
            this.emit('close');
        })
    }
}

module.exports = WriteStream;
複製程式碼
Duplex

Duplex實際上就是繼承了Readable和Writable的一類流。 所以,一個Duplex物件既可當成可讀流來使用(需要實現_read方法),也可當成可寫流來使用(需要實現_write方法)。

var Duplex = require('stream').Duplex

var duplex = Duplex()

// 可讀端底層讀取邏輯
duplex._read = function () {
  this._readNum = this._readNum || 0
  if (this._readNum > 1) {
    this.push(null)
  } else {
    this.push('' + (this._readNum++))
  }
}

// 可寫端底層寫邏輯
duplex._write = function (buf, enc, next) {
  // a, b
  process.stdout.write('_write ' + buf.toString() + '\n')
  next()
}

// 0, 1
duplex.on('data', data => console.log('ondata', data.toString()))

duplex.write('a')
duplex.write('b')

duplex.end()
複製程式碼

上面的程式碼中實現了_read方法,所以可以監聽data事件來消耗Duplex產生的資料。 同時,又實現了_write方法,可作為下游去消耗資料。

因為它既可讀又可寫,所以稱它有兩端:可寫端和可讀端。 可寫端的介面與Writable一致,作為下游來使用;可讀端的介面與Readable一致,作為上游來使用。

Transform

在上面的例子中,可讀流中的資料(0, 1)與可寫流中的資料('a', 'b')是隔離開的,但在Transform中可寫端寫入的資料經變換後會自動新增到可讀端。 Tranform繼承自Duplex,並已經實現了_read和_write方法,同時要求使用者實現一個_transform方法。

'use strict'

const Transform = require('stream').Transform

class Rotate extends Transform {
  constructor(n) {
    super()
    // 將字母旋轉`n`個位置
    this.offset = (n || 13) % 26
  }

  // 將可寫端寫入的資料變換後新增到可讀端
  _transform(buf, enc, next) {
    var res = buf.toString().split('').map(c => {
      var code = c.charCodeAt(0)
      if (c >= 'a' && c <= 'z') {
        code += this.offset
        if (code > 'z'.charCodeAt(0)) {
          code -= 26
        }
      } else if (c >= 'A' && c <= 'Z') {
        code += this.offset
        if (code > 'Z'.charCodeAt(0)) {
          code -= 26
        }
      }
      return String.fromCharCode(code)
    }).join('')

    // 呼叫push方法將變換後的資料新增到可讀端
    this.push(res)
    // 呼叫next方法準備處理下一個
    next()
  }

}

var transform = new Rotate(3)
transform.on('data', data => process.stdout.write(data))
transform.write('hello, ')
transform.write('world!')
transform.end()

// khoor, zruog!
複製程式碼

參考資料

通過原始碼解析 Node.js 中導流(pipe)的實現

相關文章