JavaScript
websocket

WebSocketを用いたバイナリデータ送受信における、ブラウザ固有のバイトオーダー(Endianness a.k.a Byte ordering)の扱い方

背景

先日とある機会があり、WebSocketを用いて、バイナリデータをブラウザからサーバーに転送するコードを書きました。その際に、バイトオーダー(Endianness a.k.a Byte ordering)でつまずきました。

僕はウェブ上にある記事を色々漁って、「ブラウザ上にて、バイトオーダーに気をつけながらバイナリデータを扱う方法」を自分の中で理解しました。

ちょっくら、まとめてみようと思います。

僕が直面した問題

「Aブラウザ上で、Uint32Array型の変数を生成し、それをWebSocketを介してBブラウザ上で処理する」
といったプログラムを書きました。図にするとこんな感じ。

Aブラウザ
||
|| websocket
||
\/
サーバー
||
|| websocket
||
\/
Bブラウザ

その時、「バイトオーダー」に関する知識が抜けていた僕は、ざっくりと以下のようなコードを書きました。
とりあえず送信側のコードだけ・・・

let ui32arr = new Uint32Array(2048);
...省略...
ui32arr[0] = 12345;
...省略...
socket.send(ui32arr.buffer); // socketはwebsocket

このコードは正常に動作する時もありますが、意図しない動作になる時もあります。
バグが含まれているコードです。

バイトオーダー(Endianness a.k.a Byte ordering)とは?

バイトオーダーとは、コンピュータのメモリにマルチバイト数値を格納する際の、各バイトの格納順序です。主に以下の2つの格納順序があります。

  • ビッグエンディアン(Big endian)
    • マルチバイト数値を最上位バイトから順番に格納します。
  • リトルエンディアン(Little endian)
    • マルチバイト数値を最下位バイトから順番に格納します。

どういうこと?例で説明します。

let a = new Uint32Array(2);
a[0] = 99998891;
a[1] = 14892758;

上記の配列の要素は、マルチバイト数値です。
(Uint32は、32ビットの符号なし正数、という意味です。)
従って、数値は4バイトで表現されます。
(1バイトは8ビットです。よって、4=32/8で、4バイト。)

コンピュータのメモリ上では、99998891という数値は、4バイトの数値によって表現されています。14892758も同様です。

ビッグエンディアンでは、4バイトは、最上位バイトから順番にメモリに格納されます。
リトルエンディアンでは、4バイトは、最下位バイトから順番にメモリに格納されます。

a[0] a[1]
格納されている数値 99998891 14892758
上位 下位 上位 下位
各バイトの値
(16進数表記)
05 f5 dc ab 00 e3 3e d6
ビッグエンディアン 05 f5 dc ab 00 e3 3e d6
リトルエンディアン ab dc f5 05 d6 3e e3 00

コンピュータのメモリ上に格納される全てのデータは、ビッグエンディアン、リトルエンディアン、どちらかのバイトオーダーによって、メモリ上に格納されます。

このデータのバイトオーダーは何だ?と思ったら

このデータのバイトオーダーが、ビッグエンディアン、リトルエンディアン、どっちだろう?と思った時に、
「そのデータのバイトオーダーを判別する方法」を知っていることが大切です。

バイトオーダーは、様々な「仕様」により決められています。
(だから厄介なんだよなぁ・・・。どっちかに統一してくれよ・・・。)

ファイルの仕様

ファイルフォーマットのエンコードの仕様として、バイトオーダーが決められている場合があります。
(場合があります、というか殆どの場合、決められているはずです。バイトオーダーが明確に定義されていないと、プログラムが書けないからです。)

ファイル形式 バイトオーダー
wav RIFFなら、そのwavファイルはリトルエンディアン。RIFXなら、そのwavファイルはビッグエンディアン。詳しくはこちら
png ビッグエンディアン。詳しくはこちら
ユニコードのテキスト BOMにより判断。詳しくはこちら

プログラムの仕様

そのプログラムの実行環境により決められている場合があります。

例えば、Javaのプログラムの実行環境であるJVMでは、基本的には、マルチバイト数値はビッグエンディアンとしてメモリ上に格納されます。

CPUの仕様

上記にあげた仕様によりバイトオーダーが決められていない場合、或いは、「バイトオーダーはネイティヴのものに従う」といった記述がある場合、
そのコンピュータのCPUの仕様によりバイトオーダーが決まります。

僕はMacbook proを利用しているのですが、Macbook proのネイティヴバイトオーダーはリトルエンディアンです。なぜならIntelのCPUを利用しているからです。

WebSocketを介したバイナリデータの送受信で気をつけること

JavaScriptには、バイナリデータを扱うために、TypedArrayビューと呼ばれる、マルチバイト数値を要素とする配列があります。

気をつけることべきことは、

Typed array views are in the native byte-order (see Endianness) of your platform.

TypedArrayビューは、プラットフォーム固有のネイティヴのバイトオーダーです。

要するに、ブラウザがインストールされているCPUの仕様に依存するので、リトルエンディアン、ビッグエンディアン、どっちか分からないってことです。

バイトオーダーを考慮せずに書かれたプログラムは、なぜ問題なのか?

「Aブラウザ上で、Uint32Array型の変数を生成し、それをWebSocketを介してBブラウザ上で処理する」
といったプログラムを、僕はこんな風に書きました。

Aブラウザ
||
|| websocket
||
\/
サーバー
||
|| websocket
||
\/
Bブラウザ

Aブラウザ

let ui32arr = new Uint32Array(2048);
...省略...
ui32arr[0] = 12345;
...省略...
socket.send(ui32arr.buffer);
// socketはwebsocket
// ui32arr.bufferは、
// 実際にネイティヴメモリ上のに格納されている
// ui32arrデータ表わすバイト配列

Bブラウザ

socket.onmessage = (event) => {
  let ui32arr = new Uint32Array(
    event.data, 0, event.data.length / Uint32Array.BYTES_PER_ELEMENT
  );
};

このコードは、AブラウザとBブラウザのバイトオーダーが同じ時には正常に動作します。
しかし、AブラウザとBブラウザのバイトオーダーが異なる時には意図しない動作になります。

なぜか?

すごい簡単な例で説明します。

Aブラウザ(リトルエンディアン)

let ui32arr = new Uint32Array(2);
ui32arr[0] = 99998891;
ui32arr[1] = 14892758;
socket.send(ui32arr.buffer);

Bブラウザ(ビッグエンディアン)

socket.onmessage = (event) => {
  let ui32arr = new Uint32Array(
    event.data, 0, event.data.length / Uint32Array.BYTES_PER_ELEMENT
  );
  console.log(ui32arr[0]);
  console.log(ui32arr[1]);
};
ui32arr[0] ui32arr[1]
Aブラウザ
ui32arr
99998891 14892758
各バイトの値
(16進数表記)
05 f5 dc ab 00 e3 3e d6
ui32arr.buffer
の中身
リトルエンディアン
ab dc f5 05 d6 3e e3 00
websocketで送られているデータ ab dc f5 05 d6 3e e3 00
Bブラウザ
ui32arr.bufferの中身
ビッグエンディアンで読み出しちゃう
こりゃあかん!
ab dc f5 05 d6 3e e3 00
各バイトの値
(16進数表記)
ab dc f5 05 d6 3e e3 00
Bブラウザ
ui32arr
2883384581 3594445568

送信したデータと、受信したデータが異なる!バグです。

解決方法) DataViewを使う

問題の解決方法の1つは、DataViewを利用することです。

DataViewを利用することで、ネイティヴバイトオーダーがビッグエンディアンかリトルエンディアンかに関わらず、プログラマが意図したバイトオーダーでマルチバイト数値をメモリ上に書き出すことができます。

Aブラウザ(リトルエンディアン)

let ui32arr = new Uint32Array(2);
ui32arr[0] = 99998891;
ui32arr[1] = 14892758;
// リトルエンディアンでマルチバイト数値をメモリ上に書き出す
let buf = new ArrayBuffer(ui32arr * Uint32Array.BYTES_PER_ELEMENT);
let dv = new DataView(buf);
dv.setInt32(0, ui32arr[0], true);
dv.setInt32(4, ui32arr[1], true);
// DataViewのsetInt32メソッド(第3引数true)を用いることで、
// bufは、必ずリトルエンディアンになる。
socket.send(buf);

Bブラウザ(ビッグエンディアン)

socket.onmessage = (event) => {
  let dv = new DataView(event.data);
  let ui32arr = new Uint32Array(
    event.data.length / Uint32Array.BYTES_PER_ELEMENT
  );
  // リトルエンディアンでマルチバイト数値をメモリ上から読み出す
  ui32arr[0] = dv.getInt32(0, true);
  ui32arr[1] = dv.getInt32(4, true);
  // DataViewのgetInt32メソッド(第2引数true)を用いることで、
  // 必ずリトルエンディアンでデータが読み出せる。
  console.log(ui32arr[0]);
  console.log(ui32arr[1]);
};
ui32arr[0] ui32arr[1]
Aブラウザ
ui32arr
99998891 14892758
各バイトの値
(16進数表記)
05 f5 dc ab 00 e3 3e d6
buf
の中身
リトルエンディアン
ab dc f5 05 d6 3e e3 00
websocketで送られているデータ
リトルエンディアン
ab dc f5 05 d6 3e e3 00
Bブラウザ
ui32arr.bufferの中身
リトルエンディアンとして
読み出せた!
05 f5 dc ab 00 e3 3e d6
各バイトの値
(16進数表記)
05 f5 dc ab 00 e3 3e d6
Bブラウザ
ui32arr
99998891 14892758

まとめ

得られた教訓は以下。

  1. WebSocketでバイナリデータを送受信する場合、必ずWebSocketを介して送受信されるデータのバイトオーダーを、明確に定義し、実装すること。実装する際にはDataViewを使うこと。

参考文献