【WebRTC】シグナリングサーバーをたててPCとスマホでビデオチャット

2019年4月28日

環境

今回はSTUN/TURNサーバーは使わないのでVMware上にCentOS7.5を立てて
NginxでWebClient、Node.jsでシグナリングサーバーを構築していきます。
またGoogleChromeなどのブラウザではWebRTCをするにはSSL対応でなければ
ならないためopensslでオレオレ証明書を発行します。

手順

  1. VMwareでCentOS7を立ててNginx(Webサーバー)をインストール
  2. anyenv、nodenvを導入してNode.jsをインストール
  3. OpenSSLをインストールしオレオレ証明書を発行
  4. Node.jsでシグナリングサーバーを作り起動しておく
  5. JavaScriptでWebRTCクライアントを作る

1.VMwareでCentOS7を立ててNginxをインストール

WebRTCではNAT経由した端末同士でも第3者のシグナリングサーバーを通じて
お互いの環境で配信ができるかOffer/Answerの通信をし、TURNサーバーなどで
情報の横流しをしてP2P通信を実現しています。

全部ローカルでやってもいいのですがシグナリングのイメージをつかむため仮想でたててみましょう。
まぁここらへんの環境はお好みで大丈夫です。

nginxを立てるまでのやり方は過去に紹介しておりますのでそちらを参照してください。

【初心者】VMware上にCentOS7.5サーバーをたてる
CentOS7+nginx+phpenvの環境を作る

phpは今回使わないのでphpenvの導入などはしなくてもいいです。
かわりにnodenvを導入してください(次章)

2.anyenv、nodenvを導入してNode.jsをインストール

こちらについても過去に紹介していますのでそちらを参考にインストールしてください。
Node.jsは偶数バージョンが安定版なので10.x.xの一番新しいやつをインストールすることをおすすめします。

【初心者】Node.jsのインストール方法 – anyenvでバージョン管理しよう

またanyenvでNode.jsをインストールするとsudoが使えない場合があるので
~/.anyenv/envs/nodenv/shimsを/usr/binにリンクを作っておきましょう

sudo ln -s ~/.anyenv/envs/nodenv/shims/node /usr/bin/node
sudo ln -s ~/.anyenv/envs/nodenv/shims/npm /usr/bin/npm
sudo ln -s ~/.anyenv/envs/nodenv/shims/npx /usr/bin/npx

sudo node -vができたら成功です。

3.OpenSSLをインストールしオレオレ証明書を発行

Edgeなどのブラウザでは問題ないのですがGoogleChromeなどでは
映像を映すWebページ及びWebSocketの通信はSSL対応でないと通信できません。

ということで今回は外部に公開するわけではないのでOpenSSLでオレオレ証明書を発行します。

sudo yum install -y openssl

インストールできたら以下のコマンドで証明書を発行します。
途中で国とかいろいろ聞かれますが全部そのままエンターで大丈夫です。

make pki
openssl genrsa 2048 > pki/key.pem
openssl req -new -key pki/key.pem > pki/cacert.pem
openssl x509 -days 3650 -req -signkey pki/key.pem < pki/cacert.pem > pki/cert.pem

次に443ポート(https)を開放します。

sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

次にnginxにSSLの設定をしていきます。
/etc/nginx/conf.d/**.confに以下を変更・追記します。
(インストールしたばかりならどうせ仮想だしdefault.confにでも)

listen 443 ssl;
ssl_certificate さっき作ったやつのPATH/cert.pem;
ssl_certificate_key さっき作ったやつのPATH/key.pem;

nginxを再起動します。

sudo systemctl restart nginx

仮想のIPにホストPCからブラウザでアクセスすると(https://でアクセスすることを忘れずに)
以下のような画面が出ると思いますがGoogleさんがオレオレ証明書に苦言を呈している
だけなので詳細設定を押して「〇〇にアクセスする(安全ではありません)」をクリックしてください。

4.Node.jsでシグナリングサーバーを作り起動しておく

まずは作業用ディレクトリを作って初期化しましょう。

mkdir signaling
cd signaling
npm init

npm initはすべてエンターで初期値が設定されるので特に設定したい項目がなければ連打しましょう

次にsocket.ioを使いますのでnpmでインストールします。

npm install socket.io

最後にシグナリングサーバーの通信に使うポートを開放しておきましょう。
なんでもいいですが今回は8080ポートで。

sudo firewall-cmd --permanent --add-port=8080/tcp
sudo firewall-cmd --reload

以上でシグナリングサーバーを作る準備は完了したのでコードを書いていきます。
お好みの方法でserver.jsを作成してください。
私はVScodeのsftpプラグインを使って自動アップロードしながら書いています。

シグナリングサーバーでは以下の機能を実装します。

  • 指定のルームに参加、存在しなければルームを新規作成
  • クライアントから送られたメッセージを接続中のPeerすべてに送信
  • クライアントから送られた、宛先Peer付きのメッセージをそのPeerに送信

※ルームとはsocket.ioで複数人で通信するときのユニットです
※また、socket.ioではpeerにメッセージを送信することをemitといいます

server.js
"use strict";

/**
 * GoogleChromeではSSL対応でないとSocket.ioが
 * 操作できないのでSSL対応させる
 */
const port = 8080;
const fs = require("fs");
const server = require("https").createServer({
    key: fs.readFileSync("/home/ユーザー名/pki/key.pem").toString(),
    cert: fs.readFileSync("/home/ユーザー名/pki/cert.pem").toString(),
}).listen(port);
const io = require("socket.io").listen(server);
console.log('listen: ' + port);

/**
 * クライアント側から socket.emit("イベント名", データ);
 * のように名付けられたイベントが送られてくるのでonでキャッチして処理する
 * また、socket.ioにはroomという機能で複数人で通信できる
 * 
 * 以下でroomへの参加・退場とroom内のメッセージの振り分けを実装します
 */
io.on("connection", function(socket) {
    // roomに参加
    socket.on("enter", function(roomname) {
        socket.join(roomname);
        socket.roomname = roomname;
        console.log("enter room[" + roomname + "]: " + socket.id);
    });

    // roomから退場
    socket.on("disconnect", function() {
        broadcastEmit("user disconnected", {id: socket.id});

        let roomname = socket.roomname;
        if (roomname) {
            socket.leave(roomname);
        }
    });

    // メッセージの送り主と送り先を特定してemit
    socket.on("message", function(message) {
        message.from = socket.id;
        let target = message.sendto;

        if (target) {
            console.log("message from: " + message.from + " to: room[" + target + "]");
        } else {
            console.log("message from: " + message.from);
        }
        broadcastEmit("message", message);
    });

    // 同roomの接続してるpeerすべてにemit
    function broadcastEmit(type, message) {
        let roomname = socket.roomname;

        if (roomname) {
            console.log(socket.id + " room[" + roomname + "] broadcast: " + type);
            socket.broadcast.to(roomname).emit(type, message);
        } else {
            console.log(socket.id + " broadcast: " + type);
            socket.broadcast.emit(type, message);
        }
    }
});

スクリプトを書き終えたら起動して常駐させておきましょう

node server.js

5.JavaScriptでWebRTCクライアントを作る

P2Pを開始するために以下の二つの情報をシグナリングサーバーを経由して交換していきます。

  • カメラ映像やブラウザの情報が載ったSDP
  • UDPホールパンチングするためのIP:portの候補と優先度

 

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>WebRTC Test</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="style.css">
    <script src="https://192.168.0.24:8080/socket.io/socket.io.js"></script>
  </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>
    <div id="container">
      <video id="local_video" autoplay></video>
    </div>
    <script src="script.js"></script>
  </body>
</html>
script.js
/**
 * 自身のWebカメラの映像をlocal_videoに、
 * WebSocketで接続したpeerの映像をcontainer内に動的に生成します 
 */
const localVideo = document.getElementById("local_video");
const container = document.getElementById("container");

let localStream = null;
let peerConnections = [];
let remoteVideos = [];
const streamOption = {video: {facingMode: "environment"}, audio: false};
const MAX_CONNECTION_COUNT = 5;

// ベンダープレフィックス
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate || window.msRTCIceCandidate;

// ボタンを押して自分のWebカメラの映像を映す
async function startVideo() {
    try {
        localStream = await getDeviceStream(streamOption);
        playVideo(localVideo, localStream);
        console.log("start stream video");
    } catch(error) {
        console.log("can't start local video: " + error);
    }
}
// Webカメラの映像を取得
async function getDeviceStream(option) {
    if ("getUserMedia" in navigator.mediaDevices) {
        return await navigator.mediaDevices.getUserMedia(option);
    }
}
// videoタグにstreamを映す
async function playVideo(element, stream) {
    if ("srcObject" in element) {
        element.srcObject = stream;
    } else {
        element.src = window.URL.createObjectURL(stream);
    }
    try {
        await element.play();
    } catch(error) {
        console.log("stream error: " + error);
    }
}

// ボタンを押して自分のWebカメラの映像を止める
function stopVideo() {
    stopLocalStream(localStream);
    localStream = null;
    console.log("video stream stop");
}
// 映像を取得することをやめる
function stopLocalStream(stream) {
    let tracks = stream.getTracks();
    for (let track of tracks) {
        track.stop();
    }
}

/**
 * シグナリングサーバーを経由してSDPと呼ばれる自分のWebカメラや
 * ブラウザの情報を同room内で交換します
 * 
 * WebRTCでは、このSDPを受け取って相手のWebカメラ映像を取得したい!という
 * 要望を出すことを「Offer」といい、Offerに対して、自分のSDP渡すから
 * あなたのも頂戴!という要望を出すことを「Answer」という
 */
const url = "https://192.168.0.24:8080/";
const socket = io.connect(url, {secure: true});
let room = "testRoom";

// シグナリングサーバー接続成功時
socket.on("connect", function(evt) {
    console.log("signaling server connected\r\nmy id: " + socket.id);
    socket.emit("enter", room);
    console.log("enter room: " + room);
});

/**
 * まずはボタンをお押すことで
 * 同roomのpeerに対してofferを送ってほしいという要望を出します
 */
function connect() {
    if (localStream && peerConnections.length < MAX_CONNECTION_COUNT) {
        socket.emit("message", {type: "call me"});
        console.log("send call me by: " + socket.id);
    }
}
// 各種メッセージの処理
socket.on("message", function(message) {
    let fromId = message.from;
    console.log("message by " + fromId + ": " + message.type);

    switch (message.type) {
        // Offer要請を受けたとき
        case "call me":
            if (localStream && peerConnections.length < MAX_CONNECTION_COUNT) {
                makeOffer(fromId);
                console.log("send offer from: " + socket.id + " to: " + fromId);
            }
            break;
        // Offerを受けたらSDPを生成してAnswerを送る
        case "offer":
            let offer = new RTCSessionDescription(message);
            setOffer(fromId, offer);
            console.log("send answer from: " + socket.id + " to: " + fromId);
            break;
        case "answer":
            let answer = new RTCSessionDescription(message);
            setAnswer(fromId, answer);
            break;
        case "candidate":
            let candidate = new RTCIceCandidate(message.ice);
            addCandidate(fromId, candidate);
            break;
        case "bye":
            break;
    }
});

/**
 * WebRTCでは自分も相手もポート開放などの特別な操作をせずP2P通信をするという
 * ことを実現しています。本来別のネットワーク同士の通信ではNATにより
 * 互いに相手のグローバルIPアドレスを知ることができないため通信ができません。
 * 
 * そのため、自身のグローバルIPを教えてくれるSTUNサーバーをネットワーク外に用意し、
 * ICEという手法で接続できそうなaddr:portを総当たりで通信を試みます。
 * この時の候補を「candidate」と呼びます
 * 
 * それでも通信ができなかった場合、TURNサーバーを経由して通信をします。
 */
function prepareNewConnection(id) {
    // 今回は同ネットワーク内なのでSTUN/TURNサーバーは使いません
    let pc_config = {"iceServers": [/* 本来ここに stun:アドレス のように設定する */]};
    let peer = new RTCPeerConnection(pc_config);

    // SDPの受け渡し(Offer/Answer)と同時に以下のイベントが発火しcandidateを送りあう
    peer.onicecandidate = evt => {
        if (evt.candidate) {
            sendCandidate(id, evt.candidate);
            console.log("send candidate from: " + socket.id + " to: " + id);
        }
    }


    // リモートPeerからトラックを受け取り映像を流す
    peer.ontrack = evt => {
        let stream = evt.streams[0];
        if (!remoteVideos[id]) {
            createVideo(id, stream);
        }
    }

    // Offer/Answerのセット終了後addSすることでonicecandidateが発火
    if (localStream) {
        console.log("adding local stream");
        localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
    }

    return peer;
}
// SDPを指定idに送信する
function sendSdp(id, sessionDescription) {
    let message = {type: sessionDescription.type, sdp: sessionDescription.sdp, sendto: id};
    console.log("send sdp to: " + id);
    socket.emit("message", message);
}

// Offerを作成、SDPをCallMeしたpeerに送信
async function makeOffer(id) {
    peer = prepareNewConnection(id);
    peerConnections[id] = peer;

    let offer = await peer.createOffer();
    await peer.setLocalDescription(offer);

    sendSdp(id, peer.localDescription);
}

// 受け取ったOfferをセットし、Answerを送る
async function setOffer(id, sessionDescription) {
    let peer = prepareNewConnection(id);
    peerConnections[id] = peer;

    await peer.setRemoteDescription(sessionDescription);
    makeAnswer(id);
}
// Answerを作成、SDPをOfferを送信したpeerに送信
async function makeAnswer(id) {
    let peer = peerConnections[id];
    if (peer) {
        let answer = await peer.createAnswer();
        await peer.setLocalDescription(answer);

        sendSdp(id, peer.localDescription);
    }
}

// 受け取ったAnswerをセット
async function setAnswer(id, sessionDescription) {
    let peer = peerConnections[id];
    if (peer) {
        await peer.setRemoteDescription(sessionDescription);
    }
}

// candidateを送る
function sendCandidate(id, candidate) {
    let message = {type: "candidate", ice: candidate};

    if (peerConnections[id]) {
        socket.emit("message", message);
    }
}
// 送られたcandidateをセット
function addCandidate(id, candidate) {
    if (peerConnections[id]) {
        let peer = peerConnections[id];
        peer.addIceCandidate(candidate);
        // このタイミングでonicecandidateが発火するので送り返す
    }
}

// ICEでP2Pができたらリモート映像を出力する
function createVideo(id, stream) {
    let video = document.createElement("video");
    video.id = "remote_" + id;
    container.appendChild(video);
    remoteVideos[id] = video;

    playVideo(video, stream);
}

※socket.io/socket.io.jsは自動生成されるので自分でファイルを用意する必要はありません

以上でPCブラウザとスマホブラウザでそれぞれアクセスすると
カメラ映像を共有することができます。