春風十里不如Node中的一股清流

chenhongdong發表於2018-04-06

一股清流

清明時節雨紛紛,果然每逢清明是會下雨的。在這個雨夾雪,不方便外出的日子,宅在家裡一起來相互學習分享吧!不然還能怎樣呢!哈哈

友情提示:本文可能會涉及到一些Api的內容,會很乏味,很枯燥,很沒勁

But我們後面的精彩也會超乎你的想象,因為我們要手寫實現一下,不親自上馬怎麼知道馬跑的有多慢呢,Hurry Up Go Go Go!

用過node的朋友們都知道流的作用非常之厲害,可讀可寫,無所不能。

相比於fs模組,流更適用於讀取一個大檔案,一次性讀取會佔用大量記憶體,效率很低,而流是將資料分割成段,會一段一段的讀取,效率會很高。說了一堆,先上概念,一起看看它是誰

概念

  • 流是一組有序的,有起點和終點的位元組資料傳輸手段
  • 它不關心檔案的整體內容,只關注是否從檔案中讀到了資料,以及讀到資料之後的處理
  • 流是一個抽象介面,被 Node 中的很多物件所實現。比如HTTP 伺服器request和response物件都是流

node中很多內容都應用到了流,比如http模組的req就是可讀流,res是可寫流,而socket是可讀可寫流,看起來屌屌的,那麼我們今天就都不講他們,只來講一下可讀流和可寫流這對兄弟

可讀流和可寫流對檔案的操作用的也是fs模組

那麼讓我們從可讀流講起,先來看下都有哪些方法(Api)

可讀流

首先要會用,重點在會用

建立可讀流

const fs = require('fs');   // 引入fs核心模組

// fs.createReadStream(path, options)
// 返回的是一個可讀流物件
let rs = fs.createReadStream('1.txt', {
    flags: 'r',         // 檔案的讀取操作,預設是'r':讀取
    encoding: 'utf8',   // 設定編碼格式,預設是null, null代表的是buffer
    autoClose: true,    // 讀取完畢後自動關閉
    highWaterMark: 3,   // 預設是讀取64k    64 * 1024位元組
    start: 0,
    end: 3              // 檔案結束位置索引,和正常的擷取slice有所不同,包前又包後(包括自己結束的位置)
});

// 預設情況下,不會將檔案中的內容輸出
// 內部會先建立一個buffer先讀取3位元組

// 1.txt檔案內容為 123456789
複製程式碼

以上程式碼寫了如何建立可讀流,看起來要記那麼多options項,真是頭疼,其實一般情況下,配置項是不用我們寫的,這下大家滿足了吧

知道了如何建立,我們就看看rs這個可讀流物件上有哪些監聽事件啊

監聽data事件

可讀流這種模式它預設情況下是非流動模式(暫停模式),它什麼也不做,就在這等著

大家知道流是基於事件的,所以我們可以去監聽事件,監聽了data事件的話,就可以將非流動模式轉換為流動模式

// 流動模式會瘋狂的觸發data事件,直到讀取完畢
// 根據上面設定的highWaterMark一次讀3個位元組
rs.on('data', data => { // 非流動模式 -> 流動模式
    console.log(data);  // 觸發2次data事件, 分別打出123和4  從0到3共4個(包括末尾)
});

// 題外話:
// 監聽data事件的時候,如果沒有設定編碼格式,data返回的是buffer型別
// so我們可以為data設定encoding為utf8
rs.setEncoding('utf8');     // 等同於options裡的encoding: 'utf8'
複製程式碼

當我們把想要讀取的內容都讀完後,還可以監聽一個end事件,去判斷何時讀完

監聽end事件

rs.on('end', () => {
    console.log('完畢了'); 
});

// 此時除了會列印data事件裡的123, 4之外還會列印 完畢了
// 如下表示:
// 123
// 4
// 完畢了
複製程式碼

除了data和end兩個事件之外,可讀流中還可以監聽到error、open以及close事件,由於用處沒有前兩位大,就委屈一下放在一起寫吧

監聽error/open/close事件

// error
rs.on('error', err => {
    console.log(err);
});
// open
rs.on('open', () => {
    console.log('檔案開啟了');
});
// close
rs.on('close', () => {
    console.log('檔案關閉了');
});

// 根據上面監聽data、end事件,下面列印的內容是
/*
*   檔案開啟了
    123
    4
    end
    檔案關閉了
* */
複製程式碼

各類監聽事件都知道怎麼寫了,最後再看兩個方法,他們是pause和resume,暫停和恢復觸發data

暫停和恢復

// pause
rs.on('data', data => { 
    console.log(data);  // 只會讀取一次就暫停了,此時只讀到了123
    rs.pause();     // 暫停讀取,會暫停data事件觸發
});
// resume
setInterval(() => {
    rs.resume();    // 恢復data事件, 繼續讀取,變為流動模式
                    // 恢復data事件後,還會呼叫rs.pause,要想再繼續觸發,把setTimeout換成setInterval持續觸發
}, 3000);
// 列印如下:
/*
*   檔案開啟了
    123
    4   // 隔了3秒後列印
    end
    檔案關閉了
* */
複製程式碼

說完了可讀流的用法,讓我們再接再厲(不得不)去看下它的兄弟可寫流吧,畢竟對於作為世界第一大群體的程式猿來說,總得有個從入門到精通(放棄)的深層次提升嘛!加了個油的,各位走起。

可寫流

廢話不多說,上來就是幹

建立可寫流

const fs = require('fs');
// fs.createWriteStream(path, options);
const ws = fs.createWriteStream('2.txt', {
    flags: 'w',         // 檔案的操作, 'w'寫入檔案,不存在則建立
    mode: 0o666,
    autoClose: true,
    highWaterMark: 3,   // 預設寫是16k
    encoding: 'utf8'
});
複製程式碼

可寫流就有兩個方法,分別是write和end方法,直接看下如何使用

write方法

// ws.write(chunk, encoding(可選), callback);
// 寫入的chunk資料必須是字串或者buffer
let flag = ws.write('1', 'utf8', () => {});     // 非同步的方法 有返回值

console.log(flag);  // true
flag = ws.write('22', 'utf8', () => {});    
console.log(flag);  // false    超過了highWaterMark的3個位元組,不能再寫了
flag = ws.write('3', 'utf8', () => {}); 
console.log(flag);  // false

// 2.txt -> 寫入了 1223
複製程式碼

flag識別符號表示的並不是是否寫入,而是能否繼續寫,true為可以繼續寫入。但是返回false,也不會丟失,還會寫到檔案內的

接下來再介紹下end方法

end方法

// 可以傳入chunk值
ws.end('完畢');   // 當寫完後 就不能再寫了

// 此時2.txt -> 寫入了 1223完畢
複製程式碼

講完了write和end方法,可寫流還有一個on監聽事件,它可以監聽drain(抽乾)事件

監聽drain事件

// drain方法
// 抽乾方法 當都寫入後觸發drain事件
ws.on('drain', () => {
    console.log('已經抽乾了');
});
複製程式碼

重頭戲來了

  • 前面羅裡吧嗦都在寫如何使用,Api著實讓大家看的昏昏欲睡了。
  • 但是各位觀眾,現在才是最最值得高興的時刻,對於流的操作,我們不僅僅要會用,還應該簡單的去實現一下。
  • 這樣才能滿足我們龐大的求知慾並且get到新技能,老樣子,直接上程式碼,從程式碼中去深入分析一番
  • 如果讀的疲憊了,那就歇歇吧,當一個佛系青年,看空一切也是一種痛的領悟啊

實現可讀流

先來個栗子

// demo.js
const ReadStream = require('./ReadStream'); // 引入實現的可讀流

const rs = new ReadStream('1.txt', {
    flags: 'r',
    // encoding: 'utf8',
    autoClose: true,
    highWaterMark: 3,
    start: 0,
    end: 4
});

rs.on('data', data => {
    console.log(data);
    rs.pause();
});

rs.on('end', () => {
   console.log('end');
});

setTimeout(() => {
    rs.resume();
}, 2000);
複製程式碼

前方高能,開啟敲擊模式,如果還不知道node中的buffer和events的話,千萬別捉急。大家都是一條船上的人,我會在之後的文章裡給大家分享,且先暫且繼續看下去啊!堅持住,兄弟姐妹們!

建立ReadStream類
// ReadStream.js
const fs = require('fs');
const EventEmitter = require('events');  // 需要依賴事件發射
// 這裡用ES6提供的class寫法,大家也一起來看看是怎麼寫的吧

class ReadStream extends EventEmitter {
    constructor(path, options) {    // 需要傳入path和options配置項
        super();    // 繼承
        this.path = path;
        // 參照上面new出的例項,我們開始寫
        this.flags = options.flags || 'r';  // 檔案開啟的操作,預設是'r'讀取
        this.encoding = options.encoding || null;   // 讀取檔案編碼格式,null為buffer型別
        this.autoClose = options.autoClose || true;
        this.highWaterMark = options.highWaterMark || 64 * 1024;  // 預設是讀取64k
        this.start = options.start || 0;
        this.end = options.end;
        
        this.flowing = null;   // null表示非流動模式
        // 要建立一個buffer,這個buffer就是一次要讀多少內容
        // Buffer.alloc(length)  是通過長度來建立buffer,這裡每次讀取建立highWaterMark個
        this.buffer = Buffer.alloc(this.highWaterMark);  
        this.pos = this.start;  // 記錄讀取的位置
        
        this.open();    // 開啟檔案,獲取fd檔案描述符
        
        // 看是否監聽了data事件,如果監聽了,就變成流動模式
        this.on('newListener', (eventName, callback) => {
            if (eventName === 'data') {   // 相當於使用者監聽了data事件
                this.flowing = true;  // 此時監聽了data會瘋狂的觸發
                this.read();    // 監聽了,就去讀,要乾脆,別猶豫
            }
        });
    }
}

module.exports = ReadStream;    // 匯出
複製程式碼

寫到這裡我們已經建立好了ReadStream類,在該類中我們繼承了EventEmitter事件發射的方法

其中我們寫了open和read這兩個方法,從字面意思就明白了,我們的可讀流要想讀檔案,the first就需要先開啟(open),after我們再去讀內容(read)

這就是實習可讀流的主要方法,我們接下來先從open方法寫起

open方法
class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    
    open() {
        // 用法: fs.open(filename,flags,[mode],callback)
        fs.open(this.path, this.flags, (err, fd) => {   // fd為檔案描述符
            // 說實在的我們開啟檔案,主要就是為了獲取fd
            // fd是個從3開始的數字,每開啟一次都會累加,4->5->6...
            if (err) {
                if (this.autoClose) {  // 檔案開啟報錯了,是否自動關閉掉
                    this.destory();    // 銷燬    
                }
                this.emit('error', err);    // 發射error事件
                return;
            }
            this.fd = fd;   // 如果沒有錯,儲存檔案描述符
            this.emit('open');  // 發射open事件
        });
    }
    
    // 這裡用到了一個destory銷燬方法,我們也直接實現了吧
    destory() {
        // 先判斷有沒有fd 有就關閉檔案 觸發close事件
        if (typeof this.fd === 'number') {
            // 用法: fs.close(fd,[callback])
            fs.close(this.fd, () => {
                this.emit('close'); 
            });
            return;
        }
        this.emit('close');
    }
}
複製程式碼

萬事開頭難,我們把第一步開啟檔案搞定了,那麼就剩下讀取了,再接再厲當上王者

read方法
class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    // 監聽data事件的時候,去讀取
    read() {
        console.log(this.fd);   // 直接讀fd為undefined,因為open事件是非同步的,此時還拿不到fd
        // 此時檔案還沒開啟
        if (typeof this.fd !== 'number') {  // 前面說過fd是個數字
            // 當檔案真正開啟的時候,會觸發open事件
            // 觸發事件後再執行read方法,此時fd肯定有了
            return this.once('open', () => this.read());  // once方法只會執行一次
        }
        // 現在有fd了,大聲的讀出來,不要害羞
        // 用法: fs.read(fd, buffer, offset, length, pos, callback((err, bytesRead)))
        
        // length就是一次想讀幾個, 不能大於buffer長度
        // 這裡length不能等於highWaterMark,舉個?
        // 檔案內容是12345如果按照highWaterMark:3來讀,總共讀end:4個,每次讀3個位元組
        // 分別是123 45空,我們應該知道一共要讀幾個,總數-讀取位置+1得到下一次要讀多少個
        // 這裡有點繞,大家可以多去試試體會一下
        // 我們根據原始碼起一個同樣的名字
        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, bytesRead) => {
            // bytesRead為讀取到的個數,每次讀3個,bytesRead就是3
            if (bytesRead > 0) {
                this.pos += bytesRead; // 讀到了多少個,累加,下次從該位置繼續讀
                
                let buf = this.buffer.slice(0, bytesRead);  // 擷取buffer對應的值
                // 其實正常情況下,我們只要把buf當成data傳過去即可了
                // 但是考慮到還有編碼的問題,所以有可能不是buffer型別的編碼
                // 這裡需要判斷一下是否有encoding
                let data = this.encoding ? buf.toString(this.encoding) : buf.toString(); 
                
                this.emit('data', data);    // 發射data事件,並把data傳過去
                
                // 如果讀取的位置 大於 結束位置 就代表讀完了,觸發一個end事件
                if (this.pos > this.end) {
                    this.emit('end');
                    this.destory();
                }
                // 流動模式繼續觸發
                if (this.flowing) {   
                    this.read();
                }
            } else {    // 如果bytesRead沒有值了就說明讀完了
                this.emit('end');   // 發射end事件,表示檔案讀完
                this.destory();     // 沒有價值了,kill
            }
        });
    }
}
複製程式碼

以上就是read方法的主要實現了,其實思路上並不是很難,去除掉註釋的話也會更精簡些。大家上面也瞭解了可讀流的用法,知道它還有兩個方法,那就是pause(暫停)和resumt(恢復),那麼我們擇日不如撞日直接寫完吧,簡單到令人髮指的實現,看看不會吃虧的,哈哈

pause和resume方法
class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    pause() {
        this.flowing = false;
    }
    resume() {
        this.flowing = true;
        this.read();
    }
}
複製程式碼

完事,就是這麼so easy,我們實現了自己的可讀流了,可喜可賀,可喜可賀。

實現可寫流

先看下測試資料

let WriteStream = require('./WriteStream'); // 引入我們實現的可寫流

let ws = new WriteStream('3.txt', {
    flags: 'w',
    highWaterMark: 3,
    autoClose: true,
    encoding: 'utf8',
    mode: 0o666,
    start: 0
});

// ws.write('你d好', 'utf8', () => {});

let i = 9;

function write() {
    let flag = true;
    while (i >= 0 && flag) {
        flag = ws.write(--i + '', 'utf8', () => {});
        console.log(flag);
    }
}

write();
// drain只有當快取區充滿後 並且被消費後出發
ws.on('drain', () => {
    console.log('抽乾');
    write();
});
複製程式碼

可寫流的實現前面部分和可讀流基本一致,不過可寫流是有drain(抽乾)事件的,所以在編寫的時候也會對這一點進行處理的

建立可寫流
let fs = require('fs');
let EventEmitter = require('events');   // 需要事件發射

// 繼承事件發射EventEmitter
class WriteStream extends EventEmitter {
    constructor(path, options) {
        super();    // 繼承
        this.path = path;
        this.highWaterMark = options.highWaterMark || 16 * 1024;    // 預設一次寫入16k
        this.autoClose = options.autoClose || true;
        this.encoding = options.encoding || null;
        this.mode = options.mode;
        this.start = options.start || 0;
        this.flags = options.flags || 'w';  // 預設'w'為寫入操作
        
        this.buffers = [];
        this.writing = false;   // 標識 是否正在寫入
        this.needDrain = false;     // 是否滿足觸發drain事件
        this.pos = 0;   // 記錄寫入的位置
        this.length = 0;
        
        this.open();    // 首先還是開啟檔案獲取到fd檔案描述符
    }
}

module.exports = WriteStream;
複製程式碼
  • 可寫流要有一個快取區,當正在寫入檔案時,內容要寫入到快取區裡,在原始碼中是一個連結串列 => 我們就直接用個[]來實現,這就是this.buffers的作用

  • 再有就是用buffers計算的話,每增加一項都需要遍歷一遍,維護起來效能太高了,所以用this.length來記錄快取區的大小

下面我們直接寫open方法開啟檔案拿到fd檔案描述符

open方法
class WriteStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
        this.open();
    }
    
    open() {
        // 用法: fs.open(filename,flags,[mode],callback)
        fs.open(this.path, this.flags, this.mode, (err, fd) => {
            if (err) {
                this.emit('error', err);
                // 看一下是否會自動關閉
                if (this.autoClose) {
                    this.destory();     // 銷燬
                }
                return;
            }
            this.fd = fd;
            this.emit('open');  // 觸發open事件,表示當前檔案開啟了
        });
    }
    
    destory() {
        if (typeof this.fd !== 'number') {  // 如果不是fd的話直接返回一個close事件
            return this.emit('close');
        }
        fs.close(this.fd, () => {
            this.emit('close');
        });
    }
}
複製程式碼

通過open方法獲取到了fd檔案描述符後,對於流來說就成功了一半。下面乘勝追擊,直搗黃龍完成可寫流的兩個方法吧!!!

write和end方法
class WriteStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    // 用法:ws.write(chunk,[encoding],[callback])
    write(chunk, encoding = this.encoding, callback) {
        // 通過fs.write()寫入時,chunk需要改成buffer型別
        // 並且要用我們指定的編碼格式去轉換
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
        // chunk.length就是要寫入的長度
        this.length += chunk.length;
        // 比較是否達到了快取區的大小
        let result = this.length < this.highWaterMark;
        this.needDrain = !result;   // 是否需要觸發drain事件
        
        if (this.writing) {
            this.buffers.push({
                chunk,
                encoding,
                callback
            });
        } else {
            this.writing = true;
            this._write(chunk, encoding, () => {
                callback();
                this.clearBuffer();
            });
        }
        
        return result;  // write方法 返回一個布林值
    }
    _write(chunk, encoding, callback) {
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._write(chunk, encoding, callback));
        }
        // fs.write寫入檔案
        // 用法: fs.write(fd, buffer[, offset[, length[, position]]], callback)
        
        fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, bytesWritten) => {
            // this.length記錄快取區大小,寫入後length需要再減掉寫入的個數
            this.length -= bytesWritten;    
            // this.pos下次寫入的位置
            this.pos += bytesWritten;
            this.writing = false;
            callback();     // 清空快取區內容
        });
    }
    
    clearBuffer() {
        let buf = this.buffers.shift(); // 每次把最先放入快取區的取出
        if (buf) {  // 如果有值,接著寫
            this._write(buf.chunk, buf.encoding, () => {
                buf.callback();
                this.clearBuffer(); // 每次寫完都清空一次快取區
            });
        } else {    // 快取區已經空了
            if (this.needDrain) {   // 是否需要觸發drain 需要就發射drain事件
                this.needDrain = false;
                this.emit('drain');
            }
        }
    }
    
    end() {
        if (this.autoClose) {
            this.emit('end');
            this.destory();
        }
    }
}
複製程式碼
  • 以上就完成了可寫流的實現了,各位可能會有一些疑惑,在此我先把普遍的疑惑說一下吧
  • write方法裡的條件判斷先解釋一下
    • if條件
      • 如果是正在寫入,就先把內容放到快取區裡,就是this.buffers裡
      • 給陣列裡存入一個物件,分別對應chunk, encoding, callback
      • 這樣方便在清空快取區的時候取快取區裡對應的內容
    • else條件
      • 專門用來將內容,寫入到檔案內
      • 每一次寫完後都需要把buffers(快取區)裡的內容清空掉
      • 當快取區buffers陣列裡是空的時候就會觸發drain事件了
  • _write方法裡typeof那裡的判斷來說明一下
    • 判斷是否有fd檔案描述符,只有在開啟檔案成功的時候才會有fd
    • 所以如果沒有的話,需要觸發一次open事件,拿到fd都再調_write方法
  • end方法就比較簡單了
    • 判斷是否會自動關閉,發射end事件並銷燬即可了

寫在最後

終於都搞定了,其實說實話,這些基於Api的東西說起來還是很讓人枯燥無聊的,大家都是拒絕無聊主義者。但我還是堅持寫下來了,也是想讓大家和我一起去感受一下大師們是怎麼實現怎麼思考的過程

謝謝大家的觀看了,能堅持下來的都不是折翼的天使啊!

相關文章