在 JavaScript 中,一般只處理字符串層面的數據,但是在 Node.js 中,需要處理網絡、文件等二進制數據。
由此,引入了Buffer和Stream的概念,兩者都是字節層面的操作。
Buffer 表示一塊專門存放二進制數據的緩衝區。Stream 表示流,一種有序、有起點和終點的二進制傳輸手段。
Stream 會從 Buffer 中讀取數據,像水在管道中流動那樣轉移數據。
本系列所有的示例源碼都已上傳至Github,點擊此處獲取。
一、Buffer
Buffer 是 JavaScript 中的 Uint8Array 的子類,Uint8Array 是一種類型化數組,處理 8 位無符號整數。
其行爲類似於數組(有 length 屬性,可迭代等),但並不是真正的數組,其元素是 16 進制的兩位數。
Buffer 在創建時就會確定佔用內存的大小,之後就無法再調整,並且它會被分配一塊 V8 堆棧外的原始內存。
Buffer 的應用場景比較多,例如在zlib模塊中,利用 Buffer 來操作二進制數據實現資源壓縮的功能;在crypto模塊的一些加密算法,也會使用 Buffer。
1)創建
在 Node 版本 <= 6 時,創建 Buffer 實例是 通過構造函數創建的:new Buffer(),但後面的版本就廢棄了。
現在常用的創建方法有:
- Buffer.from() :傳入已有數據,轉換成一個 Buffer 實例,數據可以是字符串、對象、數組等。
- Buffer.alloc():分配指定字節數量的 Buffer 實例。
- Buffer.allocUnsafe() :功能與 Buffer.alloc() 相同,但其所佔內存中的舊數據不會被清除,可能會泄漏敏感數據。
2)編碼
在創建一個 Buffer 實例後,就可以像數組那樣訪問某個字符,而打印出的值是數字,如下所示,這些數字是 Unicode 碼。
let buf = Buffer.from('strick') console.log(buf[0]); // 115 console.log(buf[1]); // 116
若在創建時包含中文字符,那麼就會多 3 個 16 進制的兩位數,如下所示。
let buf = Buffer.from('strick') console.log(buf); // <Buffer 73 74 72 69 63 6b> buf = Buffer.from('strick平') console.log(buf); // <Buffer 73 74 72 69 63 6b e5 b9 b3>
Buffer.from() 的第二個參數是編碼,默認值是 utf8,而 1 箇中文字符經過 UTF-8 編碼後通常會佔用 3 個字節,1 個英文字符只佔用 1 個字節。
在調用 toString() 方法後就能根據指定編碼(不傳默認是 UTF-8)將 Buffer 解碼爲字符串。
console.log(buf.toString()); // strick平
Node.js 支持的其他編碼包括 latin1、base64、ascii 等,具體可參考官方文檔。
3)內存分配原理
Node.js 內存分配都是在 C++ 層面完成的,採用 Slab 分配器(Linux 中有廣泛應用)動態分配內存,並且以 8KB 爲界限來區分是小對象還是大對象(參考自深入淺出Node.js)。
可以簡單看下Buffer.from()的源碼,當它的參數是字符串時,其內部會調用 fromStringFast() 函數(在src/lib/buffer.js中),然後根據字節長度分別處理。
如果當前所佔內存不夠,那麼就會調用 createPool() 擴容,通過調用 createUnsafeBuffer() 創建 Buffer,其中 FastBuffer 繼承自 Uint8Array。
// 以 8KB 爲界限 Buffer.poolSize = 8 * 1024; // Buffer.from() 內會調用此函數 function fromStringFast(string, ops) { const length = ops.byteLength(string); // 長度大於 4KB(>>> 表示無符號右移 1 位) if (length >= (Buffer.poolSize >>> 1)) return createFromString(string, ops.encodingVal); // 當前所佔內存不夠(poolOffset 記錄已經使用的字節數) if (length > (poolSize - poolOffset)) createPool(); let b = new FastBuffer(allocPool, poolOffset, length); const actual = ops.write(b, string, 0, length); if (actual !== length) { // byteLength() may overestimate. That's a rare case, though. b = new FastBuffer(allocPool, poolOffset, actual); } poolOffset += actual; alignPool(); return b; } // 初始化一個 8 KB 的內存空間 function createPool() { poolSize = Buffer.poolSize; allocPool = createUnsafeBuffer(poolSize).buffer; markAsUntransferable(allocPool); poolOffset = 0; } // 創建 Buffer function createUnsafeBuffer(size) { zeroFill[0] = 0; try { return new FastBuffer(size); } finally { zeroFill[0] = 1; } } // FastBuffer 繼承自 Uint8Array class FastBuffer extends Uint8Array {}
二、流
流(Stream)的概念最早見於 Unix 系統,是一種已被證實有效的編程方式。
Node.js 內置的流模塊會被其他多個核心模塊所依賴,它具有可讀、可寫或可讀寫的特點,並且所有的流都是 EventEmitter 的實例,也就是說被賦予了異步的能力。
官方總結了流的兩個優點,分別是:
- 內存效率: 無需加載大量的數據到內存中即可進行處理。
- 時間效率: 當獲得數據之後就能立即開始處理數據,而不必等到整個數據加載完,這樣消耗的時間就變少了。
1)流類型
流的基本類型有4種:
- Readable:只能讀取數據的流,例如 fs.createReadStream(),可註冊的事件包括 data、end、error、close等。
- Writable:只能寫入數據的流,例如 fs.createWriteStream(),HTTP 的請求和響應,可註冊的事件包括 drain、error、finish、pipe 等。
- Duplex:Readable 和 Writable 都支持的全雙工流,例如 net.Socket,這種流會維持兩個緩衝區,分別對應讀取和寫入,允許兩邊同時獨立操作。
- Transform:在寫入和讀取數據時修改或轉換數據的 Duplex 流,例如 zlib.createDeflate()。
來看一個官方的 Readable 流示例,先是用 fs.readFile() 直接將整個文件讀到內存中。當文件很大或併發量很高時,將消耗大量的內存。
const http = require('http') const fs = require('fs') http.createServer(function(req, res) { fs.readFile(__dirname + '/data.txt', (err, data) => { res.end(data) }) }).listen(1234)
再用 fs.createReadStream() 方法通過流的方式來讀取文件,其中 req 和 res 兩個參數也是流對象。
data.txt 文件中的內容將會一段段的傳輸給 HTTP 客戶端,而不是等到讀取完了再一次性響應,兩者對比,高下立判。
http.createServer((req, res) => { const readable = fs.createReadStream(__dirname + '/data.txt') readable.pipe(res); }).listen(1234)
2)pipe()
在上面的示例中,pipe() 方法的作用是將一個可讀流 readable 變量中的數據傳輸到一個可寫流 res 變量(也叫目標流)中。
pipe() 方法地主要目的是平衡讀取和寫入的速度,讓數據的流動達到一個可接受的水平,防止因爲讀寫速度的差異,而導致內存被佔滿。
在 pipe() 函數內部會監聽可讀流的 data 事件,並且會自動調用可寫流的 end() 方法。
當內部緩衝大於配置的最高水位線(highWaterMark)時,也就是讀取速度大於寫入速度時,爲了避免產生背壓問題,Node.js 就會停止數據流動。
當再次重啓流動時,會觸發 drain 事件,其具體實現可參考此文。
pipe() 方法會返回目標流,雖然支持鏈式調用,但必須是 Duplex 或 Transform 流,否則會報錯,如下所示。
http.createServer((req, res) => { const readable = fs.createReadStream(__dirname + '/data.txt') const writable = fs.createWriteStream(__dirname + '/tmp.txt') // Error [ERR_STREAM_CANNOT_PIPE]: Cannot pipe, not readable readable.pipe(writable).pipe(res); }).listen(1234)
3)end()
很多時候寫入流是不需要手動調用 end() 方法來關閉的。但如果在讀取期間發生錯誤,那就不能關閉寫入流,發生內存泄漏。
爲了防止這種情況發生,可監聽可讀流的錯誤事件,手動關閉,如下所示。
readable.on('error', function(err) { writeable.close(); });
接下來看一種網絡場景,改造一下之前的示例,讓可讀流監聽 data、end 和 error 事件,當讀取完畢或出現錯誤時關閉可寫流。
http.createServer((req, res) => { const readable = fs.createReadStream(__dirname + '/data.txt') readable.on('data', chunk => { res.write(chunk); }); readable.on('end',() => { res.end(); }) readable.on('error', err => { res.end('File not found'); }); }).listen(1234)
若不手動關閉,那麼頁面將一直處於加載中,在KOA源碼中,多處調用了此方法。
注意,若取消對 data 事件的監聽,那麼頁面也會一直處於加載中,因爲流一開始是靜止的,只有在註冊 data 事件後纔會開始活動。
4)大JSON文件
網上看到的一道題,用 Node.js 處理一個很大的 JSON 文件,並且要讀取到 JSON 文件的某個字段。
直接用 fs.readFile() 或 require() 讀取都會佔用很大的內存,甚至超出電腦內存。
直接用 fs.createReadStream() 也不行,讀到的數據不能格式化成 JSON 對象,難以讀取字段。
CNode論壇上對此問題也做過專門的討論。
藉助開源庫JSONStream可以實現要求,它基於jsonparse,這是一個流式 JSON 解析器。
JSONStream 的源碼去掉註釋和空行差不多 200 行左右,在此就不展開分析了。
參考資料: