JavaScript/Node.jsでのバイナリデータ処理

## 概要

JavaScriptでバイナリデータを扱おうとすると`Buffer`と`ArraryBuffer`という似た名前のオブジェクトが登場して混乱したり、`Uint8Array`という耳慣れないオブジェクトが登場して途方に暮れたりすることがあると思います。本記事ではこれらのオブジェクトの概念を整理することでJavaScriptでのバイナリデータの扱いの見通しを良くしていきます。

## 概念の整理

まずバイナリデータを処理しようとしている環境がNode.jsなのかブラウザ環境なのかが重要です。Node.js環境の場合は`Buffer`クラスを利用することになります。`Buffer`クラスはNode.jsの標準ライブラリとして実装されています。

https://nodejs.org/api/buffer.html

ブラウザ環境ではJavaScript自体に実装されている`ArraryBuffer`オブジェクトを利用することになります。`Buffer`は実装されていません。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer

これらは名前は似ていますが別物です。

また`Uint8Array`はJavaScript標準で実装されている`ArraryBuffer`のビューです。生のバイナリデータに近い`ArraryBuffer`をより使いやすい形にしたものという位置付けです。

以上のように環境によって利用できるオブジェクトが異なるため、この記事では環境ごとにバイナリデータの扱い方を解説していきます。

(補足として最後にDenoでのバイナリデータ処理について触れます。基本的にブラウザ環境と同じAPIを利用します。)

## Node.jsでのバイナリデータ処理

Node.jsでバイナリデータを扱うなら`Buffer`クラスを利用します。後述するJavaScript標準の`ArraryBuffer`より便利なので利用できる場合は基本的にこちらを使うのが良いと思います。グローバルスコープで利用できるのでインポートしなくても利用できますが、以下のように明示的にインポートすることが推奨されています。

```js
// cjs
const { Buffer } = require('node:buffer');

// esm
import { Buffer } from 'node:buffer';
```

コンパイルしてブラウザ環境でコードを実行する場合はポリフィルライブラリを利用するとNode.js環境と同じように実装することができます。

https://www.npmjs.com/package/buffer

```bash
npm install buffer
```

```js
// cjs
const Buffer = require('buffer/').Buffer;

// esm
import { Buffer } from 'buffer';
```

### Buffer

`Buffer`クラスは固定長のメモリ領域を表現したオブジェクトです。`Buffer.alloc()`で長さを指定して生成したり、`Buffer.from()`で文字列などのデータから生成することができます。

```js
// 10バイトのメモリ領域を確保する(各バイトは0で初期化される)
Buffer.alloc(10);
// <Buffer 00 00 00 00 00 00 00 00 00 00>

// バイトの配列から生成
// 値の範囲は0~255
Buffer.from([1, 255, 256]);
// <Buffer 01 ff 00>

// 文字列から生成
Buffer.from('テスト');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
```

各バイトにはインデックスによってアクセスすることができます。それぞれの値は10進数の数値型で表現されます。

```js
const buf = Buffer.from('hoge');
> buf[0];
104
> buf[1];
111
> buf[2];
103
> buf[3];
101
```

取得するだけではなく上書きすることもできます。

```js
buf[0] = 102;
buf[1] = 117;
buf[2] = 103;
buf[3] = 97;
buf;
// <Buffer 66 75 67 61>

// toString()について詳しくは後述
buf.toString();
// 'fuga'
```

文字列から生成する場合はエンコーディングを指定することができます。デフォルトではUTF-8でエンコードされたものとして解釈されます。

```js
Buffer.from('テスト', 'utf8');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
```

16進数文字列から生成することもできます。

```js
Buffer.from('e38386e382b9e38388', 'hex');
// <Buffer e3 83 86 e3 82 b9 e3 83 88>
```

サポートされている形式は以下に挙げられています。

https://nodejs.org/api/buffer.html#buffers-and-character-encodings

`Buffer.toString()`を利用することでバイナリデータを文字列として出力することができます。その際もデフォルトの形式はUTF-8です。引数によって出力形式を指定することができます。UTF-8文字列とバイナリデータの16進数文字列を相互変換したり、文字列をbase46エンコードして出力することができます。

```js
const buf1 = Buffer.from('e38386e382b9e38388', 'hex');
buf1.toString();
// > 'テスト'

const buf2 = Buffer.from('テスト', 'utf8');
buf2.toString('hex');
// > 'e38386e382b9e38388'
buf2.toString('base64');
// > '44OG44K544OI'
```

Node.jsでファイルを読み込む場合、エンコーディングを指定しない場合はこの`Buffer`型のデータとして扱われます。

https://nodejs.org/api/fs.html#fsreadfilepath-options-callback

```js
import { readFile } from 'node:fs';

readFile('/path/to/file', (err, data) => {
    console.log(data);
    // <Buffer ...>
    data.toString('hex');
    // '...'
})
```

サーバサイドのヘッドレスブラウザなどでローカルファイルの画像のバイナリデータを`img`タグで表示するとします。base64エンコーディング文字列への変換を活用して以下のように実装することができます。

```jsx
<img
    src={`data:image/png;base64,${data.toString('base64')}`}
/>
```

`Buffer`クラスは`new`によって生成することもできますが、非推奨とされています。引数に渡す値の型によって挙動が変わるため扱いが難しく、またNode.jsのバージョン8以前では割り当てられたメモリが初期化されない場合があるためセキュリティ上の危険性があります。

https://nodejs.org/api/buffer.html#bufferfrom-bufferalloc-and-bufferallocunsafe

常に`new Buffer()`ではなく`Buffer.alloc()`や`Buffer.from()`を使うようにしましょう。

## JavaScriptでのバイナリデータ処理

JavaScriptには`ArrayBuffer`オブジェクトが実装されてます。こちらも固定長のメモリ領域を表現しています。ブラウザ環境で直接実行するコード上でバイナリデータを扱う場合はこのオブジェクトを利用します。ただしこの`ArrayBuffer`ではデータの内容にアクセスすることはできません。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer

`ArrayBuffer`の内容にアクセスするには`TypedArray`というオブジェクトを利用します。`TypedArray`には`Uint8Array`や`Float32Array`といったいくつかの種類が存在します。これらは`ArrayBuffer`のビューとして機能します。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray

### ArrayBuffer

`ArrayBuffer`は確保するメモリのバイト長を指定して生成します。メソッドはほとんどないため、この`ArrayBuffer`を直接操作することはありません。

```js
new ArrayBuffer(10);
// ArrayBuffer {
//   [Uint8Contents]: <00 00 00 00 00 00 00 00 00 00>,
//   byteLength: 10
// }
```

### TypedArray

バイナリデータを操作するためには`ArrayBuffer`から`TypedArray`ビューを生成する必要があります。その際に最もよく利用されるのが`Uint8Array`です。`Uint8Array`は符号なしの8ビット=1バイト単位でメモリ領域を扱います。メモリは1バイト単位で扱われるのが一般的なのでこの`Uint8Array`がメモリ領域を表現するのに一番自然です。

`TypedArray`はコンストラクタに`ArrayBuffer`を入力することで生成できます。

```js
const buffer = new ArrayBuffer(8);
new Uint8Array(buffer);
// Uint8Array(8) [
//   0, 0, 0, 0,
//   0, 0, 0, 0
// ]
```

また`ArrayBuffer`を経由せず直接`TypedArray`を生成することもできます。この場合`TypedArray`の種類に応じた単位の領域が指定した長さ分確保されます。また数値の配列から生成することもできます。その場合の数値の範囲は`TypedArray`の種類によって変わります。`Uint8Array`なら値は8ビットなので0~255になります。

内部的には`ArrayBuffer`が生成された上でそのビューが返されていますが、`ArrayBuffer`自体を意識する必要はあまりありません。

```js
new Uint8Array(8);
// Uint8Array(8) [
//   0, 0, 0, 0,
//   0, 0, 0, 0
// ]

// こちらは32ビット=4バイト単位
// 8*4=32バイトのメモリ領域が確保される
new Int32Array(8);
// Int32Array(8) [
//   0, 0, 0, 0,
//   0, 0, 0, 0
// ]

// 符号なし8ビットなので値の範囲は0~255
new Uint8Array([0, 255, 256]);
// Uint8Array(3) [ 0, 255, 0 ]
```

`TypedArray`はインデックスによってデータの内容にアクセスすることができます。その際の単位は`TypedArray`の種類によって異なります。

```js
const u8 = new Uint8Array(8);
u8[0];
// 0
u8[1];
// 0

u8[0] = 255;
u8;
// Uint8Array(8) [
//   255, 0, 0, 0,
//     0, 0, 0, 0
// ]

u8[1] = 256;
u8;
// Uint8Array(8) [
//   255, 0, 0, 0,
//     0, 0, 0, 0
// ]
```

その他、`Array`に似たメソッドが実装されています。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray#instance_methods

`TypedArray`単体ではNode.jsの`Buffer`のように文字列からバイナリデータを作成したり、逆にバイナリデータを文字列に変換ことはできません。`TextEncoder`/`TextDecoder`を利用します。

文字列からバイナリデータを得るには`TextEncoder.encode()`を使います。`Uint8Array`が返ってきます。エンコーディングはUTF-8のみ対応しています。

```js
const encoder = new TextEncoder();
encoder.encode('テスト');
// Uint8Array(9) [
//   227, 131, 134,
//   227, 130, 185,
//   227, 131, 136
// ]
```

バイナリデータを文字列に変換するには`TextDecoder.decode()`を使います。

```js
const encoded = encoder.encode('テスト');
const decoder = new TextDecoder();
decoder.decode(encoded);
// 'テスト'
```

こちらは`TextDecoder`のコンストラクタの引数で指定することでUTF-8以外のエンコーディングを利用することができます。対応しているものの一覧は以下にあります。

https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings

バイナリデータと16進数文字列の変換手段は実装がありません。少しく工夫すれば自分で実装することもできます。

まずはバイナリデータから16進数文字列への変換です。

```js
Array.from(encoded).map(
    (byte) => byte.toString(16).padStart(2, '0')
).join('');
// 'e38386e382b9e38388'
```

`Uint8Array`を配列に変換して各値を取り出すと10進数の数値になります。それらを`Number.prototype.toString()`を利用して16進数文字列にします。さらに二桁で固定するため`String.prototype.padStart()`を使います。あとは`Array.prototype.join()`で繋げれば16進数文字列表記が得られます。

逆に16進数文字列からバイナリデータを生成する処理です。まずなんとかして16進数文字列を2文字ずつの配列に変換します。それらを10進数の数値に変換します。JavaScriptでは16進数は`0x83`といった形式で扱われるため、16進数文字列の頭に`0x`をつけて`Number()`に渡せばOKです。最後にその数値の配列を`TypedArray`のコンストラクタに渡せば完成です。

```js
const hexStrings = ['e3', '83', '86', 'e3', '82', 'b9', 'e3', '83', '88'];
const hexNumbers = hexStrings.map((hex) => Number('0x' + hex));
const u8 = new Uint8Array(hexNumbers);

decoder.decode(u8);
// 'テスト'
```

base64エンコードについてはかなり複雑な事態が発生します。

base64文字列を生成する手段として`btoa()`と`atob()`が実装されていますが、ASCII文字列との変換にしか対応していません。

https://developer.mozilla.org/en-US/docs/Web/API/btoa

上記のリンクの例を参考に実装するのであれば、`Uint8Array`のそれぞれのバイトを無理やりASCII文字列に変換してこの`btoa()`に入力します。

```js
const encoder = new TextEncoder();
const encoded = encoder.encode('テスト');

let result = '';
for (let i = 0; i < encoded.byteLength; i++) {
    result += String.fromCharCode(encoded[i]);
}
btoa(result);
// '44OG44K544OI'
```

逆に`Uint8Array`にデコードするなら`atob()`でデコードした文字列を`String.prototype.charCodeAt()`を使って一文字ずつバイトに変換していきます。`String.prototype.charCodeAt()`はUTF-16でエンコードしますが`atob()`の戻り値はASCII文字なので0~255の範囲で表現できます。なのでそのまま`Uint8Array`に入れて問題ありません。文字列に変換したければ`TextDecoder`を使います。

```js
const ascii = atob('44OG44K544OI');

const bytes = new Uint8Array(ascii.length);
for (let i = 0; i < bytes.length; i++) {
    bytes[i] = ascii.charCodeAt(i);
}

const decoder = new TextDecoder();
decoder.decode(bytes);
// 'テスト'
```

以上のように16進数文字列やbase64を利用する場合は複雑になるので可能な場合はNode.jsの`Buffer`を利用した方がいいでしょう。

またはサードパーティライブラリのjs-base64を利用する手段もあります。

https://www.npmjs.com/package/js-base64

## (補足)Denoでのバイナリデータ処理

DenoにはNode.jsの`Buffer`クラスはありません。基本的にJavaScript標準の`Uint8Array`を利用します。ファイルリーダの戻り値も`Promise<Uint8Array>`となっています。

https://doc.deno.land/deno/stable/~/Deno.readFile

バイナリデータと文字列との変換は`TextEncoder`/`TextDecoder`を利用します。

https://doc.deno.land/deno/stable/~/TextEncoder

https://doc.deno.land/deno/stable/~/TextDecoder

上の節で悩むことになった`Uint8Array`に不足する機能は標準ライブラリによって補われています。例えばバイナリデータと16進数文字列との変換には`std/encoding/hex`ライブラリが利用できます。

https://deno.land/std@0.153.0/encoding/hex.ts

base64との変換には`std/encoding/base64`ライブラリが利用できます。

https://deno.land/std@0.153.0/encoding/base64.ts

## 参考文献

https://nodejs.dev/en/learn/nodejs-buffers

https://ja.javascript.info/arraybuffer-binary-arrays