WebRTCで1対1ビデオチャットシステムを実装して得られた知見のまとめ。
1対nとかn対nの場合はまた別なのでご注意を。
処理の全体の流れ(TrickleICE時)
- 双方がRTCPeerConnectionを作成する
- 双方がnavigator.mediaDevices.getUserMediaを実行し、取得したMediaStreamからMediaStreamTrackMediaStreamTrackを取得してConnectionにaddする
- どちらか一方がOfferSdpを作成する。(どちらでも構わない。以下送信側と呼ぶ)
- 送信側は作成したSdpをRTCPeerConnectionのlocalDescriptionとして登録する
- 送信側は、onIceCandidate,onTrackにイベントハンドラを設定する
- 送信側は、何らかの手段(通常はシグナリングサーバーを使うが何でも良い)でofferSdpをもう片方に送る
- offerSdpを受け取った側(以降受信側と呼ぶ)は受けっとたsdpをRTCPeerConnectionのremoteDescriptionにsetする。
- 受信側はRTCPeerConnection.createAnswerでanswerSdpを作る
- 受信側は作成したanswerSdpをlocalDescriptionにセットする。
- 受信側も送信側と同様にonIceCandidate,onTrackにイベントハンドラを設定する
- 受信側はanswerSdpを何らかの手段で送信側にanswerSdpを送る
- 送信側は受け取ったanswerSdpをremoteDescriptionにセットする。
- 送信側、受信側双方はiceCandidateイベントが発火した場合、そのcandidateをもう片方に送る(手段は問わない)
- iceCandidateを受け取った側はRTCPeerConnection.addCandidateで受け取ったcandidateを追加する
- 上記のICE交換を行っているうちに上手く行くとonTrackイベントが呼ばれるのでそこからMediaStreamを取得してvideoなどに投入する
実装手順
STUN/TURNサーバーを用意する
WebRTCで接続する両社が同一LANにいるとか、PublicIPアドレスを持っているとかでない限り、
STUNサーバーまたはTURNサーバーは必要。
なお、STUNサーバー、TURNサーバー自体については
https://qiita.com/okyk/items/a405f827e23cb9ef3bde
の記事が分かりやすい。
STUNサーバーは非常に処理が軽いので無料でGoogleなどが提供してくれていたり後述のcoTurnがデフォルトでSTUN機能も持っているので、あまり意識する必要はない。
一方で、TURNサーバーはWebRTCで利用するデータをリレーする性質上、データ通信のコストが大きい。
無料では利用できないので、有料サービス(あまり多くない)を利用するか自分で立てる必要がある。
自分で立てる場合はcoTurnがデファクトスタンダード。
とはいえ、Turnサーバー自体がいまいちメジャーではないので、構築は結構面倒。
(パッケージマネージャーから一発では入らずビルドが必要、構築済みのDockerイメージもないなど)
新規に対応が必要な場合は、多めに工数を見積もっておくべき。
TURNサーバーの稼働チャックに関するTIPS
TrickleICEをまず使う。
ICE candidateの取得シミュレーションが出来るページ。
ここでtype:relayでcandidateに出てこない場合は確実に無理である。
なお、candidateとして選出されるかどうかの保証にしかならないので、
ここが上手く通ったからと言って確実に本番のWebRTCも通るわけではないのは注意。
coTurnはデフォルトでstun機能もONになっている。
純粋にTURNサーバーとして動くかどうか確認したい場合、turnserver.confにno-stunを付けてSTUN機能を切るべし。
これをやっておかないとTURNでテストしているつもりが実際はSTUNで接続していたということになりかねない。
基本WebRTCでは開発者は接続方法の選定に直接タッチできないので、確実にTURNでテストしたい場合は対応必須。
RTCConfigurationの作成
前節までで用意したICEサーバー(STUN or TURNサーバー)の情報からRTCConfigurationを作る。
最低限だとこんな感じ。
export const RTC_CONFIG: RTCConfiguration = {
iceServers: [
{ "urls": "stun:stun.l.google.com:19302" },
{ "urls": "turn:{turnサーバーのhost}:{turnサーバーのport}", username: {turnサーバーユーザー名}, credential: {turnサーバーパスワード} }
]
}
STUNはURLのみ、TURNの場合は認証情報が必須。(認証情報はturnserver側で設定する)
navigator.mediaDevices.getUserMedia周り
MDN
結構事故る可能性の高い部分。
対応ブラウザ問題
実は対応ブラウザが意外と狭く、特にiOS系は純正のSafari以外では動かない。(2019年11月現在)
ChromeやFireFoxなどはAndroidではOKだが、iOSでは無理。
また、WebView系も全滅である。
PC版Chrome
非常にカメラ周りがデリケートで、別タブや他アプリがカメラを利用している状態でgetUserMediaをするとすぐにDOMExceptionを吐くので注意が必要。
開発時の問題
最近のブラウザではhttpsではないとgetUserMediaが出来ないケースが多い。
本番環境がHttpsではないことは今日日ないと思われるが、開発環境の場合は注意。
ただし、localhostの場合はhttpでもOK。
SDPのやり取り
RTCPeerConnection.createOffer()
を呼ぶと、正確にはSDPではなくRTCSessionDescriptionInitという型のObjectが返る。
これはsetLocalDescripitonする場合にはそのまま引数で渡してもOK。
このObjectはtype(基本的にofferまたはanswer)とsdp文字列で構成されている。
offerかanswerかは自明なケースが多いので、通信の双方でやり取りを行う場合はsdpを文字列としてやり取りすればOKである。
(受け取った側で、typeとsdpを任意のObjectに格納してやれば良い)
やり取りの方法
後述するICE Candidateのやり取りの方法であるが、ここに関しては全くの自由である。
実際にはWebSocketを使ってタイムラグがないように実装するのが普通であるが、
ハンズオン系のサンプルで行われているようにコピー&ペーストで手動伝達しても構わない。
(凡そ文字列のやり取りが出来るのならば何でも通る)
システム構成に応じて一番やりやすい方法を選べばよいと思われる。
(例えばFirebaseを利用したサーバーレスだったらFireStoreとquerySnapshotが良いし、
サーバーがあるならそこでWebSocket通信を行うのがやりやすいはず)
ICEのやり取り
TrickleとVanilla
ICEのやり取り方としてTrickleICEとVanillaICEがあります。
前者はまずSDPだけやり取りしてICE Candidateについては都度やり取りする方式、
後者はICE Candidateの収集が完了するまでSDPの送付を遅らせて、
SDPと一緒にまとめて送ってしまう方式。
同一LAN内で試しに動かしてみるくらいだと、Vanillaでも普通に動くので大丈夫そうな気がするが、
実際に本運用に乗せると体感レベルでパフォーマンスが違うので基本Trickle ICEを使うものだと思っていて問題ない。
ICEの取得とやり取りの方法
基本的に、onIceCandidateの中で非同期で取得する
全て集めきった場合にも呼ばれるので、event.candidateの存在確認は必要である。
connection.onicecandidate = (event) => {
if (event.candidate) {//candidateがundefinedの場合は全て集めきった場合
//candidateがある場合の処理
}
ICEを文字列としてやり取りする
IceCandidateを文字列に変換する場合は以下のように行う
JSON.stringify(event.candidate.toJSON())
event.candidateで取得できるRTCIceCandidateはそのまま文字列出来ないので、
toJSONを呼んでRTCIceCandidateInitに変換した上で、JSON.stringifyで文字列化すればOK。
復号は以下のように行う。
const iceCandidate = new RTCIceCandidate(JSON.parse(iceString))
rtcPeerConnection.addCandidate(iceCandidate);
このようにすることで、同様に文字列→init→Candidateと変換できる。
streamの取り出し
onTrackで取り出す
基本的にはこの方法でOK
onTrackにはRTCTrackEventが渡ってくるので、event.streams[0]
でMediaStreamが取り出せる。
後はvideoのsrcObjectに入れればOK
Receiverから引っこ抜く
何らかの理由でonTrackで上手く行かない場合
WebRTCの通信準備を始めた時点で、内部的にRTCRtpReceiverが生成されている。
内部的に、これがよろしく受信処理を行っているわけだが、これは受信したMediaStreamTrackを保持している。
よって、connectionStateChangeをトリガーにして以下のようにも書ける
connection.onconnectionstatechange = () => {
if (connection.connectionState === 'connected') {
const tracks = connection.getReceivers().map(r => r.track);
const stream = new MediaStream(tracks);
//MediaStreamを利用した処理
}
}
videoにsrcObjectを投入する時にやらかしたこと
投入後video.play()を呼び忘れる
play()系で再生を開始しない限り画面が黒いまま
playsInlineを付けておらずiOS系で動かない
特にWindowで開発している場合は気づきにくいので注意
Debugに役立つもの
chromeでchrome://webrtc-internals
をアドレスバーに入れるとWebRTC用のDebugツールが起動する。
コロコロ仕様が変わるのが玉に瑕だが、connectionStateの変化やデータの流れ等がみられるのでつながらない時の切り分けに役立つ