流是一組有序的,有起點和終點的位元組資料傳輸手段,它不關心檔案的整體內容,只關注是否從檔案中讀到了資料,以及讀到資料之後的處理。
流是一個抽象介面,被 Node
中的很多物件所實現。比如HTTP 伺服器request
和response
物件都是流。
今天我們來學習這塊知識,怎麼學,寫原始碼唄。這篇文章主要是去實現一個可讀流,實現一個可寫流,就不介紹流的基礎API
和用法了。
如果你還不熟悉,建議移步到這裡
1、前置知識
學習流之前,我們需要掌握事件機制
。原始碼中採用的是events
模組,你也可以自己寫一個,只要有釋出、訂閱這兩個API
就可以了。
釋出訂閱這種機制可以完成資訊的互動功能,同時還能解耦模組。這種機制在好多原始碼中都有體現,比如:webpack
原始碼中的事件流、vue
中MVVM
模式也用了釋出訂閱機制。
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
)是同時實現了 Readable
和 Writable
介面的流。有了雙工流,我們可以在同一個物件上同時實現可讀和可寫,就好像同時繼承這兩個介面。 重要的是雙工流的可讀性和可寫性操作完全獨立於彼此。這僅僅是將兩個特性組合成一個物件。
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、轉換流
對於轉換流,我們不必實現read
或write
的方法,我們只需要實現一個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);
複製程式碼