🛠️

ReactとWebRTCでZoomのようなビデオチャットアプリを作ってデータフローを図解してみた

9 min read 4

はじめに

こんにちは。
都内在住のフロントンドエンジニアです。
僕はとある会社にて約 1 年半ほど React と WebRTC を用いて映像配信のアプリケーション開発を行ってきました。

そこでは開発をスムーズに進める為に WebRTC の SDK を利用していて、
本来学習コストが高いとされている WebRTC をカジュアルに利用することができています。
しかし、より入り組んだ実装をしたり映像配信特有の問題(後述) を解決するとなると以下 3 つの WebAPI の理解は避けて通れません。

詳しくは文中に記載しますがこれらの理解を深めないと開発の進行に大きな影響があると思ったので、WebRTC 関連のライブラリ等を利用せずに映像配信のアプリケーションを作って学習しようという考えになり、実際に作ってみました。

それがこれです!

スクリーンショット 2020-09-11 9.24.00.png
リンク: https://react-webrtc-starter.herokuapp.com

※ Heroku に上げたので初回起動の場合コンテンツレンダリングが遅めです 😅

このスクショでは何をやっているのか端的に説明しますと、まず Mac で上記リンクにアクセスして部屋を作成します。
続いてその部屋に iPhone からアクセスをすると、既に部屋に入っているユーザー(Mac)と映像や音声(MediaStream)を交換することで相手と会話をすることが可能となります。

これはSFU(後述) と呼ばれる仕組みを使った双方向通信といわれるもので、別々のデバイスから流す映像や音声を特定のサーバーを経由して送り合うということをやっています。

この記事で話す内容

表題の通り、別々のデバイスで映像と音声のやり取りを行う上でのデータフローについて自分なりの解釈を述べていきたいと思っています。
アプリケーションの技術スタックであったり実際のコードについて興味がある方は、公開リポジトリを用意してるのでそちらを見てもらえればよいのかなと思うので、今回は WebRTC に焦点を当ててこんなハマりどころがあるとか、こんなところが歯痒いとかそういう使用感について取り上げていきたいと思います。

※ 本文では後述する SFU という通信方式である前提で執筆しているので、一部偏った表現があるかもしれませんが予めご了承くださいまし。

リポジトリ: https://github.com/yuyake0084/react-webrtc-starter

WebRTC について

webrtc_wide.png

1. そもそも WebRTC って何?

アカデミックな話はできないし知らないので端的かつ自分の理解で言ってしまうと、Web ブラウザというツールを介して音声や映像を相互に送り合ってリアルタイムコミュニケーションを Web で実現することができる仕組みのことを指します。
※ WebRTC とはWeb Real-Time Communicationの略称

WebRTC 以外で Web というプラットフォームを使ってリアルタイムコミュニケーションをするとなれば、WebSocket を用いてのテキストチャット等が挙げられますね。
そのテキストチャットと明確に異なるのは、MediaStream(音声・映像)を用いてのリアルタイムコミュニケーションが実現可能になるということです。

2. リアルタイム通信を行うにあたって必要な情報

Web を介しているとはいえお互いの情報を交換し合わなければ MediaStream を送り合うことができないので、
RTCPeerConnection(以下、PC)から提供されている API を利用して、通信している人同士で特定のデータを送り合う必要があります。
それが以下 2 つで、どちらも WebSocket を介して相手に送る。

⭐️ SDP(Session Description Protocol)

利用しているブラウザで配信可能なコーデックの種類だったり、セッション情報、通信相手の情報等が記載されている文字列。
PC のcreateOffercreateAnswerというものを行って以下のオブジェクトを作成する。
かなり長いので DevTool の console で読むのはちょっと辛い。

{
  type: "offer", // or answer
  sdp: "v=0↵o=- 7548328979379926014 2 IN IP4 127.0.0.1↵s=-↵t=0 0↵a=group:BUNDLE 0 1↵a=msid-semantic: WMS HLYst9oarpp0MHhOHH47iyqzgQypSVIAM3Zq↵m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126↵c=IN IP4 0.0.0.0↵a=rtcp:9 IN IP4 0.0.0.0↵a=ice-ufrag:uJVj↵a=ice-pwd:iDqHChd7qahzFuRfZMiAdnN5↵a=ice-options:trickle....." // まだまだ続く
}

⭐️ ICE(Interactive Connectivity Establishment)

相手との通信経路が記載された文字列。
SDP をsetLocalDescriptionを使って自身が保持している PC に格納すると、
その PC でicecandidateイベントが発火するのでそのイベント内に格納されているのが以下オブジェクト。

{
  type: "candidate",
  candidate: "candidate:669691712 1 udp 2122260223 172.16.100.225 63152 typ host generation 0 ufrag xLmL network-id 1 network-cost 10"
}

3. 通信方式は主に 3 種類

特定の相手と通信するにあたってお互いの
WebRTC を用いてサービス開発をする方にとってはその要件次第で以下の 3 つから通信方式を選定すると思います。

  • P2P(Peer-to-peer)
  • MCU(Multipoint Control Unit)
  • SFU(Selective Forwarding Unit)

🌟 P2P(Peer-to-peer)

サーバーを介さず直接端末(ブラウザ)同士で接続する通信方式。
高解像度で視聴できるがそれ故にモバイル端末に於いてはデコードにかかる CPU への負荷が高い。

🌟 MCU(Multipoint Control Unit)

音声と映像(ストリーム)をサーバーで結合してそれをクライアントに提供する通信方式。
PC のコネクションが 1 本しか無い為クライアントサイドの負荷は低いが、
サーバーサイドは音声と映像を結合する為のエンコード処理が走る為負荷が高い。(それに付随して遅延が発生するリスクがある)

⭐ SFU(Selective Forwarding Unit)

MCU と同じくサーバーを介してストリームを提供し合う通信方式。
しかし MCU と異なるのは、サーバーはクライアントから送られたストリームを結合せずに他の配信者に流す役割を持っている。

冒頭にも記載しましたが、本記事で紹介したアプリケーションは SFU を採用しています。
理由は単純で、チームで SFU を利用しているので同じ仕組みを使った上で挙動を把握したかった為です。

複数人でビデオチャットをするにあたってのデータフロー

ここまででビデオチャットを行う上で必要最低限の前提知識は紹介させて頂いたので、
実際にお互いの映像が映るまでのデータフローを追いかけてみましょう。

1. 部屋の作成

video_chat01.png
特定の人同士でクローズドなチャットを行うにはまず最初に部屋の作成を行う必要があります。
上記図では A さんが「XXXX-XXX-XXXX」という RoomId の部屋を作成しました。

2. B さんが入室

video_chat02.png
続いて B さんは A さんが作成した XXXX-XXX-XXXX の部屋に入室します。
前提として、この時の B さんは WebSocket に接続しているけれど A さんの映像は写っていない状態です。
なので B さんはまず、「入室しましたよ!」という旨を WebSocket を介して入室中のユーザーに伝えます。

3. A さんから B さんへ Offer を送る

video_chat03.png
ちょっとここはやや複雑なので最初にここでやってることを簡潔に述べると、 「私(A さん)はこういう者ですけれど、あなたはどちら様ですか??」 という質問を新しく入室してきたユーザーに対して投げかける、ということをやっています。

Call が届いた A さんはまず最初に pc.createOffer() で SDP を作成します。
作成された SDP は Call 主である B さんに返すのですが、B さんとの通信経路を確立する必要があります。
ここでいう通信経路とは互いの映像と音声を送る上での PC 上での MediaStream の通り道をイメージしてもらうと良いと思います。
簡易的なコードを用いて説明すると以下の通り。

const pc = new RTCPeerConnection(...) // 接続するクライアントごとにPCを生成。引数にはSTUNサーバーの情報とか色々渡す
const sessionDescription = await pc.createOffer() // SDP生成

// setLocalDescriptionが実行されると発火
pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  console.log(e.candidate) // イベント経由で得られる経路情報。これをWebSocketを使って相手に送る
}

await pc.setLocalDescription(sessionDescription)

具体的に説明すると、まず最初に pc.setLocalDescription(SDP) を実行します。
引数の SDP は上記の pc.createOffer() で生成された SDP と同一のものです。
pc.setLocalDescription(SDP)が実行されると該当する pc から通信経路が確定するまで icecandidate イベントが発火され続けます。
このイベントを契機に通信相手に対して SDP を送るのですが、方法としては以下の 2 種類があります。

⭐ Trickle ICE
上記の icecandidate イベントは経路情報が確定するまで何度か発火するのですが、発火される度に収集した経路情報があれば WebSocket で即座にサーバに送りつけるのが Trickle ICE です。

pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  if (!!e.candidate) {
    const data = {
      toId: clientId,
      roomId: this.roomId,
      sdp: {
        type: 'candidate',
        ice: e.candidate,
      },
    };

    this.socket?.emit(types.CANDIDATE, data);
  }
};

⭕ メリット
Vanilla ICE と比較して部屋にいるユーザーとの通信確立の時間が短い

❌ デメリット
イベントが発火される度に小出しで送る為、仮にネットワーク不安定等の理由でうまく送れなかった場合経路情報に欠損があるとうまく通信ができなくなる可能性がある

⭐ Vanilla ICE
経路情報が確立すると pc 内部にlocalDescriptionが用意されるので、それを 1 度だけ送るようにするのが Vanilla ICE です。

pc.onicecandidate = (e: RTCPeerConnectionIceEvent): void => {
  if (this.pc?.localDescription) {
    this.sendSDP(this.pc.localDescription);
  }
};

⭕ メリット
Trickle ICE と比較して安定した通信経路の送信が可能

❌ デメリット
Trickle ICE と比較して通信経路確立までの時間が長い

今回僕が作ったアプリケーションでは Trickle ICE を採用していますが、どちらが良いかは作成するアプリケーションの性質によって異なると思うので適宜使い分けると良いのかなと思います。

4. B さんから A さんに対して Answer を返す

A さんから SDP が送られたら B さんの手元にも A さんとの PC が作成され、
A さんに対して B さんの SDP を乗せて Answer を返します。
また、3 同様 A さんとの通信経路を確立します。

video_chat04.png

5. A さんと B さんのコネクションが確立 🎉

SDP の交換が成立した時点で、PC の addstream イベントが発火し、相手の MediaStream を受け取ることができます。
MediaStream を受け取ったタイミングで新たに video タグを生成し、srcObject 属性に受け取った MediaStream を渡すことで自身のブラウザ上で相手の映像と音声を再生することができる為、ここでようやくコネクションが確立したと言えるのかなと思います。

video_chat05.png

長くなってしまいましたがデータフローの説明については以上です。
厳密には異なりますが、C さんが入室した場合でも上記の入室フローとほぼ一緒の挙動となります。

(記事の尺的な都合上 C さんの出番無くなってしまった。。。)

映像配信特有の問題とは?

例を上げると、配信映像が意図しないタイミングで切断してしまったり、音声は聴こえるが映像が固まったままの状態になってしまうことが稀にあって、要因については様々です。

ネットワーク帯域幅が低かったり、利用しているブラウザとそのバージョン、果てには利用している端末で WebRTC との相性なんてものもあったりしますし、これら以外にも存在するあらゆる問題全てを網羅的にカバーするのはほぼ不可能です。

しかし、映像系のサービスに対して課金を行なったが、映像が止まってしまったことによって課金額に見合うだけのサービスが提供されなかったエンドユーザーにとっては、そんなことは関係ないですし、まともに動作しないサービスとして不信感を持たれてしまうことになります。

網羅的にカバーすることは不可能であるにしても、エンドユーザーからお問い合わせがあった際にどこに原因があったのかを特定できるようにログを残して根気強くその調査を行って、そのログから得られた知見を基に映像停止の発生を抑制できるような方法を模索し続けていく努力を怠ってはいけないので、手探りしながらでも改善の糸口を掴んでいきたいと思っています。

まとめ

最後の方は映像配信特有の問題のところでマイナスな表現をしてしまいましたが、その仕組自体はとてもおもしろいものですし、コロナ時代で他者と円滑なコミュニケーションを取るにあたって WebRTC という技術はなくてはならない存在だと思うので、引き続き情報をキャッチアップしていきたいと思います。
ではでは ✋

大変参考になった記事

Discussion

とても参考になりました。
このリポジトリはあくまで確認用ということで、稼働させることは不可でしょうか。

嬉しいお言葉ありがとうございます😊
稼働自体は可能でして、記事内にあるherokuのURLが当該リポジトリをそのままホスティングさせてるものになります。

なるほど…
エラーに関する情報ありがとうございます…!

yoshixileさんが抱えていらっしゃる問題を解決したいというところと、他の方でも同じような問題が起きていた場合その旨を周知しておきたいという意図で、お手数をおかけしてしまうのですが当リポジトリにてIssueを立てて頂き、そちらで諸々の対応させて頂ければと思っておりますがいかがでしょう?

https://github.com/yuyake0084/react-webrtc-starter

承知しました!ではIssueのほうにあげておきます。
おそらくnode_moduleの読み込み先が異なる・・みたいなことだとは思いますが。

ログインするとコメントできます