WebRTC でビデオチャットアプリを作ってみた
リモート会議をやる機会が増えて、Google Meet、Microsoft Teams、Zoom などを利用している。ビデオチャットとか面白いなー、WebRTC とかいうヤツがあったと思うけど触ったことないなー、と思ったので、作ってみることにした。
目次
成果物とデモサイト
作成したコード全量は以下の GitHub リポジトリに格納した。
動作するデモは以下にデプロイした。
タブやブラウザ、端末を分けて2つからこのサイトにアクセスし、それぞれで画面上部の「Start」ボタンを押すと、ビデオ映像が双方向に通信されて、1対1のビデオチャットとして動作する。
動作確認したのは PC の Chrome 系ブラウザと Firefox と、iOS Safari。iOS Safari は既知の不具合があったりする。うまく接続できない場合は一度「Stop」ボタンを押してもう一度「Start」ボタンで接続したりしてみて欲しい。
参考にした文献・コード
今回実装する上で参考にした文献やコードを先に紹介する。コレから書く内容は、これらを眺めながら何となくコードを書いた結果覚えたことである。
- GitHub - ticktakclock/WebRTCDemo: WebRTCを使った複数人ビデオチャットデモとWebSocketを使ったリアルタイム制御デモ
- WebRTCとWebSocketで画面共有(ビデオチャット)アプリを作る - ticktakclockの日記
- かなり最近の日本語文献。モダンな作りでしっかりしていて、参考になるだろう
- WebRTC · GitHub
- WebRTCをかじってみた2020春 - はてだBlog(仮称)
- コチラもかなり分かりやすく、モダン
- WebRTC コトハジメ · GitHub
- 技術周りの解説記事、とても分かりやすい
- WebRTCの簡易シグナリング - Qiita
- WebRTC のシグナリングの処理フローがとても参考になった
- GitHub - joshydotpoo/nodejs-videochat: Live stream video chat app using nodejs
- 少々古く、そのままでは動作しないようだったが、作りがシンプルだったのでかなり参考にした
- WebRTC初心者でも簡単にできる!Node.jsで仲介(シグナリング)を作ってみよう | HTML5Experts.jp
- コチラもコードが古めだが、かなり参考にした
- How to write a video chat app using WebRTC and Node.js | TSH.io
- How to Build a Video Chat Application with Node.js, Socket.io and TypeScript
- 英語文献だが順を追って実装していて良い
- WebRTCでビデオチャットアプリを作ってみた! - Qiita
- Node.js + Express + Socket.ioで簡易チャットを作ってみる - Qiita
- WebRTC は登場せず、Socket.io でテキストチャットを作成する解説
- 最新のWebRTCのtrack系処理を、手動+データチャネルシグナリングで観察してみる - Qiita
- WebRTCのPerfect negotiationについて - console.lealog();
- Rollback について
WebRTC を始めるために理解すること
WebRTC (Web Real-Time Communication) によるビデオチャットアプリを作りたいワケだが、予め勉強しておかないといけないことがいくつかあるようだ。ココではキーワードと簡単な理解を書いていこうと思う。
WebRTC による双方向通信の仕組み
WebRTC は、ブラウザ内で P2P 通信を実現し、ある端末と双方向にやり取りするモノである。Peer-To-Peer ということは、お互いの端末を直で特定しないと、通信が始められないワケだ。
もしもグローバル IP を持っているマシン同士なら、双方が相手のグローバル IP を知っていれば直接通信できそうな気もするが、それにしても、通信相手のグローバル IP をどうやって知るか、という問題は残る。
そこで、P2P 通信を始める前に、お互いの情報を交換するための「シグナリング」という処理が必要になる。「シグナリングサーバ」と呼ばれる役割を持ったサーバを作ってやり、コレを経由することでお互いの情報をやり取りするのだ。その際にやり取りする情報は以下のようなモノがある。
- Session Description Protocol (SDP)
- 自分の IP アドレスやポート番号、セッションに含むメディアの種類など
- 大まかにいって「どこのどいつが、どんなものを送ろうとしているか」
- Interactive Connectivity Establishment (ICE)
- 通信経路の情報
- STUN サーバ (Session Traversal Utilities for NAT・自 IP を教えてくれるサーバ) による NAT 越え通信のためのポートマッピング (前述のグローバル IP の交換に近いイメージ)
- TURN サーバ (Traversal Using Relay around NAT・P2P 通信の間をリレーするサーバ) を利用した通信経路
SDP や ICE をやり取りすることで、相手と直接通信するための経路を確立し、P2P 接続を開始できるようになる。
- 参考 : 【初心者向け】STUN/TURNサーバをざっくり解説してみた - Qiita
- 参考 : WebRTCに触ってみたいエンジニア必見!手動でWebRTC通信をつなげてみよう | HTML5Experts.jp
- 参考 : Frequently Asked Questions - Twilio
WebSocket を利用したシグナリング処理
ということで、WebRTC によるチャットアプリを作るには、画面以外に、相手との通信を確立するまでの仲介を行う「シグナリングサーバ」が必要になりそうだ。
シグナリングの処理自体は色んな技術で実現できるのだが、広く使われているのは WebSocket という双方向通信を実現するための仕組み。
そしてコレを簡単に実装できるようにしてくれる npm パッケージとして、Socket.io というモノが存在する。Socket.io は古くから存在するためか、解説記事の「鮮度」がまちまち。メソッド名や API も色々変化があり、正しく参考にするのが大変な印象だった。
- 参考 : いまさら聞けないWebSocketとSocket.IOの基礎知識&インストール (1/2) : Socket.IOで始めるWebSocket超入門(1) - @IT
- 参考 : Socket.ioとは何か?リアルタイムWebアプリケーションを実現する技術をまとめてみた - Qiita
- 参考 : 【Node.js入門】誰でも分かるSocket.ioの使い方とチャットアプリの作り方まとめ! | 侍エンジニア塾ブログ(Samurai Blog) - プログラミング入門者向けサイト
RTCPeerConnection
WebRTC による P2P 通信を行うための Web API として、RTCPeerConnection というモノが各ブラウザに実装されている。
P2P 通信を確立するまでの流れは次のようになる。
- クライアント 1
- Web ページを開いた時に、WebSocket (Socket.io) を利用してシグナリングサーバとの接続を確立しておく
navigator.mediaDevices.getUserMedia()を使ってウェブカメラを起動、ストリームを取得するRTCPeerConnectionインスタンスを生成し、取得したストリームを持たせる (addTrack())- Offer SDP というモノを作る (
createOffer()) - Offer SDP を自分のピア (=
RTCPeerConnection) に登録して覚えておく (setLocalDescription()) - Offer SDP を WebSocket (Socket.io) でシグナリングサーバに送信する
- シグナリングサーバ
- クライアント 1 から受け取った Offer SDP を、他に接続されているクライアントにリレー (中継) 送信する
- クライアント 2
- Web ページを開いた時に、WebSocket (Socket.io) を利用してシグナリングサーバとの接続を確立しておく
navigator.mediaDevices.getUserMedia()を使ってウェブカメラを起動、ストリームを取得するRTCPeerConnectionインスタンスを生成し、取得したストリームを持たせる (addTrack())- WebSocket (Socket.io) を利用してシグナリングサーバが中継してきた Offer SDP を受け取る
- 受け取った Offer SDP をリモートの情報として登録して覚えておく (
setRemoteDescription()) - Answer SDP というモノを作る (
createAnswer()) - Answer SDP を自分のピアに登録して覚えておく (
setLocalDescription()) - Answer SDP を WebSocket (Socket.io) でシグナリングサーバに送信する
- シグナリングサーバ
- クライアント 2 から受け取った Answer SDP を、他に接続されているクライアントにリレー (中継) 送信する
- クライアント 1
- WebSocket (Socket.io) を利用してシグナリングサーバが中継してきた Answer SDP を受け取る
- 受け取った Answer SDP をリモートの情報として登録して覚えておく (
setRemoteDescription())
コレで P2P 接続が確立される。
この裏で onicecandidate というイベントが絶えず発火している。ICE Candidate とは ICE (通信経路) の候補情報が追加された時に発火するそうで、タイミングとしては createOffer() 後に発火するイベントとなる。この Candidate 情報は WebSocket を経由して投げあっておく。
P2P 接続が確立された後、相手側に addTrack() された映像や音声の情報があると、ontrack イベントが発火する。このイベントで、受け取った相手のストリームを video 要素なんかに流し込んでやれば良いというワケ。
RTCPeerConnection によるやり取りと、getUserMedia() による映像の取得とは、それぞれ切り離して処理できるので、落ち着いて処理の順番を見極め、ハンドリングしてやろう。
シグナリングサーバの実装
シグナリングサーバ (index.js) の実装は、Socket.io の公式サンプルに沿って行った。
Express サーバを http モジュールでラップし、さらにその http サーバを Socket.io でラップしてやると、Socket.io の準備ができる。
Socket.io の処理としてはクライアントから受け取った情報を中継してブロードキャスト (よそに送信) してやるだけなので、コードは30行ほどで済んでしまった。
クライアントの実装
Socket.io でラップした Express サーバは、/socket.io/socket.io.js というパスで JavaScript ファイルを提供するようになる。コレをクライアントサイドの HTML で読み込むと、グローバル変数 io が使えるようになり、Socket.io サーバとのやり取りが実装できるようになる。
クライアントサイド (index.html) では、このクライアント用 socket.io.js を読み込み、getUserMedia() を叩いたり、RTCPeerConnection を作ったり、io が受け取ったメッセージを基に処理したり、というのを愚直に実装している。
キモはこのクライアント側の実装なので、頑張った。
つまづいたところ
RTCPeerConnection の仕様
RTCPeerConnection も、Socket.io のようにそれなりに歴史が長く、色々と仕様変更があったり、ブラウザごとの実装差異が大きい時期が長かった。
そのため、記事によっては RTCPeerConnection の各メソッドをコールバック形式で実装していたり、ブラウザごとに異なる API をハンドリングしていたりと、つらみが高かった。
2020年現在は、RTCPeerConnection で検索して出てくる MDN のリファレンスに従うのが一番正しく、Chrome と Firefox はこのとおりに動作する。
RTCPeerConnection は各メソッドを Promise 形式で返すので、async・await で処理してやると良いだろう。
getUserMedia()・video 要素の扱い
navigator.getUserMedia() は古い。navigator.mediaDevices.getUserMedia() を使う。コレも Promise。
localhost で動かしている端末に別の端末からアクセスした際に、getUserMedia() が動作しない。Chrome の起動オプションに次のように書き込む必要がある。
# Windows マシンから、LAN 内の Mac 端末が公開している開発サーバにアクセスする例
$ chrome.exe --unsafely-treat-insecure-origin-as-secure="http://neos-macbook.local:3000"
video 要素もハマりどころが多い。URL.createObjectURL() はもう古くて使う必要なし。srcObject に stream をブチ込む。
iOS Safari はセキュリティが厳しくて自動再生とかが大変。video 要素に playsinline muted autoplay を振っておくと良いかな。
ユーザの明示的なクリックイベント以外で video.play() などをやろうとすると怒られることが多い。特にリモートの映像を表示する時がソレに当たった。Chrome ブラウザの場合は chrome://flags#enable-webrtc-remote-event-log フラグなどを片っ端から有効化すると、自動再生ができるようになる。
addStream() ではなく addTrack()
RTCPeerConnection に映像ストリームを渡す方法として、addStream() が使える。現在も Chrome などでは動作するが、iOS Safari では動作しなかった。
調べてみると、コレもまた古くなっていて、最近は addTrack() を使う実装方法になっているようだ。
onaddstream・onremovestream もそれに対応して、ontrack および removetrack に変わっている。メンドクサ。
既知の不具合
Chrome ではひととおりはキレイに動くようになってきたが、処理できていないところがあったりする。また、iOS Safari でだけ上手くいかないところがあるので、なんとかしたい。
- 相手との接続が切れた後に、他の人と自動再接続したいができていない
- 1対1の通信が確立できたあと、相手が切断した時に、コチラは取り残されてしまう
removetrackあたりに処理を入れて、別の Offer 中の人を拾い上げたりできるようにしたい
- iOS Safari が後から接続してきた人の Offer を正しく処理できない
- iOS Safari において、
setLocalDescription(localStream)した後に Offer を受け取りsetRemoteDescription(offer)を実行しようとすると、InvalidStateError: description type incompatible with current signaling stateなるエラーが発生する - 一度も
setLocalDescription()をせずにsetRemoteDescription(offer)→setLocalDescription(answer)と叩けばエラーにならないが、Chrome ブラウザでもキレイに動くようなハンドリングが出来ていない - なんで Chrome ではキレイに動くんだろう?iOS Safari は何が違うんだろう?
- iOS Safari において、
以上
歴史はそれなりに長いが、まだまだ発展途上といったところで、自分で実装してみると世のビデオチャットアプリはなんてよく作られているんだろうと感心する。
まだまだ分からないところが多いので、もう少しキレイに動くアプリになるよう学習していきたい。