リアルタイム通信用のコネクションをタブ間で共有してまとめる
これはなんらかのアドベントカレンダーの何日目かの記事だったりしません。
KOBA789 です。仕事では人工衛星の搭載ソフトウェアを書いたり、人工衛星の管制システムのソフトウェアを書いたりしています。
先日、こういうツイ……ポストをしたらちょっとバズりました。意外と興味持ってくれる人が多かったので、それに関連するオタク早口記事でも書くかぁと思って筆を執っています。
うちの人工衛星の開発ツールはウェブ技術でできている
前述のポストは管制システムについてですが、開発用ツール(C2A DevTools)もまた React + TypeScript でできています。
まぁ画面見てもなにがなんだかという感じだと思いますが、UNIX サーバーで top コマンド叩いたときの内容と、サーバーのログが合体したみたいなものが表示されていると思ってください。
これらの数値は gRPC-web の Server-stream で受信しており、リアルタイムに更新されています。
この開発ツールの使い方
搭載ソフトウェアの開発者は C2A DevTools を自身の PC で動かし、その PC に搭載コンピュータのボードを接続します。
すると、搭載コンピュータから出力されたデータが PC 上で可視化され、デバッグできる、という仕組みです。
(実際には常に実機で開発しているわけではなくて、シミュレータを PC 内で動かしてそれと接続したりしているんですが、物体があったほうがイメージつきやすいので、そういう図にしています)
搭載ソフトウェアの開発者はたくさんのテレメトリを同時に見たい
テレメトリっていうのは、前述の画面でたくさん表示されていた数値のことです。
これらの数値は SQL のテーブルやログストリームを分けるのと似たような感覚で、いくつものテーブルに分かれています。で、そのテーブル内にまたたくさんのフィールド(カラム)があります。うちの衛星だと1機あたり数百くらいあるんだっけ。
で、搭載ソフトウェアの開発者には同時に複数のテーブルを眺めたいことがあります。top コマンド表示しながらアクセスログも見たい、みたいな感じです。
しかしながら、C2A DevTools はそれ単体では複数のテーブルを同時に表示する機能を意図的に持っていません。これは、ペイン分割のような機能を実装すると複雑になりすぎるということと、PC の OS やブラウザにはもっとかしこいウィンドウマネージャ機能があり、それらを活用すべき、という設計判断に依るものです。
実際、MS Edge や Arc Browser などを使えば、このようにペイン分割が可能です。ちょっとわかりづらいですが。
タイトルからちょっと離れた話題が続いてますがもう少しご辛抱ください。
ブラウザには同時コネクション数制限がある
こういった gRPC Server-stream のような、コネクションを開けっぱなしにする系のアプリをペイン分割や複数タブでたくさん開くと問題になるのが、ブラウザの同時コネクション数制限です。
Google Chrome では同一オリジンへの HTTP/1.1 のコネクション数は6本に制限されており、これを越えると新規のリクエストがブロック(エラーではなく待ち)されます。
このため、テレメトリのテーブルを6個開いた状態でコマンドを送ろうとすると、いつまでもコマンド送信中で待たされて送れない、といった挙動になります。
HTTP/2 を利用したりすれば回避できるようですが、このツールはサーバー側を含めてローカルで動かすことを想定しているため、主に証明書の用意等で TLS の利用が困難(というか面倒)であり、この問題の解決のためだけに対応するのは費用対効果が悪いと考えました。
そこで SharedWorker ですよ!
ようやく本題です。お待たせしました。この仕事は分野がマイナーすぎて背景説明で分量食いがちなんですよね。ほんとすみません。
みなさん SharedWorker って知ってます?
SharedWorker は、同一オリジンの異なるブラウザコンテキスト(ウィンドウ・タブ・ペイン)間で共有できるワーカーです。重い処理をオフロードするときによく使われる普通の Worker と比べると、ちょっと影が薄いような気がします。
結論としては、こうした長生きするコネクションを使った通信を SharedWorker に押し込めば、タブ・ペイン間で共有できるよ! ということです。
実は GitHub でも同様のテクニックが使われており、Google Chrome で github.com のなんらかのページを開いた状態で chrome://inspect/#workers
を開くとその様子がわかります。新着 Issue コメントをリアルタイムに取得するなどの用途で使っているようです。
しかし活用は言うほど簡単ではない SharedWorker
これで万事解決かと思いきや、話はそう簡単ではありません。
というのも、SharedWorker はメインのコンテキストとは別のコンテキストで動くスクリプトであり(普通の Worker と同様です)、メインコンテキストとのやりとりは MessagePort を介したメッセージパッシングでしか行えないからです。つまり、直接メソッドを呼んだりとかはできないということです。
ここで、SharedWorker の使い方を整理しておきましょう。
たとえば、足し算を行う SharedWorker を作るとしましょう。SharedWorker の Shared な部分をまったく活かしてない無駄な設計ですが、あくまで例なので。
main.js
がメインコンテキスト側、worker.js
が SharedWorker コンテキスト側です。ちなみにサンプルコードは Zenn のエディタ上で一発書きなので動かないかもしれません。壊れてたら教えて。
const worker = new SharedWorker("worker.js");
worker.port.addEventListener("message", (event) => {
console.log(`sum: ${event.data}`);
});
worker.port.start();
worker.port.postMessage({a: 1, b: 2});
worker.port.postMessage({a: 3, b: 4});
function add(a, b) {
return a + b;
}
addEventListener("connect", (event) => {
const port = event.ports[0];
port.addEventListener("message", (event) => {
const {a, b} = event.data;
const sum = add(a, b);
port.postMessage(sum);
});
port.start();
});
おいおい正気か? 足し算をするだけでこの行数。worker.js
の add(a, b)
を直接呼び出すことは許されず、メッセージパッシングによる迂遠な呼び出しを強制されます。最もひどいのは返り値がすべて同じハンドラに吸い込まれているために、呼び出しと返り値の対応が取れていないということです。1+2
と 3+4
をやったとき、3
がどちらの答えなのかわからないということです。
MessagePort を活用してワーカー呼び出しを整理する
ところで、SharedWorker との通信に利用している worker.port
の port
の実態は MessagePort というものです。
異なるコンテキスト間では、この MessagePort を介してデータをやりとりするわけですが、どんなデータでも渡せるというわけではなく、渡せるデータは一部の種類のオブジェクトに限定されています。この、MessagePort 経由で渡せるオブジェクトのことを transferable objectsと呼びます。詳しいリストはリンク先を参照してください。
注目すべきは、MessagePort それ自体も MessagePort に流せるということです。
これを活用すると、足し算 worker は以下のように整理できます。
const worker = new SharedWorker("worker.js");
worker.port.start();
function ipc(op, args) {
return new Promise((resolve) => {
const returnPort = new MessagePort();
worker.port.addEventListener("message", (event) => {
resolve(event.data);
});
worker.port.start();
worker.port.postMessage({op, args, returnPort});
});
}
async function add(a, b) {
return ipc("add", [a, b]);
}
console.log("1+2=", await add(1, 2));
console.log("3+4=", await add(3, 4));
function add(a, b) {
return a + b;
}
const ops = {add};
addEventListener("connect", (event) => {
const port = event.ports[0];
port.addEventListener("message", (event) => {
const {op, args, returnPort} = event.data;
const returnValue = ops[op](...args);
returnPort.postMessage(returnValue);
});
port.start();
});
やりました! ついに呼び出しの返り値の対応が取れるようになりました。しかも呼び出し側のコードがスッキリしています。
ついでに、かけ算機能も追加してみましょう。
const worker = new SharedWorker("worker.js");
worker.port.start();
function ipc(op, args) {
// 同じなので中略
}
async function add(a, b) {
return ipc("add", [a, b]);
}
async function mul(a, b) {
return ipc("mul", [a, b]);
}
console.log("1+2=", await add(1, 2));
console.log("3+4=", await add(3, 4));
console.log("5*6=", await mul(5, 6));
function add(a, b) {
return a + b;
}
function mul(a, b) {
return a * b;
}
const ops = {add, mul};
addEventListener("connect", (event) => {
// 同じなので中略
});
メソッドを追加するのも簡単です!
というわけで、C2A DevTools でも同様のテクニックを使って、ワーカーの呼び出しを整理しています。ただし、ストリームを扱う都合でもうちょい複雑なことになっています。
コードも公開されているのでご笑覧ください。
アークエッジ・スペースではReactやTypeScriptで管制システムを作りたい仲間を募集しています
最後に宣伝です。「人工衛星作ってます」って言うとなんか距離感じるって方が多いんですが、地上システムの日々のエンジニアリングはこんな感じで普通のウェブです。
人工衛星の開発環境や管制システムにイケてるウェブフロントエンドを持ち込んじゃおっかな、って方の応募をお待ちしています。一緒に作りましょう。全部いい感じにするぞ。
エントリー・カジュアル面談はこちらから:
Discussion