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

## はじめに
ご無沙汰しています。
約2年ぶりの投稿です💀
早速本題から外れて自分のお話になってしまうのですが、
僕はとある会社にて約1年半ほどReactとWebRTCを用いて映像配信のアプリケーション開発を行ってきました。

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

- [MediaStream](https://developer.mozilla.org/ja/docs/Web/API/MediaStream)
- [RTCPeerConnection](https://developer.mozilla.org/ja/docs/Web/API/RTCPeerConnection)
- [WebSocket](https://developer.mozilla.org/ja/docs/Web/API/WebSocket)

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

### それがこれです！
![スクリーンショット 2020-09-11 9.24.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/74706a49-0603-5a05-e449-e77f898a1ca2.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/7e4b707e-2bba-b0d7-4c63-775590195db1.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の`createOffer`や`createAnswer`というものを行って以下のオブジェクトを作成する。
かなり長いのでDevToolのconsoleで読むのはちょっと辛い。

```ts
{
  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`イベントが発火するのでそのイベント内に格納されているのが以下オブジェクト。


```ts
{
  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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/95eb850d-1e43-1993-7103-ecf74132aac8.png)
特定の人同士でクローズドなチャットを行うにはまず最初に部屋の作成を行う必要があります。
下記図ではAさんが「**XXXX-XXX-XXXX**」というRoomIdの部屋を作成しました。

### 2. Bさんが入室
![video_chat02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/b56fdbd1-ed04-9244-4a6d-47b7ceff5823.png)
続いてBさんはAさんが作成したXXXX-XXX-XXXXの部屋に入室します。
前提として、この時のBさんはWebSocketに接続しているけれどAさんの映像は写っていない状態です。
なのでBさんはまず、「入室しましたよ！」という旨をWebSocketを介して入室中のユーザーに伝えます。

### 3. AさんからBさんへOfferを送る
![video_chat03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/72ee481a-fa9f-47a9-cd13-2643b98e13e6.png)
ちょっとここはやや複雑なので最初にここでやってることを簡潔に述べると、 **「私（Aさん）はこういう者ですけれど、あなたはどちら様ですか？？」** という質問を新しく入室してきたユーザーに対して投げかける、ということをやっています。

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

```ts
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です。

```ts
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です。

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/3d891818-93f3-0748-4971-55122b04b064.png)

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

![video_chat05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88810/1d8e0149-cb9f-8314-1e19-68cd62d4e360.png)

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

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

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

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

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

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

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

## 大変参考になった記事
- [SDP for WebRTC](https://www.slideshare.net/iwashi86/webrtc-for-sdp)
- [WebRTCハンズオン 本編](https://qiita.com/yusuke84/items/43a20e3b6c78ae9a8f6c)
- [シグナリングを拡張して、複数人で通信してみよう ーWebRTC入門2016](https://html5experts.jp/mganeko/20112/)
- [WebRTCのICEについて知る](https://www.slideshare.net/iwashi86/webrtcice)