まだまだ安定性にはかけるWebRTCですが、実装で使ってみたいと思ったので、その仕組みについて学習したことを以下にまとめます。自分のような初心者でも以下の記事とコードでかなりどういったことができるのかわかるようになりました。
参考記事
MDN | WebRTC
WebRTC入門2016
https://qiita.com/yusuke84/items/43a20e3b6c78ae9a8f6c
サンプルコード
https://github.com/muaz-khan/WebRTC-Experiment
https://github.com/mganeko/webrtcexpjp
WebRTCとは
ブラウザ環境において、
①カメラ・マイクといったデバイスへアクセスしたり、
②仲介を必要とせずに(実際には完全なサーバーレスとは言い難い)ブラウザ・モバイル間でデータの交換や、
③キャプチャしたオーディオ/ビデオストリームの送受信
を可能にするAPIのことです。
P2P通信とその暗号化(事前に暗号鍵の交換)、UDP/IPの使用(確実性よりリアルタイム性)が特徴です。
WebRTCで利用されているプロトコル
上記のようにWebRTC APIでは多くの技術が利用されています。
P2P通信を行うには、宛先のIPアドレスとUDPポート番号を知る必要があります。以下、WebRTCで利用されるP2P接続における代表的なプロトコルについて説明します。
NAT
言わずと知れたパブリックIPアドレスとプライベートIPアドレスを変換するプロトコル。
パブリックIPアドレスの特定のポートを、特定のプライベートIPアドレスの特定のポートに固定的に対応づけたものをポートマッピングといいます。
STUN
パブリックIPアドレスを発見し、ピアとの直接接続を妨害するルーターの制限を特定するためのプロトコル。
クライアントがSTUNサーバーに問い合わせると、パブリックIPアドレス・ポートとルーターのNAT内部にアクセス可能かどうかを確認することができます。
TURN
「Symmetric NAT」 と呼ばれるルーターの制限を回避するためのプロトコル。
具体的にはパブリックIPを持っているTURNサーバがクライアントにIPとPortを貸し出します。
SDP
送受信するメディア(動画・音声)の解像度、フォーマット、コーデック、暗号化などの、接続のマルチメディアコンテンツを記述するためのプロトコル。
P2P接続によって送受信されるデータを説明するためのメタデータと思っていいようです。
ICE
ブラウザ間でP2P接続を可能にするフレームワーク。これにより、P2P接続するための経路を決定する。
例えば、
- NATを通過するためにはSTUNサーバーからポートマッピングを取得する。
- ファイアウォールなどの理由から直接接続が難しい場合は、TURNサーバーを経由する。
通信経路の候補を「ICE Candidate」と呼び、
すべてのICE Candidateが出揃ってから、SDPとまとめて交換する方式を「Vanilla ICE」、先にSDPだけを交換してからICE Candidateを順次交換する方式を「Trickle ICE」と呼びます。
WebRTCのサポート状況
IEはやはり対応してませんが、それ以外のChrome, Firefox, Safari主要ブラウザでは対応されています。
またデバイスへのアクセスでインカメの取得やスクリーンキャプチャについてもブラウザベンダーごとにコーディングしなくてはなりません。
WebRTC Demos, Experiments, Libraries, ExamplesではWebRTCの機能を利用した色々なアプリが実装されているので参考にできます。またこの開発者の実装したライブラリDetectRTCでブラウザやwebrtcサポート状況について検知できます。
let DetectRTC = require('detectrtc');
DetectRTC.load(function() {
console.log(DetectRTC.isWebRTCSupported);//WebRTCのサポート
console.log(DetectRTC.isScreenCapturingSupported);//スクリーンキャプチャのサポート
console.log(DetectRTC.browser.name);//使用しているブラウザ
})
デバイスへのアクセス
Firefoxだとデバイスへのアクセスはだいたい許容されているので、APIのドキュメントを確認しながら実装しやすいですが、Chromeはサポートしてたりしてなかったりなので、要注意です。以下のように、FirefoxはgetUserMediaでインカメもスクリーンキャプチャも取得できますが、ChromeではgetDisplayMediaを使わないとスクリーンキャプチャできませんでした。ブラウザごとの場合分けは先ほど紹介したDetectRTCを使うと良さそうです。
let video = await document.getElementById('video');
navigator.getUserMedia = await navigator.getUserMedia || navigator.webkitGetUserMedia || window.navigator.mozGetUserMedia;
//インカメなら
navigator.getUserMedia({video: true, audio: true},
(stream) => {
this.localStream = stream;//localStreamはMediaStream.addTrack()を実装するためのトリガー
video.srcObject = stream;
},
(err) => {
console.log(err);
}
);
//Firefoxで画面共有なら
navigator.getUserMedia({video: {mediaSource: "screen"}},
(stream) => {
this.localStream = stream;
video.srcObject = stream;
},
(err) => {
console.log(err);
}
);
//Chromeで画面共有なら(Firefoxでもいけた)
let stream = await navigator.mediaDevices.getDisplayMedia( { video: { displaySurface: "window" } } );
this.localStream = stream;//localStreamはMediaStream.addTrack()を実装するためのトリガー
video.srcObject = stream;
シグナリングサーバー
WebRTCではP2P接続を確定させるために、シグナリングサーバー(SDPやICE Candidateのやりとりを行う)が必要になります。シグナリングサーバーの実装にはWebSocketが使われることが多いようです。
WebSocketの復習
WebScoketとはクライアント、サーバー問わず主体的に発信できる双方向通信を実現するプロトコルです。
TCPと同じレイヤーで、HTTPの3ウェイハンドシェイク後UpgradeヘッダによてWebSocketへ進化します。ですので、アプリケーション層だけでは実現できない高速な通信が実現可能です。
参考までにWebSocketライブラリのsocket.ioのコネクション確立からイベントの送受信について簡単にまとめた図を載せておきます。
シグナリングサーバーの実装
WebSocketライブラリのsocket.ioを用いてWebRTCでのシグナリングサーバーを実装すると以下の通りです。
socket.on('signaling', function (data) {
data.from = socket.id;
let target = data.to
if (target) {
socket.to(target).emit('signaling', data);
return;
}
socket.emit('signaling', data);
});
メディアへのアクセス、RTC Peer Connectionの生成などはクライアント側のscriptに記述するので、シグナリングサーバーは簡単にクライアントからのデータを受信/配信するだけです。以下のように今回はイベント名を"signaling"としています。
クライアントサイドの実装
WebRTCにおいてSDP交渉のやりとりとその後の流れをワークフローを示すと以下のイメージです。 ①,②,⑤,⑨でシグナリングサーバーを介して通信しています。
ここで登場するのが3つのオブジェクトRTCPeerConnection, RTCSessionDescription, RTCIceCandidateです。
要約するとオファーとそれに対するアンサーを返すことで、お互いの接続するセッション情報について交換しています。
それらが終わると、SDP交渉が成立します。
成立後は、RTCIceCandidateを生成して通信経路(ICE Candidate)の交換・追加を行いながら、RTCPeerConnection定義時点で仕込んだイベントハンドラによってストリーム通知やトラック通知を受けて、メディアを送受信します。
socket.on('signaling', function (data) {
switch(data.type) {
//Emitterからのcastを受信
case "cast":
//SubscriberはビデオコネクションのcallをEmitterへ発信
socket.emit('signaling', {type: 'end'});
break;
//Subscriberからのcallを受信
case 'call':
//EmitterはOfferを作成、接続元のセッションを記憶
makeOffer(from);
break;
//Emitterからのofferの受信
case 'offer':
//Subscriberは接続先のセッションを記憶、Answerを作成、接続元のセッションを記憶
setOffer(from, offer);
break;
//Subscriberからのanswerを受信
case 'answer':
//Emitterは接続先のセッションを記憶(SDP交渉成立)
setAnswer(from, answer);
break;
//ICE Candidateを受信
case 'candidate':
//ICE Candidateを候補に追加する
addIceCandidate(from, candidate);
break;
//P2P通信を終了する通知を受信
case 'end':
//終了の処理
break;
default:
break;
}
});
以下のようにRTCPeerConnectionにはいくつかイベントハンドラを仕込まれており、これでICE Candidateの交換や相手との動画共有が可能になります。
prepareNewConnection(id) {
let peer = new RTCPeerConnection({"iceServers": []});
//ビデオストリームを受信した時の処理
//attachVideoで<video>に受信した動画を流す
peer.ontrack = (event) => {
let stream = event.streams[0];
console.log('-- peer.ontrack() stream.id=' + stream.id);
attachVideo(id, stream);
};
//ICE Candidateを受信した時の処理
peer.onicecandidate = (evt) => {
if (evt.candidate) {
console.log(evt.candidate);
this.sendIceCandidate(id, evt.candidate);
} else {
console.log('empty ice event');
console.log(this.peerConnections);
}
};
//ビデオストリームの受信が途切れた時の処理
peer.onremovestream = (event) => {
console.log('-- peer.onremovestream()');
detachVideo();
};
}
ざっと学んで見たことについて確認して見ましたが、APIが読みづらかったり、実際に運用するとブラウザサポートが壁になるかもしれませんが、それでもブラウザ上でP2P通信ができるのはなかなか楽しいものです。
初心者にとってはデバイスへのアクセスと、コネクション確立までの流れを抑えておくことが大事でした。