表題まま。
前回記事を書いたように、ChromeのM73からM75までの間、`RTCQuicTransport`のOrigin Trialがはじまります。
というわけで、`RTCQuicTransport`を使ったなにかを作ろうと思ってやってみたという話です。
成果物
ソースコードはこちら。
GitHub - leader22/rtcquic-chat-example: Need to register Origin-Trial by yourself ;D
っても、挙動的になにかが大きく変わるのではないので、ほとんどの人は何の感動もないですw
とりあえず試す用なので雑に作ってあって、
- VanillaICEで手動シグナリング
- 2タブ(A, B)を開いて
- Aの上段でGETしてコピー
- Bの下段にペーストしてSET
- Bの上段に出たのをコピー
- Aの下段にペーストしてSET
- テキストしか送れません
- 詳細は後述
です。
前提知識
今回Chromeに実装されたやつと、本家仕様には乖離があります・・。
- RTCQuicTransport
- RTCQuicStream
- RTCIceTransport
これらが本家の仕様ですが、今回実装されてるのはその一部分 + 微妙に違うAPIたち。
ここに載ってるのがすべてであり、それが`googRTCQuicTransport`では?とか揶揄される理由。
コード例も、OriginTrial開始の記事にさらっと書いてあるくらいで、全然ありません\(^o^)/
WebRTCHacksのこの記事のほうが詳しいくらい。
まぁそんな中でとりあえず動くものを作ったので、とりあえず学びをブログにしとくかーというモチベーションです。
まずはそれぞれのクラスについて書いていきます。
RTCIceTransport
何より先にまずICE。
const ice = new RTCIceTransport(); // 従来のWebRTCと同じ ice.addEventListener('icecandidate', ev => { if (ev.candidate === null) { // gathering done } }); // candidateを集める ice.gather({ gatherPolicy: 'all', iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); // シグナリングで相手に渡したいものを互いに送りあう const candidates = ice.getLocalCandidates(); const params = ice.getLocalParameters(); // 相手から受け取ったやつを渡す for (const candidate of candidates) { ice.addRemoteCandidate(candidate); } // type iceRole = 'controlling' | 'controlled'; ice.start(params, iceRole); // つながるのを待つ ice.addEventListener('statechange', ev => { if (ev.target.state === 'connected') { // ... } });
- 紹介記事のコード例では`RTCQuicTransport`ごっちゃに扱われてるけど、独立して使える
- 見つかった経路はその都度送って`addRemoteCandidate()`してもいいし
- 上のコードにあるようにまとめてやってもいい
- `start()`と`addRemoteCandidate()`の順番もどっちが先でもいい
- `getLocalXxx()`があるように、`getRemoteXxx()`も生えてる
- 経路が決まったら、`getSelectedCandidatePair()`で組み合わせが取れるようになる
さよならSDP。
ORTCのソレとも違うけど、シンプルなので使い方にも特別迷うことはないはず。
RTCQuicTransport
ICEがつながったら次にQUICをつなげる。
// 独立して使えるけど、引数で渡す必要がある const quic = new RTCQuicTransport(ice); // これを相手に渡して待つ const key = quic.getKey(); quic.connect(); // 受け取った側 quic.listen(key); // つながるのを待つ quic.addEventListener('statechange', ev => { if (ev.target.state === 'connected') { // ... } });
- QUIC用語では、クライアントとサーバーに概念が分かれるらしい
- `connect()`する側がクライアント
- `listen(key)`する側がサーバー
- 使ったことないけど、`getStats()`も生えてる
- QUICのコネクションを張れば、その中でストリームがN本作れる
getKey()
16byteの`ArrayBuffer`が取れる。
なので、これをそのまま送れるシグナリング方法じゃない場合は、ひと手間必要。
// from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String export function ab2str(buf) { return String.fromCharCode.apply(null, new Uint16Array(buf)); } export function str2ab(str) { const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char const bufView = new Uint16Array(buf); for (let i = 0, strLen = str.length; i < strLen; i++) { bufView[i] = str.charCodeAt(i); } return buf; }
みたく、一旦`string`にして相手に送ればOK。
これを踏まえて
従来のAPIはこのへんの処理を`RTCPeerConnection`がやってくれてたけど、そのレイヤーはまだ実装されてない。
なので、自作したのがこのファイル。
https://github.com/leader22/rtcquic-chat-example/blob/master/src/chat-app/quic-peer-connection.js
という具合で、単にQUICをつなげるだけなら割と簡単にできる。
問題はここから・・・!
QuicStream
個人的に一番やりづらかったのがこいつ。
WriteとReadをそれぞれ細かく見ていく。
Write
const quicStream = quic.createStream(); // 書き込み quicStream.write({ // Uint8Arrayのみ data: new TextEncoder().encode('Hello, quic!'), finish: false, });
- 引数は`data`と`finish`の2つ
- `data`は`Uint8Array`のみ
- `finish`はそのストリームの終端を表す
- 一度`finish`すると、`write()`できなくなる
どれくらいバッファリングを真面目にやるかによって、コードが変わる。
いちおう`waitForWriteBufferedAmountBelow()`ってメソッドがあって、いわゆる書き込み待ちができる。
Read
`waitForReadable()`と`readInto()`がカギ。
quic.addEventListener('quicstream', async ev => { const quicStream = ev.stream; // 最初に読み出し可能になるまで待つ await quicStream.waitForReadable(1); _readData(quicStream); }); async function _readData(quicStream) { // 今読み出せる分だけ全部読む const buffer = new Uint8Array(quicStream.readBufferedAmount); quicStream.readInto(buffer); // stringならデコードできる const message = this.decoder.decode(buffer); // ... // 次の書き込みを待つ await quicStream.waitForReadable(1); this._readData(quicStream); }
- `quicstream`イベントは、最初に`write()`されるまで呼ばれない
- これ仕様なんだろうか
- `waitForReadable()`は必須
- とりあえず全受信したいなら、`1`とか渡せばいい
- 再帰で`await`して置いておけば、そのストリームに対する書き込みは全部拾える
このあたりのAPIの取り回しは、NodeJSのStreamとかと一緒なので、まあ慣れかなーと。
ストリームをどう扱うか
このコード例では、単一のQUICコネクションの上で単一のストリームを張って、その中で全部やってる。
ただやり方としては他にもあって、いわゆるUnreliableな使い方。
その場合は、1メッセージごとに1ストリームを作ればいいだけ。
// write quicStream.write({ data: new TextEncoder().encode('Hello, quic!'), finish: true, // 毎回終わらせる }); // read quic.addEventListener('quicstream', async ev => { const quicStream = ev.stream; await quicStream.waitForReadable(1); const buffer = new Uint8Array(quicStream.readBufferedAmount); quicStream.readInto(buffer); const message = this.decoder.decode(buffer); // ... });
どっちがいいとかではなく、どっちでもいい。
ただまあだいたいのケースではReliableに使いたいと思うので、1ストリームでやればいいのでは・・と思う。
デカファイルの送信
これまで触れてなかったけど、ストリームを触る上で避けて通れないのがコレ。
- ブラウザがよしなにやってる送信量の調整しきい値がある
- NodeJSでいう`highWaterMark`
- Readなら`maxReadBufferedAmount`
- Writeなら`maxWriteBufferedAmount`
- これを超えるものを`write()`すると、1度の`readInto()`では読みきれない
というわけで、デカファイルを送るにはこのあたりも調整しないといけなくて、`readInto()`で読み出した`Uint8Array`を、面倒くさいけど連結してから処理する必要がある。(雑につなぐとbyteの境目でデータが壊れたりする)
ただそもそもの話として、デカファイルをブラウザのメモリに載せるなとか、そもそも載せてどうするんだとかあるので、ファイル共有を作りたい時以外はお世話にならんのでは・・?
テキストチャットでファイルも送信したい
とはいってもあるよねこういう要件・・。
今回のサンプル実装でも最初はそれを実装しようとしてたけど、あまりに面倒だったのでやめました。
つまりは複数のフォーマットを送信する場合、受信側でそれをさばくのが大変ということ。
なので、そういう実装をする場合にはどういうことをすればいいかメモしておきます。
- 単一ストリーム上でファイルを送るならまずその旨を通知しないといけない
- `readInto()`したものは`Uint8Array`なので、中身がわからない
- = それがテキストなのか画像なのかなんなのかわからない
- なので明示的にメタデータを送って、受け側で受け入れ準備をする
- あらかじめ知りたいMIME-TYPEとかLengthとか
- もしくは異なるストリームにして、異なるハンドラを置いておく
- でもストリームをまたぐとReliableではないし・・などなど悩ましい
的なことをまとめたものを一般的にはプロトコルといって、そのうちどこかの誰かが仕様を作ったりするんかなーと思ったのでやめました。
そもそも`RTCQuicTransport`は、いわゆるDataChannelとイコールではないはずなので。
まあ面倒ではあるがやれないことはないよという話でした。
その他のネタ
- UIは本旨ではないので雑にした
- ReactのHooks試そうかとも思ったけど、`lighterhtml`にした
- https://github.com/WebReflection/lighterhtml
- `hyperHTML`の後継で相変わらずシュッとしてる
- Materializeはまぁまぁ便利
- Chromeでしか使わないのでES Modules
- CDNで公開されてるライブラリまだまだ少ないね
そもそもWebRTCがニッチすぎるのもあってリアクションは期待してないけど、同業の人とか試してみたとか何かあれば、Twitterとかでリプください!