認識二進制數據

二進制是計算技術中廣泛採用的一種數制。二進制數據是用0和1兩個數碼來表示的數。它的基數爲2,進位規則是“逢二進一”,借位規則是“借一當二”,由18世紀德國數理哲學大師 萊布尼茲

發現。

—— 百度百科

二進制數據就像上圖一樣,由0和1來存儲數據。普通的十進制數轉化成二進制數一般採用"除2取餘,逆序排列"法,用2整除十進制整數,可以得到一個商和餘數;再用2去除商,又會得到一個商和餘數,如此進行,直到商爲小於1時爲止,然後把先得到的餘數作爲二進制數的低位有效位,後得到的餘數作爲二進制數的高位有效位,依次排列起來。例如,數字10轉成二進制就是 1010 ,那麼數字10在計算機中就以 1010 的形式存儲。

而字母和一些符號則需要通過 ASCII 碼來對應,例如,字母a對應的 ACSII 碼是 97,二進制表示就是 0110 0001 。JavaScript 中可以使用 charCodeAt 方法獲取字符對應的 ASCII:

除了ASCII外,還有一些其他的編碼方式來映射不同字符,比如我們使用的漢字,通過 JavaScript 的 charCodeAt 方法得到的是其 UTF-16 的編碼。

Node 處理二進制數據

JavaScript 在誕生初期主要用於表單信息的處理,所以 JavaScript 天生擅長對字符串進行處理,可以看到 String 的原型提供特別多便利的字符串操作方式。

但是,在服務端如果只能操作字符是遠遠不夠的,特別是網絡和文件的一些 IO 操作上,還需要支持二進制數據流的操作,而 Node.js 的 Buffer 就是爲了支持這些而存在的。好在 ES6 發佈後,引入了 類型數組 (TypedArray)的概念,又逐步補充了二進制數據處理的能力,現在在 Node.js 中也可以直接使用,但是在 Node.js 中,還是 Buffer 更加適合二進制數據的處理,而且擁有更優的性能,當然 Buffer 也可以直接看做 TypedArray 中的 Uint8Array 。除了 Buffer,Node.js 中還提供了 stream 接口,主要用於處理大文件的 IO 操作,相對於將文件分批分片進行處理。

認識 Buffer

Buffer 直譯成中文是『緩衝區』的意思,顧名思義,在 Node.js 中實例化的 Buffer 也是專門用來存放二進制數據的緩衝區。一個 Buffer 可以理解成開闢的一塊內存區域,Buffer 的大小就是開闢的內存區域的大小。下面來看看Buffer 的基本使用方法。

API 簡介

早期的 Buffer 通過構造函數進行創建,通過不同的參數分配不同的 Buffer。

new Buffer(size)

創建大小爲 size(number) 的 Buffer。

new Buffer(5)
// <Buffer 00 00 00 00 00>

new Buffer(array)

使用八位字節數組 array 分配一個新的 Buffer。

const buf = new Buffer([0x74, 0x65, 0x73, 0x74])
// <Buffer 74 65 73 74>
// 對應 ASCII 碼,這幾個16進制數分別對應 t e s t

// 將 Buffer 實例轉爲字符串得到如下結果
buf.toString() // 'test'

new Buffer(buffer)

拷貝 buffer 的數據到新建的 Buffer 實例。

const buf1 = new Buffer('test')
const buf2 = new Buffer(buf1)

new Buffer(string[, encoding])

創建內容爲 string 的 Buffer,指定編碼方式爲 encoding。

const buf = new Buffer('test')
// <Buffer 74 65 73 74>
// 可以看到結果與 new Buffer([0x74, 0x65, 0x73, 0x74]) 一致

buf.toString() // 'test'

更安全的 Buffer

由於 Buffer 實例因第一個參數類型而執行不同的結果,如果開發者不對參數進行校驗,很容易導致一些安全問題。例如,我要創建一個內容爲字符串 "20" 的 Buffer,而錯誤的傳入了數字 20 ,結果創建了一個長度爲 20 的Buffer 實例。

可以看到上圖,Node.js 8 之前,爲了高性能的考慮,Buffer 開闢的內存空間並未釋放之前已存在的數據,直接將這個 Buffer 返回可能導致敏感信息的泄露。因此,Buffer 類在 Node.js 8 前後有一次大調整,不再推薦使用 Buffer 構造函數實例 Buffer,而是改用 Buffer.from()Buffer.alloc()Buffer.allocUnsafe() 來替代 new Buffer()

Buffer.from()

該方法用於替代 new Buffer(string)new Buffer(array)new Buffer(buffer)

Buffer.alloc(size[, fill[, encoding]])

該方法用於替代 new Buffer(size) ,其創建的 Buffer 實例默認會使用 0 填充內存,也就是會將內存之前的數據全部覆蓋掉,比之前的 new Buffer(size) 更加安全,因爲要覆蓋之前的內存空間,也意味着更低的性能。

同時,size 參數如果不是一個數字,會拋出 TypeError。

Buffer.allocUnsafe(size)

該方法與之前的 new Buffer(size) 保持一致,雖然該方法不安全,但是相比起 alloc 具有明顯的性能優勢。

Buffer 的編碼

前面介紹過二進制數據與字符對應需要指定編碼,同理將字符串轉化爲 Buffer、Buffer 轉化爲字符串都是需要指定編碼的。

Node.js 目前支持的編碼方式如下:

  • hex :將每個字節編碼成兩個十六進制的字符。
  • ascii :僅適用於 7 位 ASCII 數據。此編碼速度很快,如果設置則會剝離高位。
  • utf8 :多字節編碼的 Unicode 字符。許多網頁和其他文檔格式都使用 UTF-8。
  • utf16le :2 或 4 個字節,小端序編碼的 Unicode 字符。
  • ucs2utf16le 的別名。
  • base64 :Base64 編碼。
  • latin1 :一種將 Buffer 編碼成單字節編碼字符串的方法。
  • binarylatin1 的別名。

比較常用的就是 UTF-8UTF-16ASCII ,前面說過 JavaScript 的 charCodeAt 使用的是 UTF-16 編碼方式,或者說 JavaScript 中的字符串都是通過 UTF-16 存儲的,不過 Buffer 默認的編碼是 UTF-8

可以看到一個漢字在 UTF-8 下需要佔用 3 個字節,而 UTF-16 只需要 2 個字節。主要原因是 UTF-8 是一種可變長的字符編碼,大部分字符使用 1 個字節表示更加節省空間,而某些超出一個字節的字符,則需要用到 2 個或 3 個字節表示,大部分漢字在 UTF-8 中都需要用到 3 個字節來表示。 UTF-16 則全部使用 2 個字節來表示,對於一下超出了 2 字節的字符,需要用到 4 個字節表示。 2 個字節表示的 UTF-16 編碼與 Unicode 完全一致,通過 漢字Unicode編碼表 可以找到大部分中文所對應的 Unicode 編碼。前面提到的 『漢』,通過 Unicode 表示爲 6C49

這裏提到的 Unicode 編碼又被稱爲統一碼、萬國碼、單一碼,它爲每種語言都設定了統一且唯一的二進制編碼,而上面說的 UTF-8UTF-16 都是他的一種實現方式。更多關於編碼的細節不再贅述,也不是本文的重點,如果想了解更多可自行搜索。

亂碼的原因

我們經常會出現一些亂碼的情況,就是因爲在字符串與 Buffer 的轉化過程中,使用了不同編碼導致的。

我們先新建一個文本文件,然後通過 utf16 編碼保存,然後通過 Node.js 讀取改文件。

const fs = require('fs')
const buffer = fs.readFileSync('./1.txt')
console.log(buffer.toString())

由於 Buffer 在調用 toString 方法時,默認使用的是 utf8 編碼,所以輸出了亂碼,這裏我們將 toString 的編碼方式改成 utf16 就可以正常輸出了。

const fs = require('fs')
const buffer = fs.readFileSync('./1.txt')
console.log(buffer.toString('utf16le'))

認識 Stream

前面我們說過,在 Node.js 中可以利用 Buffer 來存放一段二進制數據,但是如果這個數據量非常的大使用 Buffer 就會消耗相當大的內存,這個時候就需要用到 Node.js 中的 Stream(流)。要理解流,就必須知道管道的概念。

類Unix 操作系統 (以及一些其他借用了這個設計的操作系統,如Windows)中, 管道 是一系列將 標準輸入輸出 鏈接起來的 進程 ,其中每一個進程的 輸出 被直接作爲下一個進程的 輸入 。 這個概念是由 道格拉斯·麥克羅伊Unix 命令行 發明的,因與物理上的 管道

相似而得名。

-- 摘自維基百科

我們經常在 Linux 命令行使用管道,將一個命令的結果傳輸給另一個命令,例如,用來搜索文件。

ls | grep code

這裏使用 ls 列出當前目錄的文件,然後交由 grep 查找包含 code 關鍵詞的文件。

在前端的構建工具 gulp 中也用到了管道的概念,因爲使用了管道的方式來進行構建,大大簡化了工作流,用戶量一下子就超越了 grunt

// 使用 gulp 編譯 scss
const gulp = require('gulp')
const sass = require('gulp-sass')
const csso = require('gulp-csso')

gulp.task('sass', function () {
  return gulp.src('./**/*.scss')
    .pipe(sass()) // scss 轉 css
    .pipe(csso()) // 壓縮 css
    .pipe(gulp.dest('./css'))
})

前面說了這麼多管道,那管道和流直接應該怎麼聯繫呢。流可以理解爲水流,水要流向哪裏,就是由管道來決定的,如果沒有管道,水也就不能形成水流了,所以流必須要依附管道。在 Node.js 中所有的 IO 操作都可以通過流來完成,因爲 IO 操作的本質就是從一個地方流向另一個地方。例如,一次網絡請求,就是將服務端的數據流向客戶端。

const fs = require('fs')
const http = require('http')

const server = http.createServer((request, response) => {
    // 創建數據流
    const stream = fs.createReadStream('./data.json')
    // 將數據流通過管道傳輸給響應流
    stream.pipe(response)
})

server.listen(8100)
// data.json
{ "name": "data" }

使用 Stream 會一邊讀取 data.json 一邊將數據寫入響應流,而不是像 Buffer 一樣,先將整個 data.json 讀取到內存,然後一次性輸出到響應中,所以使用 Stream 的時候會更加節約內存。

其實 Stream 在內部依然是運作在 Buffer 上。如果我們把一段二進制數據比做一桶水,那麼通過 Buffer 進行文件傳輸就是直接將一桶水倒入到另一個桶裏面,而使用 Stream,就是將桶裏面的水通過管道一點點的抽取過去。

Stream 與 Buffer 內存消耗對比

這裏如果只是口頭說說可能感知不明顯,現在分別通過 Stream 和 Buffer 來複制一個 2G 大小的文件,看看 node 進程的內存消耗。

Stream 複製文件

// Stream 複製文件
const fs = require('fs');
const file = './file.mp4';
fs.createReadStream(file)
  .pipe(fs.createWriteStream('./file.copy.mp4'))
  .on('finish', () => {
      console.log('file successfully copy');
  })

Buffer 複製文件

// Buffer 複製文件
const fs = require('fs');
const file = './file.mp4';
// fs.readFile 直接輸出的是文件 Buffer
fs.readFile(file, (err, buffer) => {
    fs.writeFile('./file.copy.mp4', buffer, (err) => {
        console.log('file successfully copy');
    });
});

通過上圖的結果可以看出,通過 Stream 拷貝時,只佔用了我電腦 0.6% 的內存,而使用 Buffer 時,佔用了 15.3% 的內存。

API 簡介

在 Node.js 中,Steam 一共被分爲五種類型。

  • 可讀流(Readable),可讀取數據的流;
  • 可寫流(Writable),可寫入數據的流;
  • 雙工流(Duplex),可讀又可寫的流;
  • 轉化流(Transform),在讀寫過程中可任意修改和轉換數據的流(也是可讀寫的流);

所有的流都可以通過 .pipe 也就是管道(類似於 linux 中的 | )來進行數據的消費。另外,也可以通過事件來監聽數據的流動。不管是文件的讀寫,還是 http 的請求、響應都會在內部自動創建 Stream,讀取文件時,會創建一個可讀流,輸出文件時,會創建可寫流。

可讀流(Readable)

雖然叫做可讀流,但是可讀流也是可寫的,只是這個寫操作一般是在內部進行的,外部只需要讀取就行了。

可讀流一般分爲兩種模式:

stram.read()

可讀流在創建時,默認爲暫停模式,一旦調用了 .pipe ,或者監聽了 data 事件,就會自動切換到流動模式。

const { Readable } = require('stream')
// 創建可讀流
const readable = new Readable()
// 綁定 data 事件,將模式變爲流動模式
readable.on('data', chunk => {
  console.log('chunk:', chunk.toString()) // 輸出 chunk
})
// 寫入 5 個字母
for (let i = 97; i < 102; i++) {
  const str = String.fromCharCode(i);
  readable.push(str)
}
// 推入 `null` 表示流已經結束
readable.push(null)

const { Readable } = require('stream')
// 創建可讀流
const readable = new Readable()
// 寫入 5 個字母
for (let i = 97; i < 102; i++) {
  const str = String.fromCharCode(i);
  readable.push(str)
}
// 推入 `null` 表示流已經結束
readable.push('\n')
readable.push(null)
// 通過管道將流的數據輸出到控制檯
readable.pipe(process.stdout)

上面的代碼都是手動創建可讀流,然後通過 push 方法往流裏面寫數據的。前面說過,Node.js 中數據的寫入都是內部實現的,下面通過讀取文件的 fs 創建的可讀流來舉例:

const fs = require('fs')
// 創建 data.json 文件的可讀流
const read = fs.createReadStream('./data.json')
// 監聽 data 事件,此時變成流動模式
read.on('data', json => {
  console.log('json:', json.toString())
})

#### 可寫流(Writable)

可寫流對比起可讀流,它是真的只能寫,屬於只進不出的類型,類似於貔貅。

創建可寫流的時候,必須手動實現一個 _write() 方法,因爲前面有下劃線前綴表明這是內部方法,一般不由用戶直接實現,所以該方法都是在 Node.js 內部定義,例如,文件可寫流會在該方法中將傳入的 Buffer 寫入到指定文本中。

寫入如果結束,一般需要調用可寫流的 .end() 方法,表示結束本次寫入,此時還會調用 finish 事件。

const { Writable } = require('stream')
// 創建可寫流
const writable = new Writable()
// 綁定 _write 方法,在控制檯輸出寫入的數據
writable._write = function (chunk) {
  console.log(chunk.toString())
}
// 寫入數據
writable.write('abc')
// 結束寫入
writable.end()

_write 方法也可以在實例可寫流的時候,通過傳入對象的 write 屬性來實現。

const { Writable } = require('stream')
// 創建可寫流
const writable = new Writable({
  // 同,綁定 _write 方法
    write(chunk) {
    console.log(chunk.toString())
  }
})
// 寫入數據
writable.write('abc')
// 結束寫入
writable.end()

下面看看 Node.js 中內部通過 fs 創建的可寫流。

const fs = require('fs')
// 創建可寫流
const writable = fs.createWriteStream('./data.json')

// 寫入數據,與自己手動創建的可寫流一致
writable.write(`{
  "name": "data"
}`)
// 結束寫入
writable.end()

看到這裏就能理解,Node.js 在 http 響應時,需要調用 .end() 方法來結束響應,其實內部就是一個可寫流。現在再回看前面通過 Stream 來複制文件的代碼就更加容易理解了。

const fs = require('fs');
const file = './file.mp4';
fs.createReadStream(file)
  .pipe(fs.createWriteStream('./file.copy.mp4'))
  .on('finish', () => {
      console.log('file successfully copy');
  })

雙工流(Duplex)

雙工流同時實現了 Readable 和 Writable,具體用法可以參照可讀流和可寫流,這裏就不佔用文章篇幅了。

管道串聯

前面介紹了通過管道( .pipe() )可以將一個桶裏的數據轉移到另一個桶裏,但是有多個桶的時候,我們就需要多次調用 .pipe() 。例如,我們有一個文件,需要經過 gzip 壓縮後重新輸出。

const fs = require('fs')
const zlib = require('zlib')

const gzip = zlib.createGzip() // gzip 爲一個雙工流,可讀可寫
const input = fs.createReadStream('./data.json')
const output = fs.createWriteStream('./data.json.gz')

input.pipe(gzip) // 文件壓縮
gzip.pipe(output) // 壓縮後輸出

面對這種情況,Node.js 提供了 pipeline() api,可以一次性完成多個管道操作,而且還支持錯誤處理。

const { pipeline } = require('stream')
const fs = require('fs')
const zlib = require('zlib')

const gzip = zlib.createGzip()
const input = fs.createReadStream('./data.json')
const output = fs.createWriteStream('./data.json.gz')

pipeline(
  input,   // 輸入
  gzip,    // 壓縮
  output,  // 輸出
  // 最後一個參數爲回調函數,用於錯誤捕獲
  (err) => {
    if (err) {
      console.error('壓縮失敗', err)
    } else {
      console.log('壓縮成功')
    }
  }
)

參考

相關文章