これは CAMPFIRE Advent Calendar 2024 の 13日目の記事です。
他の方がCAMPFIREに関連したことを書く中、去年に引き続き、あまりCAMPFIREとは関係なく記事を書きます(去年は量子コンピューティングでした)。去年よりはWeb技術なのでCAMPFIRE寄りの内容です。
さて、みなさんはWebのリアルタイム通信、双方向通信といえば、何を思い浮かべるでしょうか?
おそらく、WebSocketが一番多いのではないでしょうか。ちなみに私もそうです。
それ以外にもW3Cで標準化済みやドラフトの技術として、Server-Sent EventsやWebRTC、WebTransportなどがあります。
今回はWebサイト開発をしているだけだとなかなか触れる機会の少ない、Webのリアルタイム通信や双方向通信の技術に触れていきたいと思います。前半部で各技術の概要、後半部で通信に利用されているプロトコル等や各ブラウザでのAPIの実装例をみていく形にします。
業務で利用する際には、この記事ではなく、仕様はきちんと各W3Cの資料やRFC、またAPIについてはMDNや各ライブラリ等のドキュメントなどを参考にしてください(MDNについては、英語のほうが詳細である場合もあるため、日本語だけでなく英語のドキュメントも参考にすることをおすすめします)。
また、この記事ではTCP/UDPの違いやHTTPの基本的な情報は前提として記載している点にもご注意ください。
目次
Webのリアルタイム通信、双方向通信の技術の概要
これまでのWebのリアルタイム通信、双方向通信の技術を(雑に)追いつつ、それぞれの技術の概要を把握していくことにします。
ポーリング
ポーリングは、双方向通信ではありませんが、クライアントのユーザー操作なしに定期的にサーバ上のイベントや状態変化を反映することができる手法です。
ポーリングは、非常にシンプルな手法です。
定期的にクライアントからサーバにリクエストを送信して、サーバから更新情報を受け取って処理するだけです。
ただし、この手法には多くの欠点があります。
まず、高い頻度でリクエストしなければ、サーバの更新からクライアントへの反映までが遅くなります。一方で頻度を高くしすぎると、サーバで更新がないにも関わらず何度もリクエストが送信されるため、サーバの負荷が高くなり、不要にサーバ側のスペックが必要になってしまいます。
- 特徴
- サーバからの一方向通信に利用可能
- HTTP/1.1でも利用可能
- メリット
- サーバ、クライアントともにほとんど通常のHTTPのリクエスト・レスポンス処理になるため、非常にシンプル
- デメリット
- リアルタイムな通知が難しい
- リアルタイム性を上げようとするとサーバへのリクエスト頻度が高くなる
- 不要なリクエストが多く発生するため、無駄にサーバのスペックを上げる必要がある
ロングポーリング(Comet)
ポーリングの手法の欠点を解消する手法として、ロングポーリングが考えられました。
ロングポーリングでは、サーバ側でリクエストが来た際に、レスポンスすべき更新があるまでレスポンスを返しません。レスポンスすべき更新があって初めてレスポンスを返します。
これにより、更新があるまで何度もリクエストしなければならない点が解決できます。
一方で、サーバの更新頻度が高い場合の遅延や、更新に対してリクエストのしなおし(接続しなおし)のコストがかかるという欠点があります。
- 特徴
- サーバからの一方向通信に利用可能
- HTTP/1.1でも利用可能
- メリット
- 通常のリクエスト・レスポンスの形に近く、シンプル
- レスポンスを返す更新がない期間でのリクエストが不要
- デメリット
- レスポンスに関わる更新頻度が高いと、遅延しやすくなる
- レスポンスがあると、再度接続し直す必要がある
- HTTPリクエストの接続をし続ける必要がある
Server-Sent Events(SSE)
ロングポーリングから、接続しなおしといった課題を解決できる手法として、Server-Sent Events(SSE)があります。
SSEは、前述のCometのロングポーリングとChunkedレスポンスで実現されており、これまでの技術を活用した仕組みです。
最初のリクエスト以降、サーバからは更新があるたびに、chunkとしてレスポンスを返すため、レスポンスを返しても接続は終了しません。そのため、レスポンスがあるたびに接続し直す必要がありません。
この技術はChatGPTのレスポンスなどにも利用されています。
一方、この時点では実はまだ双方向通信は実現していません。あくまで基本的にサーバからクライアントへの一方向通信のみです。
また、接続し続ける必要があるため、サーバ側のリソースを消費しやすい欠点もあります。さらに、他のリクエストを送りたい場合には、別の接続をする必要が出てきます。
- 特徴
- サーバからの一方向通信に利用可能
- HTTP/1.1でも利用可能
- メリット
- 更新頻度が高くても、リアルタイムにクライアントへ通知できる
- 接続が切れるまでは、レスポンスがあっても再度リクエストする必要がない
- デメリット
- 接続し続けるため、サーバ側のリソースを消費しやすい
- 同じ接続でクライアントからリクエストができない
WebSocket
Webにおける双方向通信の技術として、WebSocketがあります。
WebSocketでは、ここまで紹介したロングポーリングやSSEと違い、HTTPを直接使うのではなく、データのやり取りには新しく通信プロトコルが標準化されています。
WebSocketは、WebSocketプロトコルを利用することでより軽量なデータのやり取りを行うことができます。また、クライアント-サーバ間の双方向通信をWebSocketのコネクションで実現できます。
そして、必要な通信をすべて単一のコネクションで行うことができるため、接続をし直す必要が減らせる利点もあります。
WebSocketでひとまず双方向通信について解決したように見えますが、WebSocketも万能ではありません。
WebSocketでは、TCP上の通信を前提としているので、到達保証などの信頼性よりもリアルタイム性を重視する場合には向いていません。ネットワーク環境等の影響により、トランスポートレイヤーレベルのHead of Line(HoL)ブロッキングといった事象により、遅延する可能性もあります。
- 特徴
- クライアント-サーバ間の双方向通信に利用可能
- HTTP/1.1でも利用可能
- メリット
- 単一のコネクションで双方向通信ができる
- リアルタイムに双方向の通知ができる
- デメリット
- TCPを前提としていて再送処理等が行われるため、映像配信やクラウドゲーミングなどの低レイテンシに用途には向いていない
- トランスポートレイヤーレベルのHoLブロッキングが発生する
WebRTC
WebSocketのリアルタイム性に対する解決の一つとして、WebRTCがあります。
WebRTCには、APIの大枠として、メディアやデバイスの制御、Peer間の通信経路確保、メディア通信(メディアトランスポート)、データ通信(データチャンネル)があり、通信部分以外の機能提供も含めてWebRTCに含まれています。
WebRTCでは、TCPではなくUDPを利用しています。これによりTCPでは実現しにくい細かな最適化を達成しています。また、メディア通信のためのメディアトランスポートと、データ通信のためのデータチャンネルがあり、この2つで特徴が異なります。
メディアの通信については、順序保証、再送を行います1。一方、データのやり取りについては、順序保証や再送についても設定することができ、パフォーマンスと信頼性のトレードオフを調整して通信を行うことができます。
また、WebRTCの実際のソリューションとして、直接ピア同士を接続するP2P通信以外にも、Client-Server型のMCU/SFUを利用した通信を行う方法も取られています。現在、ビジネス用途では、SFUを活用していることが多いようです。
WebRTCは既存の技術を複数組み合わせて作られていますが、P2P通信を達成するための技術や複数の技術の活用やUDPを利用を含み、通常のWeb通信技術と大幅に異なります。そのため、現実のユースケースとしてはやや難易度が高くなっていますが、普及しているため、ライブラリやフレームワーク、サービス等は一定拡充しています(そもそも、WebRTCを使うような要件自体の難易度が高いというのもあるとは思います)。
WebRTCは、WebSocketでも解決できなかった、リアルタイム性重視のクライアント間のP2P通信を実現することができます。
- 特徴
- ピア間の双方向通信に利用可能
- UDPを利用している
- メリット
- メディア配信、データ通信の両方の用途に対応している
- P2P通信が可能なため、少数ピア間においてはサーバを経由しない低レイテンシ・高スループットを実現しやすい
- データ通信では、信頼性とパフォーマンスのトレードオフの選択が可能
- デメリット
- P2P通信でもグローバルネットワークでは、NATトラバーサルのためのSTUN/TURNサーバが必要になる
- 複数のHTTP以外の技術を利用しており、技術的な実装がやや複雑
- 高レイヤーAPIで構成されており、用途がやや限定的
- 多数のピア間の通信で利用する場合は、MCU/SFUサーバを配置する必要がある
WebTransport
Webにおける双方向通信は、現在、WebSocket、WebRTCが一般的に利用されています。
この中で、クライアント-サーバ間での双方向通信を考えるとき、WebSocketがフィットする技術になりますが、クラウドゲーミングやIoT、ストリーミング配信などの低遅延を満たしたい場合には必ずしもマッチしません。さらに、TLSを利用しない通信の発生する可能性があるなどの欠点もあります。
一方、WebRTCでの通信では、基本的にNATトラバーサルやP2P通信を前提としているため、大規模なサーバ/クライアント間の通信で考えると複雑な処理となってしまいます。また、ユースケースがある程度限定的で柔軟性は高いとは言えません。
この問題を解決するために、WebTransportが現在ドラフトで進行中になります。
WebTransportは、HTTP/3 、QUIC(UDPベースのプロトコル)を利用しており、TCPを利用しているHTTP/2と比べ、HoLブロッキングを避けることができます。また、HTTP/3が利用できない環境に対してHTTP/2へバックポートも想定されています(当たり前ですが、この場合はパフォーマンスが低下します)。
また、HTTP/3、QUICの登場により、これらの機能を活用したHTTP/2以前までに仕様化され実現されていたユースケースに、HTTP/3、QUIC、WebTransportで対応していこうという動きもあります。
WebTransportは、新しく、まだドラフトであるため、周辺環境が整っている状況ではありません。またAPI等の柔軟性がありますが、その分、現時点では自身のユースケースに自前でフィットさせる必要があります。まだDraftであり、公式サポートしていないブラウザ2もあります。
- 特徴
- 多重化転送
- 一方向/双方向ストリームとデータグラム通信
- 基本的にはHTTP/3(UDP)が前提
- メリット
- 低レイヤーAPIとなっており、柔軟性が高く、応用範囲が広い
- 信頼性とパフォーマンスのトレードオフの選択が可能
- HTTP/3上の構成となっており、HTTP/3を理解すれば、比較的技術理解しやすい
- デメリット
- パフォーマンスを出すには、HTTP/3に限定される
- HTTP/3が利用できない環境ではHTTP/2となるため、パフォーマンスが出せない
- 低レイヤーAPIになるため、用途に対して適用するのがやや難しい
- まだドラフトで、対応しているフレームワーク、サービスが少ない
各技術のまとめ
ポーリング | ロングポーリング | Server-Sent Events | |
---|---|---|---|
トランスポート | TCP | TCP | TCP |
通信プロトコル | HTTP/1.1 | HTTP/1.1 | HTTP/1.1 |
暗号化 | TLS(任意) | TLS(任意) | TLS(任意) |
通信対象 | Client-Server | Client-Server | Client-Server |
通信方向 | 一方向 | 一方向 | 一方向 |
信頼性 | 到達保証あり | 到達保証あり | 到達保証あり |
標準化 | - | - | 2006年 |
WebSocket | WebRTC(メディア) | WebRTC(データ) | WebTransport | |
---|---|---|---|---|
トランスポート | TCP | UDP | UDP | UDP(QUIC) |
通信プロトコル | 独自 | SRTP | SCTP | HTTP/3 |
暗号化 | TLS(任意) | DTLS | DTLS | TLS1.3(必須) |
通信対象 | Client-Server | Peer-Peer / Client-Server |
Peer-Peer / Client-Server |
Client-Server |
通信方向 | 双方向 | 双方向 | 双方向 | 一方向/双方向 |
信頼性 | 到達保証あり | 到達保証なし | 選択可 | 選択可 |
標準化 | 2011年 | 2021年 | 2021年 | ドラフト |
各APIでのブラウザ実装とプロトコル
ここから、SSE、WebRTC、WebTransportの、各APIの簡単なブラウザ側の利用とプロトコルの概要を記載していきます。なお、API利用やプロトコルは、すべてを説明するとかなり多くなるため、最低限に絞って記載していきます。各詳細は参考文献に記載のリンク先等を参照してください。
また、例とは異なる部分がありますが、最低限動作するサーバ、クライアントの実装を以下のリポジトリに作成しています。
https://github.com/naokirin/web_realtime_examples
Server-Sent Events(SSE)
まずはSSEです。SSEは、サーバからクライアントへの、単方向のテキストベースのストリーミングを実現します。
通信のプロトコル
SSEは、HTTPの通信で実装されています。
GET /listen HTTP/1.1
Host: localhost:3000
Accept: text/event-stream
SSEのサーバへのリクエストは、通常のHTTPリクエストです。
Accept
が text/event-stream
になっています。
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: text/event-stream
Transfer-Encoding: chunked
retry: 5000
data: stream data
event: stream-event-1
data: Multiple
data: lines
id: 123
event: stream-event-2
data: stream-event-2 data
レスポンスは、ヘッダー、複数フィールドを持つイベントになります。
ヘッダーは以下の形式です。
-
Connection
は接続を維持するため、keep-alive
-
Content-Type
はtext/event-stream
-
Transfer-Encoding
はchunked
イベントは以下の形式です。
-
event
、id
、data
、retry
のフィールドが存在する - 各イベントは、データとして複数の
data
フィールドを持つ - 1つのイベントが持つ複数の
data
フィールドは複数行のデータとなる - 各イベントは、イベントタイプとして
event
、イベントIDとしてid
を持つことができる -
retry
はクライアントの再接続時間(ミリ秒)をサーバが設定する
ここで、retry
というものが出てきました。
接続が切れると、ブラウザは自動で再接続をします。 retry
を設定している場合、この時間だけインターバルを挟んで再接続をします。このとき、接続が切れている間の情報を送り直したいといった要件も考えられます。
そういった要件に答えるため、SSEでは、ブラウザは最後に受け取ったid
の値を、再接続時のリクエストヘッダーLast-Event-ID
にのせてリクエストします。
サーバ側は、これをもとに、どの情報を送るか判断することができます(こちらのサーバサイドは基本的に自前で実装する必要があります)。
実装例
ブラウザ側(JavaScript)の実装を見ていきます。
const eventSource = new EventSource('/listen'); // EventSourceインターフェース
eventSource.onopen = () => ...; // 接続確立時のコールバック
eventSource.onerror = () => ...; // 接続失敗時のコールバック
eventSource.onmessage = (event) => { // イベント受信時のコールバック
console.log(event.data); // data にイベントのデータが格納される
if (event.id === 'CLOSE') { // イベントidが CLOSEの場合、SSE接続を終了する
eventSource.close();
}
};
APIはかなりシンプルです。
EventSourceインターフェースでコネクション、各コールバックでサーバからのイベントを処理します。
https://developer.mozilla.org/ja/docs/Web/API/EventSource
WebSocket
WebSocketは、WebSocketのコネクションを利用してクライアント-サーバ間の双方向通信を実現します。
通信のプロトコル
WebSocketのレイヤー構造は以下のようになります。
WebSocketには、大きく開始ハンドシェイクとバイナリメッセージフレーム化があります。
開始ハンドシェイク
WebSocketにおいて、HTTPアップグレードを利用して、クライアントからのハンドシェイクリクエストを行います(今回、拡張機能、サブプロトコルは省略します)。
GET /ws HTTP/1.1
Host: localhost:3000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
-
Upgrade: websocket
: WebSocketプロトコルへのアップグレードリクエストを行う -
Sec-WebSocket-Key
: サーバのプロトコルサポートを確認するための自動生成されるキー -
Sec-WebSocket-Version
: クライアントのWebSocketプロトコルのバージョン
サーバによるハンドシェイクのレスポンス例は以下のようになります。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
- レスポンスコードは101となります。これは、プロトコルへの切り替えを行うことを通知しています
-
Sec-WebSocket-Accept
: サーバがクライアントからのSec-WebSocket-Key
から導き出します-
Sec-WebSocket-Key
と258EAFA5-E914-47DA-95CA-C5AB0DC85B11
を文字列で連結し、SHA-1 hashをとり、base64エンコーディングした結果を返す必要があります
-
バイナリメッセージフレーム化
ハンドシェイクが成功すると、以降、サーバ、クライアントはエンコードされたメッセージを送信します。
- WebSocketバイナリフレームは、2〜14バイトの可変長ヘッダとペイロードを持つ
- 最初のビットは、フレームがメッセージの最後のフラグメントであるかを示す
- Opcodeは、データ送信ではテキスト:1、バイナリ:2、コントロールフレームでは接続終了:8、ping:9、pong:10 となる
- MASKは、ペイロードがマスクされているかを示す(セキュリティのため)
- ペイロード長は、以下のようにペイロードの長さを示す
- 0〜125の場合は、そのままペイロード長を示す
- 126の場合は、次の2バイトがペイロード長を示す
- 127の場合は、次の8バイトがペイロード長を示す
ルールはSSEに比べて多く、独自のバイナリフレームフォーマットを扱うことにもなりますが、低レイヤーでも比較的シンプルなフォーマットとなっています(今回はプロトコル拡張を省略しているため、ここにさらにプロトコル拡張の機能が追加される点には注意です)。
実装例
const ws = new WebSocket('/ws'); // WebSocket接続の開始
ws.onopen = () => ...; // 接続が確立したときのコールバック
ws.onmessage = (msg) => ...; // メッセージを受信したときのコールバック
ws.onclose = (event) => ...; // 切断されたときのコールバック
ws.onerror = (event) => ...; // エラーが発生したときのコールバック
ws.send("クライアントからのメッセージ送信"); // サーバに送信するメッセージ
// 何らかの処理...
ws.send("クライアントからのメッセージ送信 2回目");
WebSocketのブラウザ側のAPIは低レイヤーの実装がAPIに隠蔽されているため、かなりシンプルです。
ここで、 WebSocketインターフェースのコンストラクタにわたすURLについて補足します。
ここで渡すURLは ws
、wss
、http
、https
のURLスキームのどれかである必要があります。URLフラグメントは含められません。また、相対URLを指定した場合、呼び出し元スクリプトのURLをもとに、URLスキームを決定するようになっています。
ws
は非暗号化通信、wss
はTLSで暗号化された通信になります。
WebRTC
WebRTCは、ここまでと異なり、クライアント-サーバ間ではなく、基本的にはピア間の双方向通信を実現する技術です。
WebRTCは新規のWebRTCという通信プロトコルの開発がされているわけではなく、既存のプロトコルや技術を組み合わせて作られているソリューションです。それに合わせて、APIも作られています。
WebRTCに関連する技術について詳細を記載するとかなり量が多く、これだけで1記事以上のボリュームがあるため、基本的には概要に留めつつ記載します。
概要
WebRTCでは、APIとP2Pの通信フローの概要を記載します。
API
WebRTCは、まず(ブラウザ側での)APIとしては、おもに以下で構成されます。
- RTCPeerConnection: ローカルのコンピュータとリモートピアとの接続
- MediaDevices.getUserMedia: 各種メディア(カメラ、マイク、動画、音声等)へのアクセス
- RTCDataChannel: データチャンネルでの通信
WebRTCでは、複数の既存技術を組み合わせることで様々なユースケースへ対応できるようになっています。とくに、リアルタイムな通信やP2P通信への対応にメリットがあります。
WebRTCのP2P通信でのフロー
P2P通信でのフローの概要を記載します(ここで①、②は表現上の関係で順番に記載されていますが、複合的な動きとなります)。
①シグナリング(ICE、SDP)
WebRTCエージェントが、誰となにの通信をするかを決めるプロセスです。
ここで利用されるフレームワークをICEといいます。
ここで利用されるのが、SDPというプロトコルです(厳密にはデータ形式と呼ぶほうがよさそうです)。SDPはプレーンテキストのプロトコルで、キーバリューペアで構成されています。
重要なのは、以下のような各種メタデータを持っていることです。
- エージェントが到達可能なIPおよびポート(候補)
- エージェントが送信しようとしているオーディオおよびビデオトラック数
- 各エージェントがサポートするオーディオおよびビデオコーデック
- 接続時に使用される値(uFrag/uPwd)
- セキュリティ確保時に使用される値(証明書のフィンガープリント)
これにより、どのような通信経路が利用でき、どういったコーデックが利用できるか、何を送ろうとしているかといった情報をピア間で交換できます。また証明書のフィンガープリントがあることで安全な通信を開始できます。
実際のSDPは以下のような キーとバリューを =
で記載した複数の行で構成されます。
v=0
o=- 4962303333179871722 1 IN IP4 0.0.0.0
s=-
t=0 0
m=audio 10100 UDP/TLS/RTP/SAVPF 96 0 8 97 98
c=IN IP4 192.0.2.100
a=rtpmap:96 opus/48000/2
m=video 4002 RTP/AVP 96
c=IN IP4 192.0.2.100
a=rtpmap:96 VP8/90000
SDPで定義されるもののうち、WebRTCで重要なのは以下です。
-
v
: プロトコルバージョン。v=0
である必要がある -
o
: オリジン。セッションとセッション識別子とバージョン番号 -
s
: セッション名。s=-
である必要がある -
t
: タイミング。t=0 0
である必要がある -
m
: メディアの説明。フォーマットはm=<media> <port> <proto> <fmt> ...
-
a
: 属性。様々な属性を定義する(※) -
c
: 接続情報。c=IN IP4 0.0.0.0
である必要がある
(※)属性の詳細は RFC 9929 JavaScript Session Establishment Protocol (JSEP) を参照
WebRTCでは、ピアはこれらの情報をオファー/アンサー方式でやり取りします。
ここまでで、通信を開始するための各情報を取得できます。
ただ、通常、各ピアですべての情報が揃ってから送信するのではなく、インクリメンタルに情報収集と送信を行います。これはローカルの情報収集は高速ですが、ネットワーク通信が必要になるような情報収集では時間がかかるといったことがあるためです。
このICEの拡張を、TricleICEと呼びます。
②NATトラバーサルでの接続(STUN/TURN)
P2P通信において、NATを超える(NATトラバーサル)が最も大きな問題になることが多くあります。NATおよびNATトラバーサルの詳細はWeb上の文献に譲るとして、WebRTCでは、STUNサーバや、TURNサーバを利用して、ピア間の接続を行います。
STUNサーバはNATマッピングされたグローバルのアドレスを知ることに利用します。このアドレスがそのまま利用できる場合、ここで知り得たアドレスをもとにピア間の通信ができます。
一方、ファイアウォール等の制限などで、そのまま利用できない場合があります。この際には、TURNサーバと呼ばれる、ピアのプロキシをするサーバを経由した通信を行います。
③セキュリティ確保(DTLS、SRTP)
WebRTCでは基本的にデータ通信をUDP上で行うため、TCPを前提としたTLSによるハンドシェイクは行えません。そのかわりに、DTLSを用いてハンドシェイクを行います。
DTLSはTLSに似せて設計されていますが、TLSが前提とするTCPとは異なり、UDPは順序保証がありません。この問題に対して、DTLSでは、各レコードにフラグメントオフセットとシーケンス番号を追加しています。
また、パケットロスに対応する必要もあります。このことに対応するために、双方で再送タイマーと呼ばれる単純なタイマーを用いて、時間内に返信を受け取れなかった場合に再送を行うようにしています。
DLTSでは、さらに、ハンドシェイクのレコードが分割され、順不同になる可能性を考慮する必要があります。そこで、単一のパケットに収まるようにし、ブロック暗号を利用します。
WebRTCでは、オーディオ・ビデオ転送用のRTPプロトコルを利用します。このRTPパケットの暗号化には、SRTPを利用します。SRTPセッションは、ネゴシエートされたDTLSセッションからキーを抽出して初期化を行います。
SRTPパケットは以下のような形式です。
SRTPパケットの概要です。
- 自動インクリメントされるシーケンス番号を持つ。これにより、アプリケーション側でメディアデータを順番に処理できるようにする
- メディアの開始時を示すタイムスタンプを持つ。これにより、アプリケーション側で複数のメディアの同期を行うことができる
- メディアストリームとパケットを紐づけるためのSSRC識別子を持つ
- 認証タグは、パケットの整合性を証明する
④通信(RTP、SCTP)
最後に、実際のメディア・データ通信を行います。すでに記載している部分もありますが、メディアではRTP(およびそのパケットをSRTPで暗号化したもの)を、データではSCTPを利用します。
データチャンネルにおいて、①複数の独立したチャネルの多重化、②メッセージ志向APIのサポート、③トランスポートでのフロー制御・輻輳制御の仕組みの提供、および④転送データの機密性と整合性の保持が要件となります。このうち、④はDTLSで、①②③はSCTPで達成します。
SCTPは以下のようなパケットとなります。
SCTPパケットの概要です。
- データタイプは0
- Uは順不同のDATAチャンクかを表すビット
- B、Eは開始、終了のチャンクであるかを示す
- チャンク長は、ヘッダを含めたDATAチャンクのサイズ
- 伝送シーケンス番号(TSN)は、SCTP内部で到達と重複の検知に利用される
- ストリームIDは、ストリームとの紐づけに利用される
- ストリームシーケンス番号は、ストリーム内のメッセージ番号。自動的にインクリメントされる
- データチャンネルでは、ペイロードプロトコル識別子(PPID)を用いて、データ型を通知する(UTF-8: 0x51、バイナリ: 0x52)
WebRTCのレイヤー構造
WebRTCのレイヤー構造は大まかに以下のようになります。
実装例
今回は、複雑なNATトラバーサル等を行わず、同一ブラウザ上の2つのタブ間でのWebカメラ・マイクの映像、音声の通信を行ってみます。
まずは、MediaDevices.getUserMedia()
でユーザーからカメラ、マイクの映像、音声の許可を取り、ストリームを取得します。
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// videoタグのソースに設定
const video = document.getElementById('video');
video.srcObject = stream;
次に、タブ間の通信を行うため、BroadcastChannel
を利用します。
これにより、NATトラバーサルのためのSTUN/TURNサーバの準備等をスキップしますが、グローバルネットワークにおいては、NATトラバーサルのためにSTUN/TURNサーバの準備が基本的には必要です。
// signaling.postMessage() を利用することで、同じBroadcastChannelのIDを持つ
// BroadcastChannelにメッセージを送信でき、messageイベントを発生させることができる
const signaling = new BroadcastChannel('private_room');
次に、リモートピアとの接続を管理するRTCPeerConnection
を作成します。
onicecandidate
では、ICEの新しい候補が見つかったときにそれをそのままリモートピアに送信します(icecandidate
イベントは、候補の世代の終わりや候補の収集が終わった場合にも通知されます)。
peerConnection = new RTCPeerConnection();
peerConnection.onicecandidate = e => {
...
signaling.postMessage({ type: 'candidate', candidate: e.candidate });
};
ここまでで、メッセージの通信自体はできるようになったので、SDPのやり取りをしたうえでシグナリングを実施します。
// readyを受け取ったら、offerを行う
const offer = await peerConnection.createOffer();
signaling.postMessage({ type: MESSAGE_TYPE.OFFER, sdp: offer.sdp });
// setLocalDescription()でSDPを自身に登録する
// このタイミングで icecandidate イベントが呼ばれる
await peerConnection.setLocalDescription(offer);
// offerを受け取ったら、answerを行う
// リモートピアのSDPを登録する
await peerConnection.setRemoteDescription(offer);
const answer = await peerConnection.createAnswer();
postMessage({ type: MESSAGE_TYPE.ANSWER, sdp: answer.sdp });
await peerConnection.setLocalDescription(answer);
// リモートピアの候補の通知が来たら、候補を追加する
// nullの場合、リモートピアの候補の収集が完了したことを示す
if (!candidate.candidate) {
await peerConnection.addIceCandidate(null);
} else {
await peerConnection.addIceCandidate(candidate);
}
ここまでくると、あとは取得したリモートピアのストリームをvideoタグに割り当てます。
(実際には、RTCPeerConnection
の初期化のすぐ後にイベントリスナを登録しておきます)
const remoteVideo = document.getElementById('remoteVideo');
peerConnection.ontrack = e => remoteVideo.srcObject = e.streams[0];
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
上記の実装を適切に行うことで、以下のように、タブ間でのWebRTCの通信を行うことができます。
※各タブの左の映像が直接カメラから取得した映像、
右側の映像がリモートピア(別タブ)から受け取った映像
WebTransport
WebTransportは、HTTP/3を基本的には前提として記載していきます(WebTransport over HTTP/3)。HTTP/3が利用できない場合に、HTTP/2で動くこと(WebTransport over HTTP/2)も想定されていますが、WebTransport本来のパフォーマンスは発揮できないため、以下ではHTTP/3を前提として記載します。
以下のプロトコルの各形式は、以下より引用しています。
フローの概略
① WebTransportセッションの開始要求
WebTransportの通信を開始するには、クライアントからCONNECTメソッドでリクエストを送信します。
CONNECTメソッドによるリクエストでは、以下のような拡張された疑似ヘッダを持ちます。
:method CONNECT
:scheme = https
:authority = example.com:443
:path = /path/to/webtransport
:protocol = webtransport
:origin = https://example.com
このリクエストにサーバが2xxステータスを返すことで、WebTransportの通信が開始されます。
なお、このリクエストのストリームIDをセッションIDと呼びます。
② 単方向・双方向ストリーム、データグラム通信
単方向ストリームは、Stream Type に0x54
を指定します。以降でセッションID、Bodyを送信します。
Unidirectional Stream {
Stream Type (i) = 0x54,
Session ID (i),
Stream Body (..)
}
双方向ストリームは、Stream Type に0x41
を指定します。単方向と同じく、以降でセッションID、Bodyを送信します。
Bidirectional Stream {
Signal Value (i) = 0x41,
Session ID (i),
Stream Body (..)
}
データグラムは、HTTP/3のデータグラム形式をそのまま使用できます。
HTTP/3 Datagram {
Quarter Stream ID (i),
HTTP Datagram Payload (..),
}
HTTP/3として、以降でリクエストやプッシュ通信を受け付けないためのGOAWAYフレームを送信できます。これはすべてのWebTransportセッションのシャットダウンに利用できます。一方で、単一のWebTransportセッションのシャットダウンには、DRAIN_WEBTRANSPORT_SESSION カプセルを送信することで実行できます。
DRAIN_WEBTRANSPORT_SESSION Capsule {
Type (i) = DRAIN_WEBTRANSPORT_SESSION(0x78ae),
Length (i) = 0
}
セッションの終了には、主に2種類のパターンがあります。
- CONNECTストリームが閉じられる
- CLOSE_WEBTRANSPORT_SESSION カプセルが送信、もしくは受信される
CLOSE_WEBTRANSPORT_SESSION カプセルの形式は以下です。
CLOSE_WEBTRANSPORT_SESSION Capsule {
Type (i) = CLOSE_WEBTRANSPORT_SESSION(0x2843),
Length (i),
Application Error Code (32),
Application Error Message (..8192),
}
ストリームとデータグラムの違い
ストリームとデータグラムの違いを簡単に説明しておきます。
ストリームは、QUICレイヤーで順序保証や再送を行ってくれます。そのため、順序が重要なストリーム配信等の用途に向いています。一方でデータグラムでは、必要なら順序保証や再送は自前で対応することになります。また、データグラムでは、パケットに収まるようにデータを分割しておく必要があります。その分、トレードオフの調整はしやすく、低レイテンシのリアルタイム通信(たとえばオンラインゲームなど)、データ通信を細かく調整したい用途(IoTなど)の用途に向いています。
ストリーム | データグラム | |
---|---|---|
順序保証・再送 | 自動 | 自前 |
データ分割 | 自動 | 自前 |
WebTransportのレイヤー構造
WebTransportのレイヤー構造は以下のようになります。
前述の通り、HTTP/2へのフォールバック機構を備えているため、HTTP/2も含まれています。
実装例
ここで、ブラウザ側のWebTransportの簡単な実装例を示しておきます。
注意点として、WebTransportの通信における値はすべて ArrayBuffer(Uint8Array)
のため、テキスト形式の情報を送受信するには、エンコード・デコードが必要です。
また、WebTransport over HTTP/3 をベースとする場合、 https
、つまりSSL証明書による認証が必須です(これはHTTP/3の制約です)。これはローカル開発においても、基本的にSSL証明書による認証が必要になることを意味します。このあたりは自己証明書を利用した開発になりますが、http
でアクセスできるサーバとの通信による開発より手間がかかります(解決手順はGitHubリポジトリ側に記載しています。私はMac上の開発のため、Mac上での手順のみとなります)。
WebTransportの通信の開始
まずは通信の開始です。
WebTransportのインスタンスを生成して、ready
で準備の完了を待ちます。
const url = 'https://localhost:4433/webtransport';
const transport = new WebTransport(url);
await transport.ready; // 通信の準備ができたら、Promiseが完了する
双方向ストリームによる送信・受信
双方向ストリームは、以下のように createBidirectionalStream()
で開始します(単方向ストリームは似ているため、省略します)。
そして、 writable.getWriter()
、 readable.getReader()
それぞれで取得したインスタンスを使用して、送信・受信を行います。
const stream = await transport.createBidirectionalStream();
// 送信
const writer = stream.writable.getWriter();
await writer.write(new TextEncoder('utf-8').encode('Hello, World'));
// 受信
const reader = stream.readable.getReader();
const { value } = await reader.read();
console.log('Received:', new TextDecoder('utf-8').decode(value));
一方、サーバ側から開始する場合、incomingBidirectionalStreams
プロパティを利用します。
const reader = await transport.incomingBidirectionalStreams.getReader();
const { stream } = await reader.read();
const reader = stream.readable.getReader();
const { done, value } = await reader.read();
if (done) break;
console.log('Received from server:', new TextDecoder('utf-8').decode(value));
}
データグラム通信
データグラム通信での送信は以下のように行います。ストリームとの違いは、読み取り専用のdatagrams
プロパティを直接利用することです。
const writer = transport.datagrams.writable.getWriter();
writer.write([1, 2, 3]);
一方、受信は以下のように行います。
const reader = transport.datagrams.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value);
}
まとめ
今回はWebのリアルタイム通信、双方向通信をざっと眺めてみました。
特にWebRTC、WebTransportについては、学んでみたいと思いつつも後回しになっていたので、この機会に学べて良かったです。実用的なアプリケーション作成といったところまでは時間が全然足りませんでしたが、Web上でも知見が溜まってきているようなので、ハマりポイントは多いものの、趣味レベルであればWebRTCやWebTransportなども活用できそうです。
現状、Web上を見る限り、WebTransportがこれまでのWebSocketやWebRTCが解決していた領域でも席巻すると予想する人、複雑さなどから純粋なHTTP/3との棲み分けや超低遅延を必要とする領域以外ではそこまで活用は進まない予想など様々です。
また、HTTP/3、QUICの登場により、これまで独自のプロトコルやソリューションとしていた領域をそれらの上で再実装する試みも進んでおり、まだまだ新たな技術が生まれてくる状況は続くかなと思います。
新しい技術は、ライブラリやサービス側の拡充などが進まなければ先進的な企業やごく一部の開発者でしか活用されないことは多々あります。とはいえ、どういった技術か知っておくことで、今後の技術の比較検討や将来の技術進歩についていける下地は必要かなと改めて思いました。
必要に応じてこうした技術の活用ができるように研鑽を深められればと思います。
参考文献
- MDN
- RFC,ドラフト
- RFC 6455 - The WebSocket Protocol
- RFC 8829 - JavaScript Session Establishment Protocol (JSEP)
- RFC 9147 Datagram Transport Layer Security Version 1.3
- RFC 3711 The Secure Real-time Transport Protocol (SRTP)
- RFC 4960 Stream Control Transmission Protocol
- WebTransport over HTTP/3
- RFC 9297 - HTTP Datagrams and the Capsule Protocol
- 書籍
-
Xにて指摘をいただいたため、修正しています。
https://x.com/voluntas/status/1867430859412058184 ↩ -
2024年12月現在、Chrome、Edge、Firefoxはサポートしています。
Safariではテクノロジープレビューが提供されています。
サポートブラウザの状況はMDNを参照してください。 ↩