使用 JavaScript 處理二進位資料


Posted by TechBridge 技術週刊 on 2017-09-24

由於高度封裝與抽象,JavaScript 的執行效率比不上 C 的語言。例如 JavaScript 的 Array 下標(subscript)是根據 hash key 而非實體記憶體位址 offset 取值,雖然方便,卻多了效能開銷。當 Canvas、WebGL、WebVR 開始走紅,效能越來越受重視,如何讓 JavaScript 達到如同 C 指標般操作 binary data 變得至關重要。

存在許久但最近才變為 ES6 標準「Typed Array」就是解放 JavaScript 操作 binary data 能力的好工具!一起來了解 Typed Array 吧!

(基於 ECMAScript 6+,Node.js 8.3)

Buffer v.s View

ES6 引入的 Typed Array 家族,可以分為兩大類:BufferView

所謂 Buffer 是一個指向儲存資料的記憶體區塊之物件,類似於 malloc 配置出來的空間,無法直接存取或修改 buffer 內部的資料,在 JavaScript 中 Buffer 的實作就是 ArrayBuffer

如果我們想存取某些 buffer 底下的內容,我們需要 View(視圖),透過宣告不同資料型別的 view,電腦就會了解如何操作這段 data chunk,該當作 float32 讀取呢?抑或以 unsigned integer 來操作。

ES6 規範了三個 Typed Array 相關物件,對應類別如下:

  • ArrayBuffer:Buffer,代表一段記憶體區塊,僅能透過 View 操作其內容。
  • TypedArray:View,儲存固定型別資料的 Array,例如 Uint8Array(8-bit unsigned integer)、Float64Array(64-bit IEEE floating point number)。
  • DataView:View,不限制型別,可自定義從哪個 byte,以什麼型別,用哪種 byte order(endian)存取。

ArrayBuffer

ArrayBuffer 代表一段固定大小的記憶體區塊,也稱為 byte-array。主要的功能就是配置實體記憶體來儲存 raw binary data。一般很少直接操作 ArrayBuffer,實際上也只能將其 reference 傳給其他物件,讓其他物件來處理/使用資料。

建立一個 ArrayBuffer 有非常多種方法,可以直接配置,

// 直接配置 8 bytes,初始值為 0 的記憶體區塊
const buffer = new ArrayBuffer(8)

// 利用 `slice` 將某些 bytes 複製到另一個 ArrayBuffer
// 這裡複製 `buffer` 倒數四個 bytes 到 bufferCopied 中
const bufferCopied = buffer.slice(-4)

或是最常使用的,HTTP response 選擇接收 buffer,

// XMLHttpRequest 指定 resopnseType (XMLHttpRequest v2)
const xhr = new XMLHttpRequest()
xhr.open('GET', '/path/to/黑人問號.jpg', true)
xhr.responseType = 'arraybuffer' // 將 reponse 型別設定為 arraybuffer
xhr.onload = function(e) {
  console.log(this.response) // this.response 為 ArrayBuffer
}
xhr.send()

// Fetch API 也提供 Body#arrayBuffer 的方法轉換 Request/Response stream body
const response = await fetch('/path/to/柯P火影.gif')
const buffer = await response.arrayBuffer() // 取得 ArrayBuffer 實例
console.log(buffer.byteLength) // 查看當前這個 ArrayBuffer 有多少 bytes。

當然,也可以透過 FileFileReader API,讀取使用者上傳的資料。

const input = document.querySelector('input')
input.addEventListener('change', handleFiles, false);

function handleFiles (files) { // files -> FileList 物件,裡面有 File 實例
  if (files[0]) {
    const reader = new FileReader()
    reader.onload = function ({ target: { result } }) {
      console.log(result.byteLength) // result 是一個 ArrayBuffer
    }
    reader.readAsArrayBuffer = files[0] // File 是特殊的 Blob 型別
  }
}

TypedArray

TypedArray 並非任何一個型別,也非全域可取得的建構函數,而是一個抽象概念,對應到許多不同型別的 Array 罷了。老實說,TypedArray 這個的命名已說明一切,讓我來說文解字,先從 Typed 講起。

Types of TypedArray

所謂的 Typed,意指「限定型別」,Array 中的元素都是同一種型別。有哪些型別呢?TypedArray 是為了操作 binary 而生,當然只有最底層以 bytes 為基礎,幾乎沒有什麼抽象概念的型別。我們可根據需求,決定每個元素該從 raw data 讀取多少與如何讀取 bytes。

目前 ES6 定義以下幾種 typed array types:

型別 bytes/元素 對應 C 語言
Int8Array 1 int8_t
Uint8Array 1 uint8_t
Uint8ClampedArray 1 uint8_t
Int16Array 2 int16_t
Uint16Array 2 uint16_t
Int32Array 4 int32_t
Uint32Array 4 uint32_t
Float32Array 4 float
Float64Array 8 double

實際上,TypedArray 本身並並不儲存任何 buffer 資料,只保存該 buffer 的 reference,我們可以透過 TypedArray#buffer 獲取原始的 ArrayBuffer。也因此,同一個 ArrayBuffer 可以建構出多個不同的 TypedArray。可視為「從不同視角解讀 ArrayBuffer 中的 binary data」。

例如下圖是一個 16 bytes 的 ArrayBuffer,我們可以透過它,建立多個不同型別的 TypedArray。

不同型別的 TypedArray 的元素對應到不同的 byte 數量,這項資訊會記錄在 TypedArray#BYTES_PER_ELEMENT property 上。例如:Uint8Array 一個元素對應到一個 byte,Float64Array 則對應到 8 bytes。

如果還是無法理解,其實可以將 TypedArray 想像為 C 語言的 void *ptr,在存取、遍歷不同 data type 的 Array 時,轉型(cast)成不同的型別,讓指標根據不同 data type 的 size 做對應 offset。

Array-like Methods

而 TypedArray 中的 Array,其實就是我們熟悉的 JavaScript Array,可視為「在 ArrayBuffer 的資料之上,架一層可存取資料的 Array API」。你想得到的 method mapfilterreduce 幾乎應有盡有,而 pushshiftunshiftsplice 這類會改變 Array 長度的 Mutator methods 沒有實作,畢竟 TypedArray 就只是 buffer 的 reference,pop 後原始資料依然存在。

比較好玩的是,TypedArray#subarrayTypedArray#slice 同樣是回傳陣列切片,slice 是回傳一個淺拷貝(shallow-copy)的新 Array,新 array 的 buffer property 指向新切出來的 buffer,byteOffset 也是依據新的 buffer,所以會是 0

subarray 則是在同個 buffer 繼續切片,調用 subarray 的 buffer 會取得相同的原始 buffer,byteOffset 也是根據原始 buffer 計算 offset。

Consturct a TypedArray

建構 TypedArray 非常簡單,選擇好 data type 之後,new 一個就完成了!

直接初始化

建立一個 4 * 2 bytes 初始值為 0 的 Uint16Array。

const u16 = new Uint16Array(4)

從 TypedArray 建立

我們也可以從其他 TypedArray 建立相同長度的 TypedArray,會指向同一個 buffer。

const u8 = new Uint8Array(u16) // length of u8 is 4

這樣會建立一個新的型別 array,但記憶體區塊不變。我們的例子中,u8 因為溢位的緣故(overflow),自動過濾偶數 bytes(或奇數,視 endianness 而定),僅顯示餘下 4 個 bytes 的資料,記憶體位址變得不連續。

從 ArrayBuffer 建立

不過,也可以透過 TypedArray#buffer 取得並共享當前的 buffer,該 array 的記憶體區間就會是連續的。

// 因為 buffer 總共有 4 * 2 = 8 bytes,所以 u8_continuous 長度為 8
const u8_continuous = new Uint8Array(u16.buffer)

當然,ArrayBuffer 可直接配置一塊記憶體區塊,並使用它建構 TypedArray,甚至透過 lengthbyteOffset 指定該 buffer 不同的區間來建構。

const buffer = new ArrayBuffer(16)
const i32 = new Int32Array(buffer) // 32 * 2 bytes

// 從 4 bytes offset 的位址開始,切一個長度為 7 bytes 的 array。
const i8 = new Iint8Array(buffer, 4, 7)

Overflow

如同 C 語言,不同類型的 TypedArray 可以容納的 bytes 範圍是固定的,超過此一範圍,就會出現「溢位(Overflow)」,例如 Uint8Array 中僅能放入 1 byte = 8 bits 的資料,如果放入 0x100(256,9 bits),就會溢位。

那溢位後,資料會怎麼呈現呢?

每個語言實作不盡相同,TypedArray 的溢位處理規則和多數語言相同:捨棄溢出的 high bits。 我們來看簡單的例子。

Uint8Array.of(0xff, 0x100)
// Unit8Array [255, 0]

第一個例子中,我們選用 Uint8Array,一個元素最多儲存 8 bits 的資料,第二個元素是 256,需要第 9 bit 來儲存,因此溢位。

// 255,至少需要 8 bits 儲存
0b11111111

// 256,至少需要 9 bits 儲存
0b100000000
//└── 這個 1 溢位,將被捨棄,僅保留最低有效的 8 bits,計算結果為 `0`
// 捨棄的方式同於 bitwise or `& 0xFF`
0x100 & 0xFF
// 0

Note:underflow 的處理方式與 overflow 相同。

What is Uint8ClampedArray

Clamp 的本意是鉗子,在計算機科學中,通常意味將資料值限制在特定範圍間。而 Uint8ClampedArray 中,就是將元素值限制在 0 - 255。換句話說,就是處理溢位的規則與 Uint8Array 不同。當 overflow 時,該值會等於最大值 255;當 underflow 時,該值會等於 0

Uint8Array.of(0xff, 0x100, -100)
// Unit8Array [255, 0, 156]
Uint8ClampedArray.of(0xff, 0x100, -100)
// Uint8ClampedArray [255, 255, 0]

這有什麼好處呢?在影像處理上非常方便。有個很常舉的例子,有 3 bytes 的 Uint8Array 存放 RGB 色碼,我們想要增加他的 gamma factor,如果使用 Uint8Array 儲存:

// 必須自行限制大小,防止 ooverflow/underflow。
u8[i] = Math.max(0, Math.min(255, u8[i] * gamma)) // u8 是一個 Uint8Array

如果是 Uint8ClampedArray,只需要直接乘上 gammer factor,非常方便。

pixels[i] *= gamma // pixels 是一個 Uint8ClampedArray

Composite Data Structure

當需處理類似 C struct 的複合資料結構,如下所示

struct employee {
  unsigned int id;               // 4 * 1 bytes
  unsigned char department[4];   // 1 * 4 bytes
  float salary;         // 4 * 1 bytes
};

我們可以宣告對應的 TypedArray 來處理。模擬出如果 C struct 的資料結構。

const buffer = new ArrayBuffer(12)
const idView = new Uint32Array(buffer, 0, 1)
const deptView = new Uint8Array(buffer, 4, 4)
const salaryView = new Float32Array(buffer, 8)
idView[0] = 123
deptView.forEach((_, i) => { deptView[i] = i * i })
salaryView[0] = 10000

DataView

顧名思義,DataView 是一種建構在 buffer 之上的 view。與一般 TypedArray 不同的是,建構 DataView 時並不會固定的資料型別,取而代之的是存取 data 時,必須明確的指定從哪個 byte offset 取哪一種 data type 出來。

借用前例的複合資料來示範,DataView 如何針對每個 bytes 處理自定義的資料。

const dv = new DataView(buffer)
// 從 byte offset 0 的位址開始取 Uint32 的資料
// 取得 ID -> 123
dv.getUint32(0, true)

// 從 byte offset 8 的位址開始寫入 Float32 的資料
dv.setFloat32(8, 200000, true)
dv.getFloat32(8, true) // 加薪囉!!

各位有沒有注意到,DataView 的 bytes getter/setter 最後面都多帶了一個 boolean 參數?這個參數是指定使用 Little-endian 讀取資料,預設為 false 也就是以 Big-endian 讀取。可控制 endian 是 DataView 蠻重要但也頗惱人的特性,在下一節會介紹 Endianness。

DataView 另一個重要特性就是不會 buffer overflow,所謂的 buffer overflow 是「當寫入一筆資料到指定 buffer 中,若寫入的資料大小超過該 buffer 的 boundary,溢出值就會覆寫下個 byte」。這種不安全的性質,也讓 buffer overflow 成為許多駭客的攻擊手法,有潛在的安全性問題。而透過 DataView setter 賦予一個超過型別最大值的數字,並不會覆蓋臨近記憶體位址的資料,而是內部先檢查邊界,處理 overflow 之後,再寫入該記憶體區間,彌補了 buffer overflow 的漏洞。

Precautions

Typed Arrays 幾乎可以做到如同 C 語言般細膩的記憶體操作。不過越是自由的 API,就代表要學習更多知識,注意更多細節,以下是操作 TypedArray 該銘記在心的事情:

Endianness (Byte order)

在計算機科學領域下,Data 是一個物理概念,指儲存在電腦記憶體上的一個 bits/bytes 序列。Data 本身並沒有任何意義,想使用它,必須自行解讀出抽象的意義,例如將 Data 讀取為字串或數字。

我們知道記憶體是基於位址(address)依序儲存 Data,每個位址可以存上 1 byte data。如果使用 Uint8Array 這種一次存取一個 byte 的格式,那麼完全不會有任何問題,怎麼存取,都是依連續的記憶體位址順序:

--- 資料讀取順序 -->
| Offset | 0    | 1    | 2    | 3    |
| ------ | ---- | ---- | ---- | ---- |
| Data   | 0x11 | 0x22 | 0x33 | 0x44 |

我們會得到 [17, 34, 51, 68] 的 Array。

當我們需要一次存取多 bytes,例如這個範例的記憶體區塊其實是一個 32 bit 的整數,那實際代表的數字會是多大?是 0x11223344(十進位:287454020) 嗎?

這其實牽扯到 CPU 的設計,目前市面上的多數 CPU 處理 multi-bytes 的資料時,大多從「最低有效位(LSB,least significant byte)」,也就是從權重最小的位數開始寫入。所以讀取這筆資料時,最前面的記憶體 offset 就是最權重最小的 bytes,所以最後會得到 0x44332211(十進位:1144201745)。這種存取排序,我們稱之為 Little-endian

有最低,當然就有最高,Big-endian 則是從「最高有效位(MSB,most significant byte)」開始存取。因此照著記憶體位址依序讀取,會得到跟記憶體 offset 順序相同的 0x11223344。Big-endian 雖然在個人電腦中不常見,但許多網路協議和設備都是採用 Big-endian 存取資料,佔有一定的重要性。

Little-endian 和 Big-endian 可以視為不同的電腦(CPU)講不同的語言,一個從右到左,另一個從左到右。其實在人類日常生活中也可以看到相同的現象,例如歐洲常用的日期格式為 day-month-year,ISO 國際標準則反之 year-month-day,所以,當你看到一個 17-09-07 的日期時,必須先判斷是否為 ISO 的標準,才能知道這場約會是在下禮拜的九月七日,抑或你需要一台時光機回到過去。

Q:那在 JavaScipt 要如何處理 endian?

如果不碰底層的記憶體操作,寫 JavaScript 是不用理會 endianness 的,但當你要操作 TypedArray 時,了解 data 的 endian 就至關重要了。TypedArray 預設是使用系統的 endianness,所以如果你接收一筆資料,與你的系統的 endianness 不一致,TypedArray 便使不上力。而前面介紹到 DataView 的 byte getter/setter 最後一個參數就是用來決定以哪種 byte order 存取資料,預設是 Big-endian(false),透過切換這個 flag,任何 binary data 都橫看成嶺側成峰了。

Q:那我們要如何得知資料的 byte order?

如果資料是自己家內部系統使用,其實溝通好就 OK,用 Mixed-endian 也不會有人管你。但如果是外界得來的任意資料,我們可以透過「BOM(byte order mark)」來判斷資料屬於哪種 endianness。BOM 是一個 Unicode magic number,通常放置在 text stream 的最前端。不過,並不是每個資料都會加上這個 header,而且有時候我們不需要 BOM 資訊,使用資料前還必須先 strip bom,說實話挺麻煩的。

Data Structure Alignment

要接觸底層的記憶體,免不了瞭解 CPU 如何從記憶體中讀取資料,記憶體底層到底如何配置。

一般來說,現代的 CPU 通常設計以 word 為單位(例如 4 bytes)讀寫記憶體裡的資料,而資料對齊(data aligment)則是將資料放置在 word-size * n 倍的記憶體位址上,使 CPU 以最為有效率的方式讀寫。那為什麼對齊 word-size 會最有效率呢?假設有個 C struct 如下:

struct AlignDemo {
  char c;     // 1 byte
  int i;      // 4 bytes
  short s;      // 2 bytes
};

理論上的記憶體配置如下,總共需要 7 bytes 的記憶體空間。

c = char 所佔的 byte
s = short 所佔的 byte
i = int 所佔的 byte

| 0x000           | 0x020           |
| [c] [i] [i] [i] | [i] [s] [s] [ ] |

前面提到 CPU 是以 word-size 存取記憶體上的資料,當嘗試讀取 char 和 short 時並沒有什麼問題,CPU 只需取一次 word chunk 再 offset 就可取得正確的值。然而,當欲讀取 int 時,CPU 需先取第一個 data chunk 以獲取 int 前三個 bytes,再取第二個 word chunk 並 shift 資料,以取得 int 最後一個 byte。如此多餘的記憶體存取會造成 CPU 額外的負擔。

解決方法是 Data Structure Padding,也就是在資料無法對齊 word-size 時,加上一些填充用的成員。

在我們的例子中,可以這樣做:

struct AlignDemo {
  char c;
  char padding_0[3]; // 填充用成員
  int i;
  short s;
  char padding_1[2];
};

記憶體配置則如下:

p = padding 所佔的 byte

| 0x000           | 0x020           | 0x040           |
| [c] [p] [p] [p] | [i] [i] [i] [i] | [s] [s] [p] [p] |

本來只需要 7 bytes,對個齊後,反而用掉這麼多額外的 bytes,你玩我嗎?

我們可以試著改變一下 struct member 的順序:

struct AlignDemo {
  int i;
  char c;
  short s;
  char padding[0]
};

記憶體配置對應改變,只佔用 8 bytes。Brilliant!

| 0x000           | 0x020           |
| [i] [i] [i] [i] | [c] [s] [s] [p] |

結構對齊(struct alignment)在 C 語言中是一門不小學問,除了結構內的成員本身要對齊,結構本身也要對齊。

回到 JavaScript,當你在創建不同的 view 時,JavaScript engine 其實會進行簡單的 natural alignment 檢查。

const buffer = new ArrayBuffer(6)
new Uint16Array(buffer, 1)
// RangeError: start offset of Uint16Array should be a multiple of 2
new Uint32Array(buffer, 0)
// RangeError: byte length of Uint32Array should be a multiple of 4

所以囉,當我們在設計複合資料時,想想對應的 C struct alignment,多考量記憶體底層,才不會讓操作 binary data 產生效能低落的反效果。

Others

最後,讓我們來認識除了 Typed Array 家族以外,JavaScript 的生態圈其他與記憶體息息相關的成員吧!

Node.js Buffer

早在 ES6 引入 TypedArray 之前,Node.js 為了處理 binary data,就實作 Buffer class,也針對 V8 引擎做最佳化。Buffer 在 Node.js 的環境中是 Global object,其功能可視為 ArrayBuffer + TypedArray + DataView 的複合體,甚至可以配置 non-zero-filled 的 unsafe buffer,好危險啊。

在使用上,Buffer 可從 ArrayBuffer 建構,也可從自身建構 TypedArray。事實上,Node.js v3+ 之後,Buffer 就繼承自 Uint8Array 了,不過有些 memory share/copy 的實作與 spec 有出入,在與 TypedArray
ArrayBuffer 轉換時,需注意這些小細節

Web API Blob

Blob 是一個不可變(immutable)的 raw binary sequence,只有兩個 attribute 和一個 method。

  • size:blob 實例的 byte 大小。
  • type:blob 實例的 MIME type
  • slice:切割一部分的 blob 實例,返回新的 blob。

Blob 的 spec 寫在 W3C File API draft 中,為 File class 的父類別。主要目的是提供可代表與儲存 JavaScript native 以外的格式,例如以 blob 儲存 死肥宅.jpg。Blob 除了可以從 object 建構,也可傳入 TypedArray 或 DOMString 建構。此外,File API,Fetch API、XMLHttpRequest v2 也都可以將 Request/Response 的 body 轉換成 Blob,非常泛用途呢!

而 Blob 最強大的地方就是配合 URL.createObjectURL 生成一個 Blob URL。如同你我認知中的 URL,任何運用 URL 之處,都可以傳入 Blob URL,比起 ImageImageDataMediaSource,URL 接受與使用度肯定更為廣闊,這讓資料處理,物件傳遞的耦合性變得更低。

當我們建立 Blob URL 後,若可預期的未來內不需要用到該 URL,就使用 URL.revokeObjectURL 取消註冊,否則該 URL 指向的 Blob 會持續留存,佔用你的儲存空間,直到瀏覽器執行 unload document cleanup 的步驟(如關閉分頁),才會將所有 Blob URL 清除。所以說,如需管理 Blob URL,還是老老實實把這些 URL 記錄起來吧!

Wrap-up

藉由這些直接操作 binary data 的 API,現代的 JavaScript 環境的效能提升到另一個層次,若再配合 Web worker Service worker 等多線程技術,加上線程共享的 ShareArrayBufferAtomic API,高效能的 web app 指日可待。如果再加上逐漸普及,即將成為 Webpack 一等公民WebAssembly,JavaScript/Web 的世界更是不可限量啊!或許,使用 Rust 寫網頁的世代即將來臨 XD。

前端工程師們,活到老,學到掛吧!

Reference

關於作者:
@weihanglo 掛著 iOS 工程師之名,行開發 Web App 之實。


#javascript #Endianness #Binary Data #Memory Alignment









Related Posts

自動化測試 x Puppeteer - 玩偶QA參一咖 Day00

自動化測試 x Puppeteer - 玩偶QA參一咖 Day00

Day 80

Day 80

DAY24:Who likes it?

DAY24:Who likes it?




Newsletter




Comments