HTML5Experts.jp

シグナリングサーバーを動かそう ーWebRTC入門2016

連載: WebRTC入門2016 (3)

こんにちは! 2014年に連載した「WebRTCを使ってみよう!」シリーズのアップデート記事も3回目となりました。今回は、前回の「手動」で行ったP2P通信の準備を、自動で行えるようにしてみましょう。

シグナリングサーバーを立てよう

前回は手動でコピー&ペーストを行い、WebRTCのP2P通信を始めるために次の情報を交換しました。

今回はこれを仲介するサーバー(シグナリングサーバー)を動かしてみましょう。方法として次の2つをご用意しました。

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 ---');

textForSendSdp.value = sessionDescription.sdp;
/*--- テキストエリアをハイライトするのを止める
textForSendSdp.focus();
textForSendSdp.select();
----*/

// --- シグナリングサーバーに送る ---
let message = JSON.stringify(sessionDescription);
console.log('sending SDP=' + message);
ws.send(message);

}

SDPをJSONテキストに変換してWebSocketでシグナリングサーバーに送信しています。

実際に動かしてみよう

シグナリングサーバーを起動して、ChromeかFirefoxのウィンドウを2つ開いて修正したHTMLを読み込んでください。ChromeとFirefoxの間で通信することもできます。

Webサーバーを立てるのが難しい場合は、GitHub Pages にもサンプルを公開しているので、そちらで試すこともできます。その場合でもシグナリングサーバーは自分で用意する必要があるのでご注意ください。

(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も用意しています。

2台のPC間の通信

ここまできたら、せっかくなので2台の別々のPCで通信してみたくなります。同じネットワークに属するPC同士ならば通信できるはずです。例として次のような状況を考えてみましょう。

Firefoxの場合は、接続するURLを変更すれば問題なく動きます。やっかいなのはChromeの場合です。

きちんと対処すると、次のような対策が必要です。ちょっと試すにはハードルが高いですよね。

そこで実験的に無理やり動かすには、次のような方法があります。Webサーバーとシグナリングサーバーは同一である必要はなく、また異なるWebサーバーでも構わないことを利用しています。

お勧めはしませんが、どうしてもやりたい場合の参考としてどうぞ。

次回は

今回はNode.jsとWebSocketを使ったシグナリングを実現しました。残念ながら今回の仕組みでは、1対1の通信しか行うことができません。次回はこれを拡張し、複数人で同時に通信できるようにしたいと思います。

オマケ:WebRTCの仕様の差分のおさらい

オマケとして、今回のWebRTC再入門2016シリーズで取り上げているWebRTC関連仕様の変更箇所について、おさらいしおきましょう。(2016年6月現在)

getUserMeida

ベンダープレフィックスの除去

RTCPeerConnection

仕様は常に更新されていますし、ブラウザの実装状況も異なります。最新の情報もご確認ください。