TT Lab
はじめる
学ぶ 学習パス コース

リアルタイム通信 — WebSocket・gRPC ストリーミング・WebRTC

シグナリングからデータチャネルまで WebRTC の 2 つのピアをつなぐ

TT Labで続きを見る

目標

WebSocketシグナリングサーバーとaiortcのピア2つを作って、オファー・アンサーをやり取りし、信頼性・順序を選んだデータチャネルでメッセージをやり取りしたあとで、ICE候補がどこで生じ、STUNが塞がれると何が遅くなるかを測ります。

なぜ重要なのか

WebRTCは、ブラウザーと携帯電話の間で、音声を最も短いレイテンシで運ぶ標準です。ところが、接続を結ぶ方法は仕様が決めていないので、シグナリング・ICEサーバーの設定・候補の収集を、サービスが自分で責任を負います。「会社のネットワークでだけ接続できない」「接続まで5秒かかる」という障害の大半が、この部分で起こります。同じPodの中なのでNATはありませんが、そのおかげで、候補とSTUNの役割を、余計なものなしに見られます。

ステップ

  1. シグナリングサーバーを作る: /root/rt/webrtc/signaling.pyにrun(host, port)コルーチンを作ってください。websocketsサーバーで/room/<名前>パスを受け付け、同じ部屋につながった2つの接続の間で、テキストメッセージをそのまま渡します。相手がまだいないときに来たメッセージは、ためておき、相手がつながった瞬間に順番に渡します。3つ目につながる接続は、クローズコード4001、理由"room full"で閉じ、片方が抜けたら、残った側に{"type": "bye"}を送ります。
  2. オファーSDPを作る: /root/rt/webrtc/peer.pyにmake_offer()コルーチンを作ってください。ICEサーバーのリストを空にしたRTCConfiguration(iceServers=[])でRTCPeerConnectionを作り、"chat"データチャネルを作ったあと、createOfferとsetLocalDescriptionを経て、localDescription.sdp文字列を返します。返す前に、接続はcloseします。
  3. SDPを読む: /root/rt/webrtc/sdp.pyにsummarize_sdp(sdp)を作ってください。SDP文字列を読んで、dictを返します。キーは、ufrag(最初のa=ice-ufragの値)、fingerprint(最初のa=fingerprintのハッシュ名を小文字で、例: sha-256)、setup(最初のa=setupの値)、media(m=行のメディアの種類を、出てきた順に入れたlist)、candidates({"host": n, "srflx": n, "relay": n}という形で、a=candidate行のtyp別の個数、ない種類も0)の5つです。行末は、\r\nや\nのどちらも来ます。
  4. アンサーするピア: /root/rt/webrtc/peer.pyにanswer_peer(signal_url, room, out_path)コルーチンを追加してください。signal_url/room/につながって{"type": "offer", "sdp": ...}を待ち、受け取ったら、setRemoteDescription → createAnswer → setLocalDescriptionのあとで{"type": "answer", "sdp": ...}を送ります。相手が開いたデータチャネルのメッセージには、前に"echo:"を付けて返し、チャネルのlabel・ordered・maxRetransmitsをJSONでout_pathに書きます。{"type": "bye"}を受け取ったら、接続を閉じて終わります。
  5. オファーするピア: /root/rt/webrtc/peer.pyにoffer_peer(signal_url, room, count)コルーチンを追加してください。"chat"データチャネルを作ってオファーを送り、answerを受け取ってsetRemoteDescriptionしたあと、チャネルが開いたら、"m0"からcount個を1つずつ送り、echoを受け取るたびに往復時間を測ります。{"sent": count, "echoed": 受け取った数, "rtt_ms": 往復ミリ秒のlist}を返します。
  6. 遅れたデータは捨てるチャネル: offer_peerのデータチャネルを、ordered=False、maxRetransmits=0で作ってください。採点ツールの基準のアンサー側が、受け取ったチャネルの属性を記録して確認します。
  7. 候補がどこから来るかを見る: /root/rt/webrtc/peer.pyにgather(stun=None)コルーチンを追加してください。stunが与えられたらRTCIceServer(stun)を1つ、そうでなければ空のリストをICEサーバーとして使い、データチャネルを1つ作って、createOfferとsetLocalDescriptionにかかったミリ秒を測ります。{"elapsed_ms": ミリ秒, "candidates": [(typ, アドレス, ポート), ...]}を返します。採点ツールは、STUNなし、同じPodの中のSTUNサーバー、届かないSTUNアドレスの3通りで呼び出します。

参考

シグナリングサーバーを作る

/root/rt/webrtc/signaling.pyにrun(host, port)コルーチンを作ってください。websocketsサーバーで/room/<名前>パスを受け付け、同じ部屋につながった2つの接続の間で、テキストメッセージをそのまま渡します。相手がまだいないときに来たメッセージは、ためておき、相手がつながった瞬間に順番に渡します。3つ目につながる接続は、クローズコード4001、理由"room full"で閉じ、片方が抜けたら、残った側に{"type": "bye"}を送ります。

WebRTCの仕様は、SDPをどう渡すかを決めていません(JSEP、RFC 9429も、その部分をアプリケーションに任せています)。そのため、ほとんどすべてのサービスが、このような小さな中継サーバーを別に置きます。4000–4999は、RFC 6455がアプリケーションの分として残しておいたクローズコードです。

オファーSDPを作る

/root/rt/webrtc/peer.pyにmake_offer()コルーチンを作ってください。ICEサーバーのリストを空にしたRTCConfiguration(iceServers=[])でRTCPeerConnectionを作り、"chat"データチャネルを作ったあと、createOfferとsetLocalDescriptionを経て、localDescription.sdp文字列を返します。返す前に、接続はcloseします。

SDPにm=行ができるには、オファーの前に、何を送るか(データチャネルかメディアトラック)を決めておく必要があります。aiortcは、ICEサーバーを指定しないと公開STUNサーバーを使いますが、外へのUDPが塞がれた場所では、その応答を待つために、候補の収集が数秒止まります。

SDPを読む

/root/rt/webrtc/sdp.pyにsummarize_sdp(sdp)を作ってください。SDP文字列を読んで、dictを返します。キーは、ufrag(最初のa=ice-ufragの値)、fingerprint(最初のa=fingerprintのハッシュ名を小文字で、例: sha-256)、setup(最初のa=setupの値)、media(m=行のメディアの種類を、出てきた順に入れたlist)、candidates({"host": n, "srflx": n, "relay": n}という形で、a=candidate行のtyp別の個数、ない種類も0)の5つです。行末は、\r\nや\nのどちらも来ます。

hostは自分のインターフェースのアドレス、srflx(server reflexive)はSTUNサーバーが見た外側のアドレス、relayはTURNサーバーが貸してくれたリレーアドレスです。fingerprintはDTLS証明書のハッシュで、シグナリングで渡ってきたこの値と、ハンドシェイクのときに受け取った証明書が合ってはじめて、接続できます。

アンサーするピア

/root/rt/webrtc/peer.pyにanswer_peer(signal_url, room, out_path)コルーチンを追加してください。signal_url/room/につながって{"type": "offer", "sdp": ...}を待ち、受け取ったら、setRemoteDescription → createAnswer → setLocalDescriptionのあとで{"type": "answer", "sdp": ...}を送ります。相手が開いたデータチャネルのメッセージには、前に"echo:"を付けて返し、チャネルのlabel・ordered・maxRetransmitsをJSONでout_pathに書きます。{"type": "bye"}を受け取ったら、接続を閉じて終わります。

データチャネルはオファーした側が作り、アンサーする側はdatachannelイベントで受け取ります。アンサーするピアでも、ICEサーバーのリストは空にしておいてください。採点ツールの基準のオファー側が、自分のシグナリングサーバーを経由してつながります。

オファーするピア

/root/rt/webrtc/peer.pyにoffer_peer(signal_url, room, count)コルーチンを追加してください。"chat"データチャネルを作ってオファーを送り、answerを受け取ってsetRemoteDescriptionしたあと、チャネルが開いたら、"m0"からcount個を1つずつ送り、echoを受け取るたびに往復時間を測ります。{"sent": count, "echoed": 受け取った数, "rtt_ms": 往復ミリ秒のlist}を返します。

チャネルのopenイベントの前に送ると、メッセージが消えます。ICE確認→DTLSハンドシェイク→SCTP接続がすべて終わってはじめてopenです。ここまでにかかった時間が、WebRTCの接続準備のコストです。

遅れたデータは捨てるチャネル

offer_peerのデータチャネルを、ordered=False、maxRetransmits=0で作ってください。採点ツールの基準のアンサー側が、受け取ったチャネルの属性を記録して確認します。

音声フレームやカーソル位置のように、20ms後には役に立たないデータは、失った1つの断片を再送するために後ろのものまで引き留めるより、捨てるほうがよいです。SCTPは、チャネルごとに順序と再送を別々に決められ、これが、TCPの上のWebSocketでは真似できない点です。

候補がどこから来るかを見る

/root/rt/webrtc/peer.pyにgather(stun=None)コルーチンを追加してください。stunが与えられたらRTCIceServer(stun)を1つ、そうでなければ空のリストをICEサーバーとして使い、データチャネルを1つ作って、createOfferとsetLocalDescriptionにかかったミリ秒を測ります。{"elapsed_ms": ミリ秒, "candidates": [(typ, アドレス, ポート), ...]}を返します。採点ツールは、STUNなし、同じPodの中のSTUNサーバー、届かないSTUNアドレスの3通りで呼び出します。

a=candidate行は、空白で分けると、5番目がアドレス、6番目がポート、typの次が種類です。同じPodの中にはNATがないので、srflxのアドレスが何と同じになるかを、先に予想してみてください。