こんにちは! がねこまさしです。前回はWebRTCの通信を手動でつなぎましたが、今回は仲介役のサーバーを作ってみましょう。
※今回の内容は、Node学園祭2013で発表した内容(の一部)とほぼ同じです。その時の資料もご参照ください。
※こちらの記事は2014年に書かれました。2016年7月のアップデート記事がありますので、そちらもご参照ください。
シグナリングサーバーを立てよう
前回は手動でコピー&ペーストしてシグナリングを実現しました。今回はそれを楽にしましょう。
シグナリングサーバーはどうして必要なの?
シグナリングの過程では、お互いのIPアドレスやポート番号を渡す必要があります。この段階ではお互いIPアドレスを知らないので直接やりとりできません。そこで、仲介役となるシグナリングサーバーが必要となります。このサーバーは、どちらブラウザもIPアドレスを知っていることが前提となります。
つまり、Peer-to-Peer通信の開始前には、普通のサーバー/クライアント型の通信が行わることになります。
Node.jsを準備しよう
今回はシグナリング処理をWebSocketを使って実現してみます。ソケットの処理が実現できればどのような言語でも構わないのですが、メッセージング処理が得意なNode.jsを使うことにします。 Node.jsのインストーラーをこちらのサイトから入手し、手順に従ってインストールしてください。Windows,Mac OS X,Linux用のバイナリが用意されています。今回のサンプルはv0.10.15で動作確認していますが、v0.10.x系ならばそのまま動くはずです。
Node.jsのインストールが完了したら、こんどはWebSocket用のモジュールをインストールします。コマンドプロンプト/ターミナルから、 次のコマンドを実行してください。 ※必要に応じて、sudoなどをご利用ください。
1 |
npm install socket.io |
socket.ioは、異なる種類のブラウザ間の通信を簡単に行えるようにしてくれるモジュールです。異なる複数の通信方式をサポートしています。
- ‘websocket’ , ‘flashsocket’ , ‘htmlfile’ , ‘xhr-polling’ , ‘jsonp-polling’
シグナリングサーバーを動かそう
次のコードを好きなファイル名で保存してください。(例えば signaling.js)
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var port = 9001; var io = require('socket.io').listen(port); console.log((new Date()) + " Server is listening on port " + port); io.sockets.on('connection', function(socket) { socket.on('message', function(message) { socket.broadcast.emit('message', message); }); socket.on('disconnect', function() { socket.broadcast.emit('user disconnected'); }); }); |
起動するにはコマンドプロンプト/ターミナルから、 次のコマンドを実行してください。
1 |
node signaling.js |
シグナリングサーバーの動作は単純で、右からきたメッセージをそのまま左に流すだけです。
シグナリング処理を変更しよう
それでは前回のHTMLを、少しずつ変更して行きましょう。まず、socket.ioのクライアント用JavaScriptを読み込みます。localhostの部分は、実際のシグナリングサーバーに変更してください。
1 |
<script src="http://localhost:9001/socket.io/socket.io.js"></script> |
次に、socket.ioの接続、通信処理をJavaScriptに追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://localhost:' + port + '/'); // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage); function onOpened(evt) { console.log('socket opened.'); socketReady = true; } // socket: accept connection request function onMessage(evt) { if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt); } else if (evt.type === 'answer' && peerStarted) { console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && peerStarted) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'user dissconnected' && peerStarted) { console.log("disconnected"); stop(); } } |
重要なのはonMessage()の処理で、Offer SDP,Answer SDP,ICE Candidateのそれぞれに対応して、前回用意したOnOffer(), onAnswer(), onCandidate()を呼び出しています。
今度は実際にSDP/ICE Candidateを送る部分を変更します。前回はテキストエリアに表示するだけでしたが、今回はそれをsocket.io経由で送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); textForSendSDP.value = text; // send via socket socket.json.send(sdp); // <--- ここを追加 } function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); textForSendICE.value = (textForSendICE.value + CR + iceSeparator + CR + text + CR); textForSendICE.scrollTop = textForSendICE.scrollHeight; // send via socket socket.json.send(candidate); // <--- ここを追加 } |
最後は、ちょっとしたフラグの処理の追加です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); peerStarted = true; // <--- ここを追加 } // start the connection upon user request function connect() { if (!peerStarted && localStream && socketReady) { // <--- ここを変更 sendOffer(); peerStarted = true; } else { alert("Local stream not running yet - try again."); } } function stop() { peerConnection.close(); peerConnection = null; peerStarted = false; // <--- ここを追加 } |
実際に動かしてみよう
シグナリングサーバーが動いてることを確認したら、Chromeのウィンドウを2つ開いて修正したHTMLを読み込んでください。
(1) 両方のウィンドウで[Start video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。
(2) どちらかのウィンドウで[Connect]ボタンを押します。SDP, ICE Candidateが自動で交換され、ビデオ通信が始まります。
前回の14ステップに比べて、ぐっと減って2ステップになりました。これなら使えそうですね。
シグナリングの流れを追ってみる
シグナリングの流れを追跡してみましょう。前回はコード上を追っかけたので、今回は流れを図で見てみます。
SDPの交換
SDPのOffer, Answerが、シグナリングサーバー経由で交換されます。
ICE Candidateの交換
複数のICE Candidateが飛び交い、すべての交換が終わるとPeer-to-Peer通信が始まります。
次回は
今回はシグナリングサーバーを動かして、Peer-to-Peer通信確立までを自動化しました(それが普通ですけど)。実は今回の仕組みでは、一つのシグナリングサーバーで同時に2人までしか通信できません。まったく実用的ではありません。次回は複数人での通信にチャレンジする予定です。
今回のソース
最後に、今回使ったHTMLを掲載しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
<!DOCTYPE html> <html> <head> <title>WebRTC 1 to 1 signaling</title> </head> <body> <button type="button" onclick="startVideo();">Start video</button> <button type="button" onclick="stopVideo();">Stop video</button> <button type="button" onclick="connect();">Connect</button> <button type="button" onclick="hangUp();">Hang Up</button> <br /> <div> <video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video> <video id="remote-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video> </div> <p> SDP to send:<br /> <textarea id="text-for-send-sdp" rows="5" cols="100" disabled="1">SDP to send</textarea> </p> <p> SDP to receive:<br /> <textarea id="text-for-receive-sdp" rows="5" cols="100"></textarea><br /> <button type="button" onclick="onSDP();">Receive SDP</button> </p> <p> ICE Candidate to send:<br /> <textarea id="text-for-send-ice" rows="5" cols="100" disabled="1">ICE Candidate to send</textarea> </p> <p> ICE Candidates to receive:<br /> <textarea id="text-for-receive-ice" rows="5" cols="100"></textarea><br /> <button type="button" onclick="onICE();">Receive ICE Candidates</button> </p> <!---- socket ------> <script src="http://localhost:9001/socket.io/socket.io.js"></script> <script> var localVideo = document.getElementById('local-video'); var remoteVideo = document.getElementById('remote-video'); var localStream = null; var peerConnection = null; var peerStarted = false; var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':true }}; // ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://localhost:' + port + '/'); // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage); function onOpened(evt) { console.log('socket opened.'); socketReady = true; } // socket: accept connection request function onMessage(evt) { if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt); } else if (evt.type === 'answer' && peerStarted) { console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && peerStarted) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'user dissconnected' && peerStarted) { console.log("disconnected"); stop(); } } // ----------------- handshake -------------- var textForSendSDP = document.getElementById('text-for-send-sdp'); var textForSendICE = document.getElementById('text-for-send-ice'); var textToReceiveSDP = document.getElementById('text-for-receive-sdp'); var textToReceiveICE = document.getElementById('text-for-receive-ice'); var iceSeparator = '------ ICE Candidate -------'; var CR = String.fromCharCode(13); function onSDP() { var text = textToReceiveSDP.value; var evt = JSON.parse(text); if (peerConnection) { onAnswer(evt); } else { onOffer(evt); } textToReceiveSDP.value =""; } //--- multi ICE candidate --- function onICE() { var text = textToReceiveICE.value; var arr = text.split(iceSeparator); for (var i = 1, len = arr.length; i < len; i++) { var evt = JSON.parse(arr[i]); onCandidate(evt); } textToReceiveICE.value =""; } function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); peerStarted = true; // ++ } function onAnswer(evt) { console.log("Received Answer...") console.log(evt); setAnswer(evt); } function onCandidate(evt) { var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); console.log("Received Candidate...") console.log(candidate); peerConnection.addIceCandidate(candidate); } function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); textForSendSDP.value = text; // send via socket socket.json.send(sdp); } function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); textForSendICE.value = (textForSendICE.value + CR + iceSeparator + CR + text + CR); textForSendICE.scrollTop = textForSendICE.scrollHeight; // send via socket socket.json.send(candidate); } // ---------------------- video handling ----------------------- // start local video function startVideo() { navigator.webkitGetUserMedia({video: true, audio: false}, function (stream) { // success localStream = stream; localVideo.src = window.webkitURL.createObjectURL(stream); localVideo.play(); localVideo.volume = 0; }, function (error) { // error console.error('An error occurred: [CODE ' + error.code + ']'); return; } ); } // stop local video function stopVideo() { localVideo.src = ""; localStream.stop(); } // ---------------------- connection handling ----------------------- function prepareNewConnection() { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create peerConnection, exception: " + e.message); } // send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate} ); } else { console.log("End of candidates. ------------------- phase=" + evt.eventPhase); } }; console.log('Adding local stream...'); peer.addStream(localStream); peer.addEventListener("addstream", onRemoteStreamAdded, false); peer.addEventListener("removestream", onRemoteStreamRemoved, false) // when remote adds a stream, hand it on to the local video element function onRemoteStreamAdded(event) { console.log("Added remote stream"); remoteVideo.src = window.webkitURL.createObjectURL(event.stream); } // when remote removes a stream, remove it from the local video element function onRemoteStreamRemoved(event) { console.log("Remove remote stream"); remoteVideo.src = ""; } return peer; } function sendOffer() { peerConnection = prepareNewConnection(); peerConnection.createOffer(function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); console.log("Sending: SDP"); console.log(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Offer failed"); }, mediaConstraints); } function setOffer(evt) { if (peerConnection) { console.error('peerConnection alreay exist!'); } peerConnection = prepareNewConnection(); peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); } function sendAnswer(evt) { console.log('sending Answer. Creating remote session description...' ); if (! peerConnection) { console.error('peerConnection NOT exist!'); return; } peerConnection.createAnswer(function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); console.log("Sending: SDP"); console.log(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Answer failed"); }, mediaConstraints); } function setAnswer(evt) { if (! peerConnection) { console.error('peerConnection NOT exist!'); return; } peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); } // -------- handling user UI event ----- // start the connection upon user request function connect() { if (!peerStarted && localStream && socketReady) { // ** //if (!peerStarted && localStream) { // -- sendOffer(); peerStarted = true; } else { alert("Local stream not running yet - try again."); } } // stop the connection upon user request function hangUp() { console.log("Hang up."); stop(); } function stop() { peerConnection.close(); peerConnection = null; peerStarted = false; } </script> </body> </html> |