先日、Chrome Tech Talk Night #11に参加してきました。
その際にGoogleのWeb Developer Relations LeadであるPaul Kinlanが IPC on the web with Comlink
という発表をされていたのですが、Comlinkやその前提知識となるWebWorkerについて知識がなかったので発表の内容がほとんど理解できませんでした(発表の英語がかなり早口だったのも原因だったと思うけど)。これではせっかく来日されたPaulに申し訳ない気がします。というわけで、この記事ではComlinkやその基盤技術であるWebWorkerについてまとめてみました。
Comlink
ComlinkとはWebWorkerをenjoyableにするライブラリのことです。enjoyableとはなんぞやという気持ちになってきますが、公式がそう言っているのです。
そもそも自分はWebWorkerにも詳しくなかったので、先にこちらをまとめてみます。
WebWorker
JavaScriptは単一スレッド環境であり、複数のスクリプトを同時に実行することはできません1。WebWorkerはそんなJavaScriptをマルチスレッドで実行できるようにする仕組みです。ただし、マルチスレッド化の代償として、利用可能なオブジェクトの制限等の制約は加えられています。
また、それぞれのWorkerはそれぞれに独立したメモリ空間を持ちます。Worker AとWorker Bがあった場合、Worker AはWorker Bの値にアクセスできませんし、逆も同じです。そこでWorkerではpostMessage
メソッドとmessage
イベントを使用してオブジェクトを送信しあいます2。この仕組みはメッセージングと呼ばれます。
具体的なコードを見てみましょう。以下が最もシンプルなWebWorkerの例です。
const worker = new Worker('worker.js');
worker.postMessage('Hello World!');
self.addEventListener('message', (message) => {
console.log(message.data);
});
WebWorkerはいくつかの種類に分かれます3。いま説明してきた最もシンプルなWebWorkerはDedicatedWorkerで、この他にも複数のブラウザコンテキストからアクセス可能なSharedWorkerなどもあります。ServiceWorkerもWebWorkerの一種です。
日本語で調べてみると、WebWorker(DedicatedWorker)の例は、高負荷な処理をマルチスレッド化するようなものが多いようです。このような例だと実際のサービスでWebWorkerが使われていてもなかなか意識しにくいですね。
もっとわかりやすい例として、外部サービスとID連携するようなサービスが挙げられます。「元のサイトから外部サービスのログイン画面等に遷移し、ログインが成功すると、元のサイトのページが勝手に書き換わっている」といった動作には見覚えがあるのではないでしょうか。
Comlink
WebWorkerはメッセージングの仕組みしか持たない比較的プリミティブなAPIです4。しかしながら、メッセージングの仕組みだけで複雑なアプリケーションを作ろうとすると、クライアントとオーナーの両方でメッセージを受け取った履歴に応じた状態マシンを実装しなければならず、非常にしんどいです5。
この問題を解決するために、MessageChannelとpostMessageを抽象化するAPIだけを持つComlinkというライブラリが開発されました。WebWorkerをenjoyableにするとはこういうことだったわけです。
ComlinkはWebWorkerのメッセージベースのAPIをただの値を扱うかのようなものに置き換えました。イメージ的にはjsonを送り合うREST APIがgRPCに置き換わったようなものと言えるでしょうか。
一番簡単な例
以下のようにComlink.proxy
を介すだけでclient.jsからMyClass
がまるでその場にあるようにして利用することができます。
const MyClass = Comlink.proxy(new Worker('worker.js'));
const instance = await new MyClass();
await instance.logSomething();
const myValue = 42;
class MyClass {
logSomething() {
console.log(`myValue = ${myValue}`);
}
}
Comlink.expose(MyClass, self);
API
Comlinkモジュールは3つの関数だけをexportしています。
1. Comlink.proxy(endpoint)
endpointにWindow
, Worker
, MessagePort.*
のいずれかを指定すると対象のendpointでexposeされたオブジェクトを利用することができるようになります。
2. Comlink.expose(obj, endpoint)
オブジェクトをexposeできます。
3. Comlink.proxyValue(value)
WebWorkerのpostMessageは値をコピーしてWorkerに渡すので大きな値を扱っているとそれだけで大きなオーバーヘッドになってしまいます。Comlinkで実行する関数も通常は値をコピーしますが、Comlink.proxyValue
を使うことで値はプロキシされます。
const api = Comlink.proxy(new Worker('worker.js'));
const obj = { x: 0 };
// await api.setXto4(obj); obj.xは0のまま
await api.setXto4(Comlink.proxyValue(obj)); //obj.xは4になる
console.log(obj.x);
WebRTCを用いた例
WebRTCを用いれば他のクライアントに表示されたウィンドウを操作することもできます。この例では相手のウインドウの背景色を変えたり、相手のコンソールに文字列を出力したりしています。まずは実際に遊んでみるのが早いと思うので引用元のツイートから動画とWebページをご参照ください。この例はかなり面白いと思います。ぜひご参照ください。
引用元: https://twitter.com/DasSurma/status/915964976946405377
このデモは今までの例のようにファイル単位での主従関係はないので、オーナーのコードとクライアントのコードが同じ場所にあります。が、かなりわかりやすいです。もとのコードを一部抜粋してコメントを追記しました。
// オーナーが呼び出すことのできるクライアントのコード
const exposedThing = {
changeBackgroundColor: (r, g, b) => {
document.body.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
},
log: console.log.bind(console), // これができるんだからどんな関数だって渡せてしまいます
getWindow: _ => Comlink.proxyValue(window), // proxyValueを用いることで任意のオブジェクトが渡せます
};
// このコードではHTML部分は省略しました。roomInputはルームキーの入力フォームを表しています。
const roomInput = document.querySelector('#roomselect > input');
// roomに参加するボタンが押されたとき
document.querySelector('#roomselect > button').onclick = async event => {
event.target.disabled = true;
event.target.textContent = 'Waiting for other participant...';
const room = roomInput.value;
if(!room) return;
// WebRTCで相手のクライアントとのチャンネルを作成
// 実装はこちら: https://glitch.com/edit/#!/comlink-webrtc?path=static/webrtc-dialup.js:2:0
const {channel, role} = await personInRoom(room);
document.body.classList.add(role);
// WebRTCのチャンネルをComlinkの扱うendpointに変換しています
const comlinkChannel = MessageChannelAdapter.wrap(channel);
if (role === 'owner') {
// オーナーならチャンネルに接続
proxy = Comlink.proxy(comlinkChannel);
} else {
// クライアントなら公開したいオブジェクトをチャンネルで公開
Comlink.expose(exposedThing, comlinkChannel);
}
};
function getColor(id) {
return document.getElementById(id).value;
}
document.querySelector('#owner').addEventListener('input', async _ => {
await proxy.changeBackgroundColor(getColor('r'), getColor('g'), getColor('b'));
});
RTCPeerConnectionから作られたchannelをComlinkのMessageChannelAdapterというアダプターで包んであげるとendpointとして使うことができるようになります(正直この仕組みはわかってないけど大変そうだということだけは伝わってくる)。WebRTCの辛いAPIを使うのははじめだけで、あとはComlinkを経由してコントロール対象のウィンドウのメソッドを実行できています。
Comlink.proxyValue
を使って window
まで公開しちゃってます。これができるんだから何でもできますね。
The Web is my API
発表者のPaul Kinlanが自身のブログでThe Web is my APIという構想を語っています。
これはComlinkのような仕組みを使ってクライアントサイドにAPIを提供(expose)すれば、ユーザーはそれらを組み合わせて高度な処理を実現できるのではないかという提案です。WebサービスはCRUDのような基本的な操作だけを公開しておけば、UNIXでシンプルなコマンドを組み合わせて高度な処理を実現するようなことが、Webでもできるというのです。
この記事は2017年の8月の記事なので、Chrome Teck Talk Nightではさらに踏み込んだ話がされていたような気がするのですが、言語の壁に阻まれて詳細はよくわかりませんでした…。えーじさんのライブ翻訳ツイートによれば「ハブになるサイトを作り、そこを中心にcomlinkを介してメッセージを交換し合う」6とのことですが、どういうことなのでしょう…?
-
https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API ↩
-
正確に言うとこれは嘘でWorker間でメモリ共有を可能にするSharedArrayBufferと、それをスレッドセーフに行うためのAtomics APIというものがあります。が、明らかにしんどそうな見た目をしています。 https://sbfl.net/blog/2017/10/31/sharedarraybuffer-atomics/ ↩