前言
在之前的部落格中已經瞭解了流的基本用法(請看我之前的部落格),這篇的重點在於根據可讀流的用法對可讀流的原理進行分析,實現簡易版的 ReadStream
可讀流的實現(流動模式)
1、ReadStream 類建立
在使用 fs
的 createReadStream
建立可讀流時,返回了 ReadStream
物件,上面存在著一些事件和方法,其實我們在建立這個可讀流的時候建立了某一個類的例項,這個例項可以呼叫類原型上的方法,我們這裡將這個類命名為 ReadStream
。
在類原型上的方法內部可能會建立一些事件,在 NodeJS 中,事件是依賴 events
模組的,即 EventEmitter
類,同時類的方法可能會操作檔案,會用到 fs
模組,所以也提前引入 fs
。
// 引入依賴模組
const EventEmitter = require("events");
const fs = require("fs");
// 建立 ReadStream 類
class ReadStream extends EventEmitter {
constructor(path, options = {}) {
super();
// 建立可讀流引數傳入的屬性
this.path = path; // 讀取檔案的路徑
this.flags = options.flags || "r"; // 檔案標識位
this.encoding = options.encoding || null; // 字元編碼
this.fd = options.fd || null; // 檔案描述符
this.mode = options.mode || 0o666; // 許可權位
this.autoClose = options.autoClose || true; // 是否自動關閉
this.start = options.start || 0; // 讀取檔案的起始位置
this.end = options.end || null; // 讀取檔案的結束位置(包含)
this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次讀取檔案的位元組數
this.flowing = false; // 控制當前是否是流動狀態,預設為暫停狀態
this.buffer = Buffer.alloc(this.highWaterMark); // 儲存讀取內容的 Buffer
this.pos = this.start; // 下次讀取檔案的位置(變化的)
// 建立可讀流要開啟檔案
this.open();
// 如果監聽了 data 事件,切換為流動狀態
this.on("newListener", type => {
if (type === "data") {
this.flowing = true;
// 開始讀取檔案
this.read();
}
});
}
}
// 匯出模組
module.exports = ReadStream;複製程式碼
使用 fs.createReadStream
時傳入了兩個引數,讀取檔案的路徑和一個 options
選項,options
上有八個引數,我們在建立 ReadStream
類的時候將這些引數初始化到了 this
上。
建立可讀流的時候有兩種狀態,流動狀態和暫停狀態,預設建立可讀流是暫停狀態,只有在觸發 data
事件時才會變為流動狀態,所以在 this
上掛載了 flowing
儲存當前的狀態是否為流動狀態,值預設為 false
。
readable
, 是可讀流的另一種模式,我們這節討論的可讀流為流動模式。在讀取檔案時其實是操作 Buffer 進行讀取的,需要有一個 Buffer 例項用來儲存每次讀取的資料,所以在 this
上掛載了一個新建立的 Buffer,長度等於 highWaterMark
。
當從 start
值的位置開始讀取檔案,下一次讀取檔案的位置會發生變化,所以在 this
上掛載了 pos
屬性,用於儲存下次讀取檔案的位置。
在建立 ReadStream
的例項(可讀流)時,應該開啟檔案並進行其他操作,所以在 this
上掛載了 open
方法並執行。
建立例項的同時監聽了 newListener
事件,回撥在每次使用 on
監聽事件時觸發,回撥內部邏輯是為了將預設的暫停狀態切換為流動狀態,因為在使用時,流動狀態是通過監聽 data
事件觸發的,在 newListener
的回撥中判斷事件型別為 data
的時候將 flowing
標識的值更改為 true
,並呼叫讀取檔案的 read
方法。
在使用 ES6 的類程式設計時,原型上的方法都是寫在 class
內部,我們下面為了把原型上的方法拆分出來成為單獨的程式碼塊,都使用 ReadStream.prototype.open = function...
直接給原型新增屬性的方式,但這樣的方式和直接寫在 class
內有一點區別,就是 class
內部的書寫的原型方法都是不可遍歷的,新增屬性的方式建立的方法都是可遍歷的,但是這點區別對我們程式碼的執行沒有任何影響。
2、開啟檔案方法 open 的實現
在使用可讀流時,開啟時預設是暫停狀態,會觸發 open
事件,如果開啟檔案出錯會觸發 error
事件。
// 開啟檔案
ReadStream.prototype.open = function() {
fs.open(this.path, this.flags, this.mode, (err, fd) => {
if (err) {
this.emit("error", err);
// 如果檔案開啟了出錯,並配置自動關閉,則關掉檔案
if (this.autoClose) {
// 關閉檔案(觸發 close 事件)
this.destroy();
// 不再繼續執行
return;
}
}
// 儲存檔案描述符
this.fd = fd;
// 成功開啟檔案後觸發 open 事件
this.emit("open");
});
};複製程式碼
open
方法的邏輯就是在開啟檔案的時候,將檔案描述符儲存在例項上方便後面使用,並使用 EventEmitter
的原型方法 emit
觸發 open
事件,如果出錯就使用 emit
觸發 error
事件,如果配置 autoClose
引數為 true
,就關閉檔案並觸發 close
。
我們將關閉檔案的邏輯抽取出來封裝在了 ReadStream
類的 destroy
方法中,下面來實現 destroy
。
3、關閉檔案方法 destroy 的實現
檔案出錯分為兩種,第一種檔案開啟出錯,第二種是檔案不存在出錯(沒開啟),第二種系統是沒有分配檔案描述符的。
// 關閉檔案
ReadStream.prototype.detroy = function() {
// 判斷是否存在檔案描述符
if (typeof this.fd === "number") {
// 存在則關閉檔案並觸發 close 事件
fs.close(fd, () => {
this.emit("close");
});
return;
}
// 不存在檔案描述符直接觸發 close 事件
this.emit("close");
};複製程式碼
如果是開啟檔案後出錯需要關閉檔案,並觸發 close
事件,如果是沒開啟檔案,則直接觸發 close
事件,所以上面通過檔案描述符來判斷該如何處理。
4、讀取檔案方法 read 的實現
還記得在 ReadStream
類中,監聽的 newListener
事件的回撥中如果監聽了 data
事件則會執行 read
讀取檔案,接下來就實現讀取檔案的核心邏輯。
// 讀取檔案
ReadStream.prototype.read = function() {
// 由於 open 非同步執行,read 是在建立例項時同步執行
// read 執行可能早於 open,此時不存在檔案描述符
if (typeof this.fd !== "number") {
// 因為 open 用 emit 觸發了 open 事件,所以在這是重新執行 read
return this.once("open", () => this.read());
}
// 如過設定了結束位置,讀到結束為止就不能再讀了
// 如果最後一次讀取真實讀取數應該小於 highWaterMark
// 所以每次讀取的位元組數應該和 highWaterMark 取最小值
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) => {
// 如果讀到內容執行下面程式碼,讀不到則觸發 end 事件並關閉檔案
if (bytesRead > 0) {
// 維護下次讀取檔案位置
this.pos += bytesRead;
// 保留有效的 Buffer
let realBuf = this.buffer.slice(0, bytesRead);
// 根據編碼處理 data 回撥返回的資料
realBuf = this.encoding
? realBuf.toString(this.encoding)
: realBuf;
// 觸發 data 事件並傳遞資料
this.emit("data", realBuf);
// 遞迴讀取
if (this.flowing) {
this.read();
}
} else {
this.isEnd = true;
this.emit("end"); // 觸發 end 事件
this.detroy(); // 關閉檔案
}
}
);
};複製程式碼
建立 ReadStream
的例項時,執行的 open
方法內部是使用 fs.open
開啟檔案的,是非同步操作,而讀取檔案方法 read
是在 newListener
回撥中同步執行的,這樣很可能觸發 read
的時候檔案還沒有被開啟(不存在檔案描述符),所以在 read
方法中判斷了檔案描述符是否存在,並在不存在時候使用 once
新增了 open
事件,回撥中重新執行了 read
。
由於在 open
方法中使用 emit
觸發了 open
事件,所以 read
內用 once
新增的 open
事件的回撥也會跟著執行一次,並在回撥中重新呼叫了 read
方法,保證了 read
讀取檔案的邏輯在檔案真正開啟後才執行,為了檔案開啟前執行 read
而不執行讀取檔案的邏輯,用 once
新增 open
事件時別忘記 return
。
在使用 fs.read
讀取檔案的時候有一個引數為本次讀取幾個字元到 Buffer 中,如果在建立可讀流的時候設定了讀取檔案的結束位置 end
引數,則讀到 end
位置就不應該再繼續讀取了,所以在存在 end
引數的時候每次都計算一下讀取個數和 highWaterMark
取最小值,保證讀取內容小於 highWaterMark
的時候不會多讀,因為讀取時是包括 end
值作為 Buffer 的索引這一項的,所以計算時多減去的要 +1
加回來,再一次讀取這個讀取個數計算結果變成了 0
,也就結束了讀取。
因為 end
引數的情況,所以在內部讀取邏輯前判斷了 bytesRead
(實際讀取位元組數)是否大於 0
,如果不滿足條件則在例項新增是否讀取結束標識 isEnd
(後面使用),觸發 end
事件並關閉檔案,如果滿足條件,也是通過 bytesRead
對 Buffer 進行擷取,保留了有用的 Buffer,並且通過 encoding
編碼對 Buffer 進行處理後,觸發 data
事件,並將處理後的資料傳遞給 data
事件的回撥。
5、暫停、恢復讀取 pause 和 resume
pause
的目的就是暫停讀取,其實就是阻止 read
方法在讀取時進行遞迴,所以只需要更改 flowing
的值即可。
// 暫停讀取
ReadStream.prototype.pause = function() {
this.flowing = false;
};複製程式碼
resume
的目的是恢復讀取,在更改 flowing
值得基礎上重新執行 read
方法,由於在 pause
呼叫時 read
內部還是執行得讀取檔案得分支,檔案並沒有關閉,讀取檔案位置的引數也是通過例項上的當前的屬性值進行計算的,所以重新執行 read
會繼續上一次的位置讀取。
// 恢復讀取
ReadStream.prototype.resume = function() {
this.flowing = true;
if (!this.isEnd) this.read();
};複製程式碼
上面在重新執行 read
之前使用 isEnd
標識做了判斷,防止在 setInterval
中呼叫 resume
在讀取完成後不斷的觸發 end
和 close
事件。
驗證可讀流(流動模式)ReadStream
接下來我們使用自己實現的 ReadStream
類來建立可讀流,並按照 fs.createReadStream
的用法進行使用並驗證。
// 檔案 1.txt 內容為 0123456789
const fs = require("fs");
const ReadStream = require("./ReadStream");
// 建立可讀流
let rs = new ReadStream("1.txt", {
encoding: "utf8",
start: 0,
end: 5,
highWaterMark: 2
});
rs.on("open", () => console.log("open"));
rs.on("data", data => {
console.log(data, new Date());
rs.pause();
});
rs.on("end", () => console.log("end"));
rs.on("close", () => console.log("close"));
rs.on("error", err => console.log(err));
setInterval(() => rs.resume(), 1000);
// open
// 01 2018-07-04T10:44:20.384Z
// 23 2018-07-04T10:44:21.384Z
// 45 2018-07-04T10:44:22.384Z
// end
// close複製程式碼
執行上面的程式碼正常的執行邏輯是先觸發 open
事件,然後觸發 data
事件,讀取一次後暫停,每隔一秒恢復讀取一次,再讀取完成後觸發 end
和 close
事件,通過執行程式碼結果和我們希望的一樣。
可讀流的實現(暫停模式)
1、在 fs 中的暫停模式的真正用法
在 fs
模組中用 createReadStream
建立的可讀流中通過監聽 readable
事件觸發暫停模式(監聽 data 事件觸發流動模式),通過下面例子感受暫停模式與流動模式的不同,現在讀取檔案 1.txt
,內容為 0~9
十個數字。
// 讀取的
const fs = require("fs");
// 建立可讀流
let rs = fs.createReadStream("1.txt", {
encoding: "utf8",
start: 0,
hithWaterMark: 3
});
rs.on("readable", () => {
// read 引數為本次讀取的個數
let r = rs.read(3);
// 列印讀取的資料
console.log(r);
// 列印容器剩餘空間
console.log(rs._readableState.length);
});
// 012
// 0
// 345
// 0
// 678
// 0
// null
// 1
// 90
// 0複製程式碼
通俗的解釋,暫停模式的 readable
事件預設會觸發一次,監聽 readable
事件後就像建立了一個 “容器”,容量為 hithWaterMark
,檔案中的資料會自動把容器注滿,呼叫可讀流的 read
方法讀取時,會從容器中取出資料,如果 read
方法讀取的資料小於 hithWaterMark
,則直接暫停,不再繼續讀取,如果大於 hithWaterMark
,說明 “容器” 空了,則會觸發 readable
事件,無論讀取位元組數與 hithWaterMark
關係如何,只要 “容器” 內容量剩餘小於 hithWaterMark
就會進行 “續杯”,再次向 “容器” 中填入 hithWaterMark
個,所以有些時候真實的容量會大於 hithWaterMark
。
read
方法讀取的內容會返回 null
是因為容器內真實的資料數小於了讀取數,如果不是最後一次讀取,會在多次讀取後將值一併返回,如果是最後一次讀取,會把剩餘不足的資料返回。
readable
事件的觸發條件:“容器” 空了;hithWaterMark
。read
返回 null
:“容器” 容器內可悲讀取資料無法滿足一次讀取位元組數。2、ReadableStream 類的實現
同為可讀流,暫停模式與流動模式相同,都依賴 fs
模組和 events
模組的 EventEmitter
類,引數依然為讀取檔案的路徑和 options
。
// 引入依賴
const EventEmitter = require("events");
const fs = require("fs");
class ReadableStream extends EventEmitter {
constructor(path, options = {}) {
super();
this.path = path; // 讀取檔案的路徑
this.flags = options.flags || "r"; // 檔案標識位
this.encoding = options.encoding || null; // 字元編碼
this.fd = options.fd || null; // 檔案描述符
this.mode = options.mode || 0o666; // 許可權位
this.autoClose = options.autoClose || true; // 是否自動關閉
this.start = options.start || 0; // 讀取檔案的起始位置
this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次讀取檔案的位元組數
this.reading = false; // 如果正在讀取,則不再讀取
this.emitReadable = false; // 當快取區的長度等於 0 的時候, 觸發 readable
this.arr = []; // 快取區
this.len = 0; // 快取區的長度
this.pos = this.start; // 下次讀取檔案的位置(變化的)
// 建立可讀流要開啟檔案
this.open();
this.on("newListener", type => {
if (type === "readable") {
this.read(); // 監聽readable就開始讀取
}
});
}
}
// 匯出模組
module.exports = ReadableStream;複製程式碼
在類的新增了 newListener
事件,在回撥中判斷是否監聽了 readable
事件,如果監聽了開始從 “容器” 中讀取。
3、開啟、關閉檔案 open 和 detroy
開啟和關閉檔案的方法和流動模式的套路基本相似。
// 開啟檔案
ReadableStream.prototype.open = function() {
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");
});
};複製程式碼
// 關閉檔案
ReadableStream.prototype.detroy = function() {
if (typeof this.fd === "number") {
fs.close(fd, () => {
this.emit("close");
});
return;
}
this.emit("close");
};複製程式碼
4、從 “容器” 中讀取 read 方法的實現
read
方法的引數不傳時就相當於從 “容器” 讀取 highWaterMart
個位元組,如果傳參表示讀取引數數量的位元組數。
ReadableStream.prototype.read = function(n) {
// 如果讀取大於了 highWaterMark,重新計算 highWaterMark,並重新讀取
if (n > this.len) {
// 計算新的 highWaterMark,方法摘自 NodeJS 原始碼
this.highWaterMark = computeNewHighWaterMark(n);
this.reading = true;
this._read();
}
// 將要返回的資料
let buffer;
// 如果讀取的位元組數大於 0 小於等於當前快取 Buffer 的總長度
if (n > 0 && n <= this.len) {
// 則從快取中取出
buffer = Buffer.alloc(n);
let current; // 儲存每次從快取區讀出的第一個 Buffer
let index = 0; // 每次讀取快取 Buffer 的索引
let flag = true; // 是否結束整個 while 迴圈的標識
// 開始讀取
while ((current = this.arr.shift()) && flag) {
for (let i = 0; i < current.length; i++) {
// 將快取中取到的 Buffer 的內容讀到自己定義的 Buffer 中
buffer[index++] = current[i];
// 如果當前索引值已經等於了讀取個數,結束 for 迴圈
if (index === n) {
flag = false;
// 取出當前 Buffer 沒有消耗的
let residue = current.slice(i + 1);
// 在讀取後維護快取的長度
this.len -= n;
// 如果 BUffer 真的有剩下的就給塞回到快取中
if (residue.length) {
this.arr.unshift(residue);
}
break;
}
}
}
}
// 如果當前 讀取的 Buffer 為 0,將觸發 readable 事件
if (this.len === 0) {
this.emitReadable = true;
}
// 如果當前的快取區大小小於 highWaterMark,就要讀取
if (this.len < this.highWaterMark) {
// 如果不是正在讀取才開始讀取
if (!this.read) {
this.reading = true;
this._read(); // 正真讀取的方法
}
}
// 將 buffer 轉回建立可讀流設定成的編碼格式
if (buffer) {
buffer = this.encoding ? buffer.toString(this.encoding) : buffer;
}
return buffer;
};複製程式碼
上面的 read
方法的引數大小對比快取區中取出的 Buffer 長度有兩種情況,一種是小於當前快取區內取出 Buffer 的長度,一種是大於了真個快取區的 len
的長度。
小於當前快取區總長度通過迴圈取出需要的 Buffer 儲存了我們要返回建立的 Buffer 中,剩餘的 Buffer 會丟失,所以我們做了一個小小的處理,將剩下的 Buffer 作為第一個 Buffer 塞回到了快取區中,在處理這個問題時與流動模式不相同,流動模式處理後直接跳出了,而暫停模式相當於從 “容器” 中讀取,如果第一次讀取後還有剩餘還要接著從容器中繼續讀取。
大於 len
屬性時,規定需要重新計算 highWaterMark
,遵循的原則是將當前 highWaterMark
設定為當前讀取位元組個數距離最接近的 2
的 n
次方的數值,NodeJS 原始碼中方法名稱為 computeNewHighWaterMark
,為了提高效能是使用位運算的方式進行計算的,原始碼如下。
function computeNewHighWaterMark(n) {
n--;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n++;
return n;
}複製程式碼
在呼叫該方法重新計算 highWaterMark
後更改正在讀取狀態,重新讀取,由於讀取邏輯的重複,所以真正讀取檔案的邏輯抽取成一個 _read
方法來實現,下面呢就來看看 _read
內部都做了什麼。
5、真正讀取檔案的 _read
對比可讀流(流動模式)的 read
方法,在呼叫 _read
方法讀取時,是在 newListener
中同步執行 _read
,所以為了保證 _read
的邏輯是在 open
方法開啟檔案以後執行,使用了與 read
相同的處理方式。
ReadableStream.prototype._read = function() {
if (typeof this.fd !== "number") {
return this.once("open", () => _read());
}
// 建立本次讀取的 Buffer
let buffer = Buffer.alloc(this.highWaterMark);
// 讀取檔案
fs.read(
this.fd,
buffer,
0,
this.highWaterMark,
this.pos,
(err, bytesRead) => {
if (bytesRead > 0) {
this.arr.push(buffer); // 快取
this.len += bytesRead; // 維護快取區長度
this.pos += bytesRead; // 維護下一次讀取位置
this.reading = false; // 讀取完畢
// 觸發 readable 事件
if (this.emitReadable) {
// 觸發後更改觸發狀態為 false
this.emitReadable = false;
this.emit("readable");
}
} else {
// 如果讀完觸發結束事件
this.emit("end");
}
}
);
};複製程式碼
由於快取區是一個陣列,儲存的每一個 Buffer 是獨立存在的,所以不能掛載在例項上共用,如果掛在例項上則引用相同,一動全動,這不是我們想要的,所以每一次執行 _read
方法時都建立新的 Buffer 例項存入讀取的資料後儲存在快取區中,如果讀取完成 bytesRead
為 0
,則觸發 end
事件。
fs.createReadStream
建立一個可讀流,通過監聽 data
和 readable
兩種不同的事件來觸發兩種不同的模式,而我們為了模擬,把兩種模式拆開成了兩個類來實現的,在測試時需要建立不同類的例項。驗證可讀流(暫停模式)ReadableStream
為了統一我們依然讀取真正用法中 1.txt
檔案,內容為 0~9 十個數字。
// 引入依賴
const fs = require("fs");
const ReadableStream = require("./ReadableStream");
let rs = new ReadableStream("1.txt", {
encoding: "utf8",
start: 0,
highWaterMark: 3
});
rs.on("readable", () => {
let r = rs.read(3);
console.log(r);
console.log(rs.len);
});複製程式碼
在列印 “容器” 剩餘容量時,我們使用在 ReadableStream
上構造的 len
屬性。
流動模式和暫停模式分別有不同的應用場景,如果只是希望讀取一個檔案,並最快的獲得結果使用流動模式是很好的選擇,如果希望瞭解讀取檔案的具體內容,並進行精細的處理,使用暫停模式更好一些。
可寫流的實現
1、WriteStream 類建立
在使用 fs
的 createWriteStream
建立可寫流時,返回了 WriteStream
物件,上面也存在事件和方法,建立可寫流的時也是建立類的例項,我們將這個類命名為 WriteStream
。事件同樣依賴 events
模組的 EventEmitter
類,檔案操作同樣依賴 fs
模組,所以需提前引入。
// 引入依賴模組
const EventEmitter = require("events");
const fs = require("fs");
// 建立 WriteStream 類
class WriteStream extends EventEmitter {
constructor(path, options = {}) {
super();
// 建立可寫流引數傳入的屬性
this.path = path; // 寫入檔案的路徑
this.flags = options.flags || "w"; // 檔案標識位
this.encoding = options.encoding || "utf8"; // 字元編碼
this.fd = options.fd || null; // 檔案描述符
this.mode = options.mode || 0o666; // 許可權位
this.autoClose = options.autoClose || true; // 是否自動關閉
this.start = options.start || 0; // 寫入檔案的起始位置
this.highWaterMark = options.highWaterMark || 16 * 1024; // 對比寫入位元組數的標識
this.writing = false; // 是否正在寫入
this.needDrain = false; // 是否需要觸發 drain 事件
this.buffer = []; // 快取,正在寫入就存入快取中
this.len = 0; // 當前快取的個數
this.pos = this.start; // 下次寫入檔案的位置(變化的)
// 建立可寫流要開啟檔案
this.open();
}
}
// 匯出模組 複製程式碼
module.exports = WriteStream;使用 fs.createWriteStream
建立可寫流時傳入了兩個引數,寫入的檔案路徑和一個 options
選項,options
上有七個引數,我們在建立 ReadStream
類的時候將這些引數初始化到了 this
上。
建立可寫流後需要使用 write
方法進行寫入,寫入時第一次會真的通過記憶體寫入到檔案中,而再次寫入則會將內容寫到快取中,注意這裡的 “記憶體” 和 “快取”,記憶體是寫入檔案是的系統記憶體,快取是我們自己建立的陣列,第一次寫入以後要寫入檔案的 Buffer 都會先存入這個陣列中,這個陣列名為 buffer
,掛載在例項上,例項上同時掛載了 len
屬性用來儲存當前快取中 Buffer 總共的位元組數(長度)。
我們在可讀流上掛載了是否正在寫入的狀態 writing
屬性,只要快取區中存在未寫入的 Buffer,writing
的狀態就是正在寫入,當寫入的位元組數大於了 highWaterMark
需要觸發 drain
事件,所以又掛載了是否需要觸發 drain
事件的標識 needDrain
屬性。
當從檔案的 start
值對應的位置開始寫入,下一次寫入檔案的位置會發生變化,所以在 this
上掛載了 pos
屬性,用於儲存下次寫入檔案的位置。
在 NodeJS 流的原始碼中快取是用連結串列實現的,通過指標來操作快取中的 Buffer,而我們為了簡化邏輯就使用陣列來作為快取,雖然效能相對連結串列要差。
2、開啟、關閉檔案 open 和 detroy
在 WriteStream
中,寫入檔案之前也應該開啟檔案,在開啟檔案過程中出錯時也應該觸發 error
事件並關閉檔案,開啟和關閉檔案的方法 open
和 detroy
與 ReadStream
的 open
和 detroy
方法的邏輯如出一轍,所以這裡直接拿過來用了。
// 開啟檔案
WriteStream.prototype.open = function() {
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");
});
};複製程式碼
// 關閉檔案
WriteStream.prototype.detroy = function() {
if (typeof this.fd === "number") {
fs.close(fd, () => {
this.emit("close");
});
return;
}
this.emit("close");
};複製程式碼
3、寫入檔案方法 write 的實現
write
方法預設支援傳入三個引數:
- chunk:寫入檔案的內容;
- encoding:寫入檔案的編碼格式;
- callback:寫入成功後執行的回撥。
// 寫入檔案的方法,只要邏輯為寫入前的處理
WriteStream.prototype.write = function(
chunk,
encoding = this.encoding,
callback
) {
// 為了方便操作將要寫入的資料轉換成 Buffer
chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
// 維護快取的長度
this.len += chunk.lenth;
// 維護是否觸發 drain 事件的標識
this.needDrain = this.highWaterMark <= this.len;
// 如果正在寫入
if (this.writing) {
this.buffer.push({
chunk,
encoding,
callback
});
} else {
// 更改標識為正在寫入,再次寫入的時候走快取
this.writing = true;
// 如果已經寫入清空快取區的內容
this._write(chunk, encoding, () => this.clearBuffer());
}
return !this.needDrain;
};複製程式碼
與可寫流的 read
一樣,我們在使用 write
方法將資料寫入檔案時,也是操作 Buffer,在 write
方法中,首先將接收到的要寫入的資料轉換成了 Buffer,因為是多次寫入,要知道快取中 Buffer 位元組數的總長度,所以維護了 len
變數。
我們的 WriteStream
建構函式中,this
掛載了 needDrain
屬性,在使用 fs.createWriteStream
建立的可讀流時,是寫入的位元組長度超過 highWaterMark
才會觸發 drain
事件,而 needDrain
與 write
的返回值正好相反,所以我們用 needDrain
取反來作為 write
方法的返回值。
在寫入的邏輯中第一次是直接通過記憶體寫入到檔案,但是再次寫入就需要將資料存入快取,將資料寫入到檔案中寫入狀態 writing
預設為 false
,通過快取再寫入證明應該正在寫入中,所以在第一次寫入後應更改 writing
的狀態為 true
,寫入快取其實就是把轉換的 Buffer、編碼以及寫入成功後要執行的回撥掛在一個物件上存入快取的陣列 buffer
中。
我們把真正寫入檔案的邏輯抽取成一個單獨的方法 _write
,並傳入 chunk
(要寫入的內容,已經處理成 Buffer)、encoding
(字元編碼)、回撥函式,在回撥函式中執行了原型方法 clearBuffer
,接下來就來實現 _write
和 clearBuffer
。
4、真正的檔案操作 _write
對比可讀流(流動模式)的 read
方法,在呼叫 _write
方法寫入時,是在建立可寫流之後的同步程式碼中執行的,與可讀流在 newListener
中同步執行 read
的情況類似,所以為了保證 _write
的邏輯是在 open
方法開啟檔案以後執行,使用了與 read
相同的處理方式。
// 真正的寫入檔案操作的方法
WriteStream.prototype._write = function(chunk, encoding, callback) {
// 由於 open 非同步執行,write 是在建立例項時同步執行
// write 執行可能早於 open,此時不存在檔案描述符
if (typeof this.fd !== "number") {
// 因為 open 用 emit 觸發了 open 事件,所以在這是重新執行 write
return this.once("open", () => this._write(chunk, encoding, callback));
}
// 讀取檔案
fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, bytesWritten) => {
// 維護下次寫入的位置和快取區 Buffer 的總位元組數
this.pos += bytesWritten;
this.len -= bytesWritten;
callback();
});
};複製程式碼
在開啟檔案並寫入的時候需要維護兩個變數,下次寫入的位置 pos
和當前快取區內 Buffer 所佔總位元組數 len
,本次寫入了多少個位元組,下次寫入需要在寫入位置的基礎上加多少個位元組,而 len
恰恰相反,本次寫入了多少個位元組,快取區中的總長度應該對應的減少多少個位元組。
在維護兩個變數的值以後呼叫 callback
,其實 callback
內執行的是 clearBuffer
方法,就如方法名,譯為 “清空快取”,其實就是一次一次的將資料寫入檔案並從快取中移除,很明顯需要遞迴呼叫 _write
方法,我們將這個遞迴的邏輯統一放在 clearBuffer
方法中實現。
5、清空快取操作 clearBuffer
// 清空快取方法
WriteStream.prototype.clearBuffer = function() {
// 先寫入的在陣列前面,從前面取出快取中的 Buffer
let buf = this.buffer.shift();
// 如果存在 buf,證明快取還有 Buffer 需要寫入
if (buf) {
// 遞迴 _write 按照編碼將資料寫入檔案
this._write(buf.chunk, buf.encoding, () => this.clearBuffer);
} else {
// 如果沒有 buf,說明快取內的內容已經完全寫入檔案並清空,需要觸發 drain 事件
this.emit("drain");
// 更改正在寫入狀態
this.writing = false;
// 更改是否需要觸發 drain 事件狀態
this.needDrain = false;
}
};複製程式碼
clearBuffer
方法中獲取了快取區陣列的最前面的 Buffer(最前面的是先寫入快取的,也應該先取出來寫入檔案),存在這個 Buffer 時,遞迴 _write
方法按照編碼將資料寫入檔案,如果不存在說明快取區已經清空了,代表內容完全寫入檔案中,所以觸發 drain
事件,最後更改了 writing
和 needDrain
的狀態。
更正 writing
是為了 WriteStream
建立的可讀流在下次呼叫 write
方法時預設第一次真正寫入檔案,而更正 needDrain
的狀態是在快取區要清空的最後一個 Buffer 的長度小於了 highWaterMark
時,保證 write
方法的返回值是正確的。
第一次是真正寫入,其他的都寫入快取,再一個一個的將快取中儲存的 Buffer 寫入並從快取清空,之所以這樣設計是為了把寫入的內容排成一個佇列,假如有 3
個人同時操作一個檔案寫入內容,只有第一個人是真的寫入,其他的人都寫在快取中,再按照寫入快取的順序依次寫入檔案,避免衝突和寫入順序出錯。
驗證可寫流 WriteStream
接下來我們使用自己實現的 WriteStream 類來建立可寫流,並按照 fs.createWriteStream
的用法進行使用並驗證。
// 向 1.txt 檔案中寫入 012345
const fs = require("fs");
const WriteStream = require("./WriteStream");
// 建立可寫流
let ws = new WriteStream("2.txt", {
highWaterMark: 3
});
let i = 0;
function write() {
let flag = true;
while (i <= 6 && flag) {
i++;
flag = ws.write(i + "", "utf8");
}
}
ws.on("drain", function() {
console.log("寫入成功");
write();
});
write();
// true
// true
// false
// 寫入成功
// true
// true
// false
// 寫入成功複製程式碼
可以使用 fs.createWriteStream
和 WriteStream
類分別執行上面的程式碼,對比結果,看看是否相同。
可讀流和可寫流的橋樑 pipe
可寫流和可讀流一般是通過 pipe
配合來使用的,pipe
方法是可讀流 ReadStream
的原型方法,引數為一個可寫流。
// 連線可讀流和可寫流的方法 pipe
ReadStream.prototype.pipe = function(dest) {
// 開始讀取
this.on("data", data => {
// 如果超出可寫流的 highWaterMark,暫停讀取
let flag = dest.write(data);
if (!flag) this.pause();
});
dest.on("drain", () => {
// 當可寫流清空記憶體時恢復讀取
this.resume();
});
this.on("end", () => {
// 在讀取完畢後關閉檔案
this.destroy();
});
};複製程式碼
pipe
方法其實就是通過可讀流的 data
事件觸發流動狀態,並用可寫流接收讀出的資料進行寫入,當寫入資料超出 highWaterMark
,則暫停可讀流的讀取,直到可寫流的快取被清空並把內容寫進檔案後,恢復可讀流的讀取,當讀取結束後關閉檔案。
下面我們實現一個將 1.txt
的內容拷貝 2.txt
中的例子。
// pipe 的使用
const fs = require("fs");
// 引入自己的 ReadStream 類和 WriteStream 類
const ReadStream = rquire("./ReadStream");
const WriteStream = rquire("./WriteStream");
// 建立可讀流和可寫流
let rs = new ReadStream("1.txt", {
highWaterMark: 3
});
let ws = new WriteStream("2.txt", {
highWaterMark: 2
});
// 使用 pipe 實現檔案內容複製
rs.pipe(ws);複製程式碼
總結
在 NodeJS 原始碼中,可讀流和可寫流的內容要比本篇內容多很多,本篇是將原始碼精簡,抽出核心邏輯並針對流的使用方式進行實現,主要目的是幫助理解流的原理和使用,爭取做到 “知其然知其所以然”,瞭解了一些底層再對流使用時,也能遊刃有餘。