認識node核心模組--從Buffer、Stream到fs

莫凡_Tcg發表於2017-11-12

[原文地址][1]在我的部落格

node中的Buffer和Stream會給剛接觸Node的前端工程師們帶來困惑,原因是前端並沒有類似概念(or 有我們也沒意識到)。然而,在後端,在node中,Buffer和Stream處處體現。Buffer是緩衝區的意思,Stream是流的意思。在計算機中,緩衝區是儲存中間變數,方便CPU讀取資料的一塊儲存區域;流是類比水流形容資料的流動。Buffer和Stream一般都是位元組級操作。本文將介紹這兩個模組的具體細節後再介紹檔案模組,以讓讀者有更清晰的認識。

正文

二進位制緩衝區Buffer

在前端,我們只需做字串級別的操作,很少接觸位元組、進位制等底層操作,一方面這足以滿足日常需求,另一方面Javascript這種應用層語言並不是幹這個的;然而在後端,處理檔案、網路協議、圖片、視訊等時是非常常見的,尤其像檔案、網路流等操作處理的都是二進位制資料。為了讓javascript能夠處理二進位制資料,node封裝了一個Buffer類,主要用於操作位元組,處理二進位制資料。

// 建立一個長度為 10、且用 30 填充的 Buffer。
const buf1 = Buffer.alloc(10, 30)
console.log(buf1)// <Buffer 1e 1e 1e 1e 1e 1e 1e 1e 1e 1e>
// 字串轉Buffer
const buf2 = Buffer.from('javascript')
console.log(buf2)// <Buffer 6a 61 76 61 73 63 72 69 70 74>
// 字串轉 buffer
console.log(buf2.toString())// javascript
console.log(buf2.toString('hex')) //6a617661736372697074複製程式碼

一個 Buffer 類似於一個整數陣列,可以取下標,有length屬性,有剪下複製操作等,很多API也類似陣列,但Buffer的大小在被建立時確定,且無法調整。Buffer處理的是位元組,兩位十六進位制,因此在整數範圍就是0~255。

可以看到,Buffer可以與string互相轉化,還可以設定字符集編碼。Buffer用來處理檔案I/O、網路I/O傳輸的二進位制資料,string用來呈現。在處理檔案I/O、網路I/O傳輸的二進位制資料時,應該儘量以Buffer形式直接傳輸,速度會得到很好的提升,但操作字串比操作Buffer還是快很多的。

Buffer記憶體分配與效能優化

Buffer是一個典型的javascript與C++結合的模組,與效能有關的用C++來實現,javascript 負責銜接和提供介面。Buffer所佔的記憶體不是V8分配的,是獨立於V8堆記憶體之外的記憶體,通過C++層面實現記憶體申請、javascript 分配記憶體。值得一提的是,每當我們使用Buffer.alloc(size)請求一個Buffer記憶體時,Buffer會以8KB為界限來判斷分配的是大物件還是小物件,小物件存入剩餘記憶體池,不夠再申請一個8KB的記憶體池;大物件直接採用C++層面申請的記憶體。因此,對於一個大尺寸物件,申請一個大記憶體比申請眾多小記憶體池快很多。

流Stream

前面講到,流類比水流形容資料的流動,在檔案I/O、網路I/O中資料的傳輸都可以稱之為流,流是能統一描述所有常見輸入輸出型別的模型,是順序讀寫位元組序列的抽象表示。資料從A端流向B端與從B端流向A端是不一樣的,因此,流是有方向的。A端輸入資料到B端,對B就是輸入流,得到的物件就是可讀流;對A就是輸出端、得到的物件是可寫流。有的流即可以讀又可以寫,如TCP連線,Socket連線等,稱為讀寫流(Duplex)。還有一種在讀寫過程中可以修改和變換資料的讀寫流稱為Transform流。

在node中,這些流中的資料就是Buffer物件,可讀、可寫流會將資料儲存到內部的快取中,等待被消費;DuplexTransform 則是都維護了兩個相互獨立的快取用於讀和寫。 在維持了合理高效的資料流的同時,也使得對於讀和寫可以獨立進行而互不影響。

在node中,這四種流都是EventEmitter的例項,它們都有close、error事件,可讀流具有監聽資料到來的data事件等,可寫流則具有監聽資料已傳給低層系統的finish事件等,DuplexTransform 都同時實現了 ReadableWritable 的事件和介面 。

值得一提的是writable的drain事件,這個事件表示快取的資料被排空了。為什麼有這個事件呢?起因是呼叫可寫流的write和可讀流的read都會有一個快取區用來快取寫/讀的資料,快取區是有大小的,一旦寫的內容超過這個大小,write方法就會返回false,表示寫入停止,這時如果繼續read完快取區資料,快取區被排空,就會觸發drain事件,可以這樣來防止快取區爆倉:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    if (ws.write(chunk) === false) {
        rs.pause();
    }
});

rs.on('end', function () {
    ws.end();
});

ws.on('drain', function () {
    rs.resume();
});複製程式碼

一些常見流分類:

  • 可寫流: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
  • 可讀流:HTTP responses, on the client、HTTP requests, on the server、fs read streams、zlib streams、crypto streams、TCP sockets、child process stdout and stderr、process.stdin
  • 可讀可寫流:TCP sockets、zlib streams、crypto streams
  • 變換流:zlib streams、crypto streams

另外,提到流就不得不提到管道的概念,這個概念也非常形象:水流從一端到另一端流動需要管道作為通道或媒介。流也是這樣,資料在端之間的傳送也需要管道,在node中是這樣的:

// 將 readable 中的所有資料通過管道傳遞給名為 file.txt 的檔案
const readable = getReadableStreamSomehow();
const writable = getWritableStreamSomehow('file.txt');
// readable 中的所有資料都傳給了 'file.txt'
readable.pipe(writable);

// 對流進行鏈式地管道操作
const r = fs.createReadStream('file.txt');
const z = zlib.createGzip();
const w = fs.createWriteStream('file.txt.gz');
r.pipe(z).pipe(w);複製程式碼

注意,只有可讀流才具有pipe能力,可寫流作為目的地。

pipe不僅可以作為通道,還能很好的控制管道里的流,控制讀和寫的平衡,不讓任一方過度操作。另外,pipe可以監聽可讀流的data、end事件,這樣就可以構建快速的響應:

// 一個檔案下載的例子,使用回撥函式的話需要等到伺服器讀取完檔案才能向瀏覽器傳送資料
var http = require('http') ;
var fs = require('fs') ;
var server = http.createServer(function (req, res) {
    fs.readFile(__dirname + '/data.txt', function (err, data) {
        res.end(data);
    }) ;
}) ;
server.listen(8888) ;

// 而採用流的方式,只要建立連線,就會接受到資料,不用等到伺服器快取完data.txt
var http = require('http') 
var fs = require('fs') 
var server = http.createServer(function (req, res) {
    var stream = fs.createReadStream(__dirname + '/data.txt') 
    stream.pipe(res) 
}) 
server.listen(8888)複製程式碼

因此,使用pipe即可解決上面那個爆倉問題。

fs檔案模組

fs檔案模組是高階模組,繼承了EventEmitter、stream、path等底層模組,提供了對檔案的操作,包括檔案的讀取、寫入、更名、刪除、遍歷目錄、連結POSIX檔案系統等操作。與node設計思想和其他模組不同的是,fs模組中的所有操作都提供了非同步和同步兩個版本。fs模組主要由下面幾部分組成:

  • 對底層POSIX檔案系統的封裝,對應於作業系統的原生檔案操作
  • 繼承Stream的檔案流 fs.createReadStream和fs.createWriteStream
  • 同步檔案操作方法,如fs.readFileSync、fs.writeFileSync
  • 非同步檔案操作方法, fs.readFile和fs.writeFile

模組API架構如下:

fs主要操作
fs主要操作

讀寫操作:

const fs = require('fs'); // 引入fs模組
/* 讀檔案 */

// 使用流
const read = fs.createReadStream('sam.js',{encoding:'utf8'});
read.on('data',(str)=>{
    console.log(str);
})
// 使用readFile
fs.readFile('test.txt', {}, function(err, data) {
    if (err) {
        throw err;
    }
    console.log(data);
});
// open + read
fs.open('test.txt','r',(err, fd) => {
    fs.fstat(fd,(err,stat)=>{
        var len = stat.size;  //檢測檔案長度
        var buf = new Buffer(len);
        fs.read(fd,buf,0,len,0,(err,bw,buf)=>{
            console.log(buf.toString('utf8'));
            fs.close(fd);
        })
    });
});

/* 寫檔案與讀取檔案API形式類似 */複製程式碼

讀/寫檔案都有三種方式,那麼區別是什麼呢?

  • createReadStream/createWriteStream建立一個將檔案內容讀取為流資料的ReadStream物件,這個方法主要目的就是把資料讀入到流中,得到是可讀流,方便以流進行操作
  • readFile/writeFile:Node.js會將檔案內容視為一個整體,為其分配快取區並且一次性將檔案內容讀/寫取到快取區中,在這個期間,Node.js將不能執行任何其他處理,所以當讀寫大檔案的時候,有可能造成快取區“爆倉”
  • read/write讀/寫檔案內容是不斷地將檔案中的一小塊內容讀/寫入快取區,最後從該快取區中讀取檔案內容

同步API也是如此。其中最常用的是readFile,讀取大檔案則採取用,read則提供更為細節、底層的操作,而且read要配合open。

獲取檔案的狀態:

fs.stat('eda.txt', (err, stat) => {
  if (err)
    throw err
  console.log(stat)
})
/* 
Stats {
  dev: 16777220,
  mode: 33279,
  nlink: 1,
  uid: 501,
  gid: 20,
  rdev: 0,
  blksize: 4194304,
  ino: 4298136825,
  size: 0,
  blocks: 0,
  atimeMs: 1510317983760.94, - 檔案資料最近被訪問的時間
  mtimeMs: 1510317983760.94, - 檔案資料最近被修改的時間。
  ctimeMs: 1510317983777.8538, - 檔案狀態最近更改的時間
  birthtimeMs: 1509537398000,
  atime: 2017-11-10T12:46:23.761Z,
  mtime: 2017-11-10T12:46:23.761Z,
  ctime: 2017-11-10T12:46:23.778Z,
  birthtime: 2017-11-01T11:56:38.000Z 
}*/複製程式碼

監聽檔案:

const FSWatcher = fs.watch('eda.txt', (eventType, filename) => {
    console.log(`${eventType}`)
})
FSWatcher.on('change', (eventType, filename) => {
    console.log(`${filename}`)
})
// watch和返回的FSWatcher例項的回撥函式都繫結在了 change 事件上

fs.watchFile('message.text', (curr, prev) => {
  console.log(`the current mtime is: ${curr.mtime}`);
  console.log(`the previous mtime was: ${prev.mtime}`);
})複製程式碼

監聽檔案仍然有兩種方法:

  • watch 呼叫的是底層的API來監視檔案,很快,可靠性也較高
  • watchFile 是通過不斷輪詢 fs.Stat (檔案的統計資料)來獲取被監視檔案的變化,較慢,可靠性較低,另外回撥函式的引數是 fs.Stat 例項

因此儘可能多的使用watch,watchFile 用於需要得到檔案更多資訊的場景。

其他

建立、刪除、複製、移動、重新命名、檢查檔案、修改許可權...

總結

由Buffer到Stream,再到fs檔案模組,將它們串聯起來能對整塊知識有更清晰的認識,也對webpack、gulp等前端自動化工具構建工作流的機制和實現有了更深的瞭解。學習其他知識亦是如此——知道來龍去脈,知道為什麼會存在,知道它們之間的聯絡,就能讓碎片化的知識串聯起來,能讓它們make sense,能夠讓自己“上的廳堂、下得廚房”。

參考:

nodeJs高階模組--fs

deep into node

相關文章