【小ネタ】ニコ生ゲームでバイナリ通信
マルチゲームで画像データなどをやり取りするためにg.game.raiseEvent()でバイナリデータを送りたいことがある。それ自体は何も難しくなく、送信データに[42, 16, 120, ...]
のようなArrayオブジェクトやUint8Arrayオブジェクトを含めればいいだけである。しかしこの方法だとニコ生ゲームの仕様上かなりのオーバーヘッドが発生してしまうのだ。今回はその原因と対策(?)を紹介する。
ニコ生ゲームの通信仕様
ニコ生ゲームは配信ページ内のiframe要素の中で実行されている。しかし、WebSocketでサーバーと通信をするスクリプトはその外側に存在している*1。そのためイベントを送信する際はiframe内からpostMessage関数で外側のスクリプトに通信データを投げ、それを外側でmsgpack(MessagePack)というライブラリを用いてバイナリ化し、ソケットに書き込むという手順をとっている。受信はその逆である。
この過程にどういう問題があるのかについて説明するためにいったんpostMessageとMessagePackというキーワードについて簡単に説明する。
postMessage
iframeの中と外は互いのコンテキストを自由に参照できないようにしてセキュリティを確保している。その安全性を保ちつつ別のウィンドウと通信をするのがpostMessage関数である。postMessage関数は構造化複製アルゴリズムを用いて送信データをディープコピーしているのだが、このアルゴリズムではオブジェクトに含まれる関数やクラス情報は削ぎ落され、純粋な配列、連想配列、数値、文字列といったデータのみがコピーされるので、送信先に処理が渡ることがなく安全というわけだ。
MessagePack
MessagePack(メッセージパック)は、バイナリ形式のデータ交換用フォーマット。配列や連想配列などの単純なデータ構造を表現できる。可能な限りコンパクトでシンプルになることを目指している。C言語、C++、C#、D言語、Erlang、Go、Haskell、Java、JavaScript、Lua、OCaml、Perl、PHP、Python、Ruby、Scala、Smalltalk、Swiftなどのプログラミング言語の実装が存在する。
ニコ生ゲームは各クライアントとサーバーの通信量を極力減らすために上記ライブラリでjsonをコンパクトなバイナリ形式に変換してから送受信している。
MessagePackではBuffer型オブジェクトをバイナリデータとして扱うように実装されている。Buffer型はnodeのクラスなのでブラウザで使う場合はポリフィルを定義する。実際iframeの外側にはBufferクラスが定義されているようだ。
バイナリ通信の問題点
生バイナリの通信はそもそも可能か?
ニコ生ゲームの通信処理をブレイクを張って追っていくとオブジェクトをバイナリに変換するための関数に辿り着く↓
ここで、isBufferという関数を呼び出しているがその実装は以下のようなものだ。
Buffer.isBuffer = function isBuffer(b) { return b != null && b._isBuffer === true && b !== Buffer.prototype }
この条件を満たすオブジェクトは無加工でバイナリデータをそのままパッキングしてくれるのだが、果たしてこの条件を満たすことは可能だろうか?
まずBuffer型だが、iframeの外に定義されているのでそのままでは使えない。しかしこれに関していえば自分でポリフィルを定義すればいいだけなのでBuffer型オブジェクトを作ること自体はできる。
次にpostMessageだが、オブジェクトのクラス情報は通過できないので、b !== Buffer.prototype
がどうしても満たせない。
よってiframe内から生バイナリ判定されるオブジェクトは送れないことが分かる。ニコ生ゲームではmsgpackのバイナリ通信機能が封じられてしまっているのだ。(ドワンゴよ・・・)
他のオブジェクトだとどうなるか?
では、BufferをあきらめてUint8ArrayをMessagePackでエンコードした場合どのようになるだろうか?どうやらTypedArrayなどは配列扱いになってしまうようだ。配列のエンコード処理はここ、uint8はここにある通りだ。もし[255, 0, 80, 128]という4byteのバイナリデータをエンコードするとこうなる。
配列長 | 要素0 | 要素1 | 要素2 | 要素3 |
---|---|---|---|---|
0x94 | 0xCC 0xFF | 0x00 | 0x50 | 0xCC 0x80 |
内容はともかくとして、7byteにエンコードされた。元のサイズから何倍に膨らむかは簡単な計算で見積もることができる。
uint8は127以下で1byte、128以上で2byteにエンコードされる。
ランダムなバイナリデータの各バイトが127以下になるかどうかは平均で50%と考えると、元データに対して平均1.5倍に増加すると見積もることができる。
1.5倍は結構大きい。さらに問題なのは配列のエンコード処理がなかなかに重いということだ。TypedArrayなので各要素はすべて同じ型であると分かっているのに配列カテゴリとしてエンコードする(=Array型と同じエンコード)ので、配列要素1つ1つに対して型の取得や連想配列によるエンコーダの逆引きなどを行っており、バイナリデータが大きくなるほど処理のオーバーヘッドが膨大になっていくことが予想できるだろう。
// 配列要素1つ1つに対して重そうな処理が走る for(const e of [...]) { TYPES[typeof e].encode(buffer, e); }
結論として、Uint8Arrayでバイナリ通信は可能である。ただし、送信データは約1.5倍に膨らみ、エンコード処理にかなりのオーバーヘッドが発生するので、あまり良い解答ではなさそうだ。
文字列はどうか?
コードを読んでみるとutf-8 stringのエンコードがBufferの次にマシそうである。 実際に実行されているコードは以下のようなものだった。
// 一部を抜粋 function utf8ToBytes(string, units) { units = units || Infinity; var codePoint; var length = string.length; var bytes = []; for (var i = 0; i < length; ++i) { codePoint = string.charCodeAt(i); if (codePoint < 128) { if ((units -= 1) < 0) break; bytes.push(codePoint) } } return bytes }
以下に生Uint8Arrayとutf-8 string版の比較を載せる。
Uint8Array | utf-8 string | |
---|---|---|
データ長 | 約1.5倍 | 約1.33倍 |
msgpack オーバーヘッド |
かなりでかい | 比較的小さい |
事前処理 | 不要 | BASE64エンコード |
バイナリデータを文字列化する方法として今回はBASE64を使用した。msgpackでutf-8をエンコードする際ASCIIコードのみの文字列でないといろいろ面倒なためだ。比較した結果を見ると正直微妙なところである。データの内容によっては生のUint8Arrayの方が小さくエンコ―ドされることもある*2ため必ずしもutf-8 stringが優れているとは言えない。実測まではしていないのでどちらの方がより効率的かは分からないが選択肢にはなり得るのかなという感じ。
最後に
今回はマルチプレイのゲームにおいてバイナリデータを大量に通信したい場合BASE64エンコードした方が軽くなるかもしれない、という話だった。
そもそも、そんなにバイナリで通信をしたいのか?という話だが、これがc++で開発しているとめっちゃ使うのだ。c++の場合メッセージは構造体で定義しているため全てのメッセージは1つのバイナリデータとして扱える。であれば、json化せずバイナリのまま通信した方が効率的だということで今回の話につながった。なので、普通にjs/tsで開発している人はあまり気にしなくていいかもしれない。しかし、通信処理が裏で何をしているか知っておくことはきっと何か役に立つだろう・・・🤔。
というかドワンゴがちゃんと生バイナリで通信できるように改造しておいてくれればこんな事せずに済むのだが。