こんにちは! 2014年に連載した「WebRTCを使ってみよう!」シリーズのアップデート記事も3回目となりました。今回は、前回の「手動」で行ったP2P通信の準備を、自動で行えるようにしてみましょう。
シグナリングサーバーを立てよう
前回は手動でコピー&ペーストを行い、WebRTCのP2P通信を始めるために次の情報を交換しました。
- SDP
- ICE candidate
今回はこれを仲介するサーバー(シグナリングサーバー)を動かしてみましょう。方法として次の2つをご用意しました。
- Node.jsを使ったシグナリングサーバー
- Chromeアプリ
Node.jsを準備しよう
まず、WebSocketを使ってシグナリングを行う方法をご紹介します。WebSocketの扱いやすさから、ここではNode.jsを使います。(もちろん他の言語を使っても同様にシグナリングサーバーを作ることができます)こちらの公式サイトから、プラットフォームに対応したNode.jsを入手してインストールしてください。今回私は 4.4.7 LTSを使いました。
Node.jsのインストールが完了したら、次はWebSocketサーバー用のモジュールをインストールします。コマンドプロンプト/ターミナルから、 次のコマンドを実行してください。 ※必要に応じて、sudoなどをご利用ください。
npm install ws※以前の連載ではsocket.ioを使いましたが、今回はよりプリミティブなwsを使っています。
シグナリングサーバーを動かそう
次のコードを好きなファイル名で保存してください。(例えば signaling.js)
"use strict";ポート番号は必要に応じて変更してください。起動するにはコマンドプロンプト/ターミナルから、 次のコマンドを実行します。let WebSocketServer = require('ws').Server; let port = 3001; let wsServer = new WebSocketServer({ port: port }); console.log('websocket server start. port=' + port);
wsServer.on('connection', function(ws) { console.log('-- websocket connected --'); ws.on('message', function(message) { wsServer.clients.forEach(function each(client) { if (isSame(ws, client)) { console.log('- skip sender -'); } else { client.send(message); } }); }); });
function isSame(ws1, ws2) { // -- compare object -- return (ws1 === ws2);
}
node signaling.jsシグナリングサーバーの動作はシンプルで、クライアントからメッセージを受け取ったら他のクライアントに送信するだけです。
Chromeアプリを使う場合は
場合によってはNode.jsをインストールして動かすのは、ハードルが高くて難しいケースもあるかもしれません。そんな人のために、Chromeアプリで「simple message server」というものを作ってみました。
Chromeを利用したアプリとしてインストールし、アプリタブから起動して利用します。デスクトップ用のChromeが動く環境(Windows, MaxOS X, Linux, ChromeOS)で動くはずです。
起動すると、 ws://localhost:3001/ でクライアントからの接続を待ち受けます。※実装があまいので時々不安定になります。その場合は[restart]ボタンを押してリセットし、ブラウザもリロードして接続しなおしてください。
シグナリング処理を変更しよう
それでは前回の手動シグナリングのコードを、少しずつ変更していきましょう。まずWebSocketで用意したシグナリングサーバーに接続します。JavaScriptに次の処理を追加してください。(URLは使っているポートに合わせて修正してください)
let wsUrl = 'ws://localhost:3001/'; let ws = new WebSocket(wsUrl); ws.onopen = function(evt) { console.log('ws open()'); }; ws.onerror = function(err) { console.error('ws onerror() ERR:', err); };
次に、WebSocketでメッセージを受け取った場合の処理を追加します。
ws.onmessage = function(evt) { console.log('ws onmessage() data:', evt.data); let message = JSON.parse(evt.data); if (message.type === 'offer') { // -- got offer --- console.log('Received offer ...'); textToReceiveSdp.value = message.sdp; let offer = new RTCSessionDescription(message); setOffer(offer); } else if (message.type === 'answer') { // --- got answer --- console.log('Received answer ...'); textToReceiveSdp.value = message.sdp; let answer = new RTCSessionDescription(message); setAnswer(answer); } };JSONテキストからオブジェクトを復元し、typeに応じて前回用意したsetOffer()/setAnswer()を呼び出し、RTCPeerConnectionに渡しています。
SDPの送信
Offer/AnswerのSDPの送信も、WebSocket経由で行います。前回要したsendSdp()を次のように変更します。
function sendSdp(sessionDescription) { console.log('---sending sdp ---');SDPをJSONテキストに変換してWebSocketでシグナリングサーバーに送信しています。textForSendSdp.value = sessionDescription.sdp; /*--- テキストエリアをハイライトするのを止める textForSendSdp.focus(); textForSendSdp.select(); ----*/ // --- シグナリングサーバーに送る --- let message = JSON.stringify(sessionDescription); console.log('sending SDP=' + message); ws.send(message);
}
実際に動かしてみよう
シグナリングサーバーを起動して、ChromeかFirefoxのウィンドウを2つ開いて修正したHTMLを読み込んでください。ChromeとFirefoxの間で通信することもできます。
Webサーバーを立てるのが難しい場合は、GitHub Pages にもサンプルを公開しているので、そちらで試すこともできます。その場合でもシグナリングサーバーは自分で用意する必要があるのでご注意ください。
- GitHub Pages で試す ws_signaling_1to1_vanilla.html
- ※http://~ なのでFirefoxのみ
- GitHub でソースを見る ws_signaling_1to1_vanilla.html
(1) カメラの取得
両方のウィンドウで[Start Video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。
(2) 通信開始
どちらかのウィンドウで[Connect]ボタンを押します。(3)SDP(ICE candidateを含む)が自動で交換され、(4)ビデオ通信が始まります。
手動シグナリングに比べて操作がずっと簡単になりました。これなら実際に利用できそうですね。
Trickle ICE を使ってみよう
コピー&ペーストを手動で行う必要がなくなったので、ICE candidateを発生するたびに交換するTrickle ICE を使ってみましょう。流れはこのような形になります。
すべてのICE candidateが出そろう前にP2P通信が確立する(ことがある)メリットがあります。(※2014年の記事では「すべてのICE candidateの交換が終わるとP2P通信が始まる」と書いていましたが、これは誤りです)
SDPをすぐに送信する
Offer SDP/Answer SDPを生成したら、すぐに相手に送るように変更します。
function makeOffer() { peerConnection = prepareNewConnection(); peerConnection.createOffer() .then(function (sessionDescription) { console.log('createOffer() succsess in promise'); return peerConnection.setLocalDescription(sessionDescription); }).then(function() { console.log('setLocalDescription() succsess in promise');// -- Trickle ICE の場合は、初期SDPを相手に送る -- sendSdp(peerConnection.localDescription); // <--- ここを加える // -- Vanilla ICE の場合には、まだSDPは送らない -- }).catch(function(err) { console.error(err); });
}
function makeAnswer() { console.log('sending Answer. Creating remote session description...' ); if (! peerConnection) { console.error('peerConnection NOT exist!'); return; }
peerConnection.createAnswer() .then(function (sessionDescription) { console.log('createAnswer() succsess in promise'); return peerConnection.setLocalDescription(sessionDescription); }).then(function() { console.log('setLocalDescription() succsess in promise'); // -- Trickle ICE の場合は、初期SDPを相手に送る -- sendSdp(peerConnection.localDescription); // <--- ここを加える // -- Vanilla ICE の場合には、まだSDPは送らない -- }).catch(function(err) { console.error(err); });
}
ICE candidateも、すぐに交換する
ICE candidateを収集した際も、すぐに送るように変更します。
function prepareNewConnection() { // ... 省略 ...// --- on get local ICE candidate peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); // Trickle ICE の場合は、ICE candidateを相手に送る sendIceCandidate(evt.candidate); // <--- ここを追加する // Vanilla ICE の場合には、何もしない } else { console.log('empty ice event'); // Trickle ICE の場合は、何もしない // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る //sendSdp(peer.localDescription); // <-- ここをコメントアウトする } }; // ... 省略 ....
}
function sendIceCandidate(candidate) { console.log('---sending ICE candidate ---'); let obj = { type: 'candidate', ice: candidate }; let message = JSON.stringify(obj); console.log('sending candidate=' + message); ws.send(message); }
合わせてICE candidateをWebSocket経由で受け取った場合の処理も追加しましょう。相手からICE candidateを受け取ったら、その度にRTCPeerConnection.addIceCandidate()で覚えさせます。
ws.onmessage = function(evt) { console.log('ws onmessage() data:', evt.data); let message = JSON.parse(evt.data); if (message.type === 'offer') { // -- got offer --- console.log('Received offer ...'); textToReceiveSdp.value = message.sdp; let offer = new RTCSessionDescription(message); setOffer(offer); } else if (message.type === 'answer') { // --- got answer --- console.log('Received answer ...'); textToReceiveSdp.value = message.sdp; let answer = new RTCSessionDescription(message); setAnswer(answer); } else if (message.type === 'candidate') { // <--- ここから追加 // --- got ICE candidate --- console.log('Received ICE candidate ...'); let candidate = new RTCIceCandidate(message.ice); console.log(candidate); addIceCandidate(candidate); } };さあ、これで修正は完了です。function addIceCandidate(candidate) { if (peerConnection) { peerConnection.addIceCandidate(candidate); } else { console.error('PeerConnection not exist!'); return; } }
Trickle ICEを実行しよう
手順はVanilla ICEの場合と同じです。シグナリングサーバーを起動して、ChromeかFirefoxのウィンドウを2つ開いて修正したHTMLを読み込んでください。あとは同様に[Start Video]→[Connect]です。
見た目も特に変わりはありません。もしかしたら人によっては早く繋がるのを実感できるかもしれません。
GitHub Pages/GitHubも用意しています。
- GitHub Pages で試す ws_signaling_1to1_trickle.html
- ※http://~ なのでFirefoxのみ。シグナリングサーバーは別途localhost上で起動しておく必要があります
- GitHub でソースを見る ws_signaling_1to1_trickle.html
2台のPC間の通信
ここまできたら、せっかくなので2台の別々のPCで通信してみたくなります。同じネットワークに属するPC同士ならば通信できるはずです。例として次のような状況を考えてみましょう。
- IPアドレスが 192.168.0.2 と、 192.168.0.3 の2台のPCがある
- 前者(192.168.0.2)のポート:8080でWebサーバー、ポート:3001でNode.jsのシグナリングサーバーが動いている
Firefoxの場合は、接続するURLを変更すれば問題なく動きます。やっかいなのはChromeの場合です。
- Chromeでは、カメラやマイクにアクセスするためのgetUserMedia()が、原則としてhttp://~では許可されていない
- http://localhost/~ は例外的な扱いで許可されている
きちんと対処すると、次のような対策が必要です。ちょっと試すにはハードルが高いですよね。
- 証明書を取得して https://~ でアクセスするように、Webサーバーに設定
- 合わせて、シグナリングサーバーも wss://~ の暗号化通信を使うように設定
そこで実験的に無理やり動かすには、次のような方法があります。Webサーバーとシグナリングサーバーは同一である必要はなく、また異なるWebサーバーでも構わないことを利用しています。
お勧めはしませんが、どうしてもやりたい場合の参考としてどうぞ。
次回は
今回はNode.jsとWebSocketを使ったシグナリングを実現しました。残念ながら今回の仕組みでは、1対1の通信しか行うことができません。次回はこれを拡張し、複数人で同時に通信できるようにしたいと思います。
オマケ:WebRTCの仕様の差分のおさらい
オマケとして、今回のWebRTC再入門2016シリーズで取り上げているWebRTC関連仕様の変更箇所について、おさらいしおきましょう。(2016年6月現在)
getUserMeida
- navigator.mediaDevices.getUserMedia() が新しく用意された
- 旧APIの navigator.getUserMedia()は Firefoxでは非推奨
- ベンダープレフィックスが取れた
- コールバックではなくPromiseベースになった
- Firefox, Edge で利用可能。Chromeではフラグ指定が必要
ベンダープレフィックスの除去
- Firefoxでは、主要なオブジェクトのベンダープレフィックスが取れた。mozプレフィックス付は非推奨に
- 新:RTCPeerConnection, RTCSessionDescription, RTCIceCandidate
- 旧:mozRTCPeerConnection, mozRTCSessionDescription, mozRTCIceCandidate (非推奨)
- ただしChromeでは、一部ベンダープレフィックス付のまま
- プレフィックス有り: webkitRTCPeerConnection
- プレフィックス無し: RTCSessionDescription, RTCIceCandidate
RTCPeerConnection
- 主要なメソッドがPromiseベースになった
- createOffer(), createAnswer()
- setLocalDescription(), setRemoteDescription()
- メディアストリーム処理の新しいイベントハンドラontrack()が追加、onaddstream()は非推奨
- Firefoxではサポート済、Chromeでは未サポート
仕様は常に更新されていますし、ブラウザの実装状況も異なります。最新の情報もご確認ください。