WebRTC P2Pを使って2つのマシンを接続する

WebRTC P2Pで2つのマシンを接続する方法です。
本サンプルの環境は次のとおりです。

  • シグナリングサーバ、WebサーバーはNode.jsを使用
  • 実行ブラウザはGoogle Chromeを使用
  • シグナリングサーバとWebサーバーは同一マシンに配置
本記事ではシグナリングサーバのみ、サーバと記載しているため、他のサーバーと表記揺れになっています。

まずは、Node.jsをインストールしてください。その後、任意のフォルダを作成し次のコマンドを実行します。

npm init

各項目は空白でも構いません。 また、こちらの記事を参考に、同フォルダ内に疑似のSSL証明書を作成してください。

Node.jsでHTTPSサーバーを起動する方法

シグナリングサーバを使って2つのデバイスを接続する

P2P方式ではシグナリングサーバを使って2つのマシンを接続します。シグナリングサーバはお互いのIPアドレスやポート番号を解決するために必用となります。P2P通信なのに、サーバーレスとならないのはこのためです。

本サンプルではシグナリングにWebSocketを利用します。まず、次のコマンドを実行してwsモジュールをインストールしてください。
wssocket.ioよりシンプルな作りで高速に動作します。

npm install ws

シグナリングサーバのソース

シグナリングサーバとして次のファイルを用意します。
クライアントから受け取ったメッセージをそのまま他のクライアントに送信しています。

ssl-signaling.js
"use strict";

const https = require('https');
const fs = require('fs');
let WebSocketServer = require('ws').Server;
let port = 3010;

var server = https.createServer({
    key: fs.readFileSync('server-key.pem'),
    cert: fs.readFileSync('server-crt.pem')
});

let wssServer = new WebSocketServer({server});
server.listen(port);
console.log('websocket server start. port=' + port);

wssServer.on('connection', function(ws) {
    console.log('-- websocket connected --');
    ws.on('message', function(message) {
        wssServer.clients.forEach(function each(client) {
            if (isSame(ws, client)) {
                console.log('- skip sender -');
            } else {
                client.send(message);
            }
        });
    });
});

function isSame(ws1, ws2) {
    return (ws1 === ws2);     
}

Webサーバーのソース

Webサーバー(フロントエンド)に次の2ファイルを用意します。

https.js
"use strict";

const https = require('https');
const fs = require('fs');

const options = {
    key: fs.readFileSync('server-key.pem'),
    cert: fs.readFileSync('server-crt.pem')
};

const server = https.createServer(options,
    (request, response) => {
        fs.readFile('./index.html', 'UTF-8',
        (error, data) => {
            response.writeHead(200, {'Content-Type':'text/html'});
            response.write(data);
            response.end();
        });
    }
);
server.listen(3001);
console.log('server running...');
index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC P2P</title>
    <style>
        .video-box {
            border: 1px solid #000;
            height: 240px;
            width: 320px;
        }

        .outlined-button {
            background-color: #2196f3;
            border-radius: 4px;
            border: none;
            color: #fff;
            font-size: 16px;
            font-weight: 600;
            line-height: 36px;
            margin-left: 4px;
            outline: none;
            padding: 0 16px;
            text-align: center;
        }
    </style>
</head>
<body>
    <div>
        <p>シグナリングサーバを利用して2つのマシンで通信を行います。</p>
    </div>
    <div id="main-container">
        <button onclick="startVideo()" class="outlined-button">Start</button>
        <button onclick="stopVideo()" class="outlined-button">Stop</button>
        <button type="button" onclick="connect()" class="outlined-button">Connect</button>
        <button type="button" onclick="hangUp()" class="outlined-button">Hang Up</button> 
        <section class="video">
            <video id="local-video" autoplay control class="video-box"></video>
            <video id="remote-video" autoplay control class="video-box"></video>
        </section>
    </div>
<script type="text/javascript">
    let localVideo = document.getElementById('local-video');
    let localStream = null;
    let remoteVideo = document.getElementById('remote-video');
    let peerConnection = null;
    let sdpValue = '';

    RTCPeerConnection = window.RTCPeerConnection;

    // connect-server
    let wsUrl = 'wss://localhost:3010';
    let ws = new WebSocket(wsUrl);
    ws.onopen = function(event) {
        // nop.
    };
    ws.onerror = function(error) {
        // nop.
    };
    ws.onclose = function(event) {
        // nop.
    };
    ws.onmessage = function(event) {
        let message = JSON.parse(event.data);
        if (message.type === 'offer') {
            let offer = new window.RTCSessionDescription(message);
            setOffer(offer);
        }
        if (message.type === 'answer') {
            let answer = new window.RTCSessionDescription(message);
            setAnswer(answer);
        }
    };

    function startVideo() {
        navigator.mediaDevices.getUserMedia({ video:true, audio:true })
        .then(function(stream) {
            localStream = stream;
            localVideo.srcObject = stream;
            localVideo.play();
        }).catch(function(error) {
            console.error('mediaDevice.getUserMedia() error:', error);
            return;
        });
    }

    function stopVideo() {
        if (localStream == null) {
            return;
        }
        for (let track of localStream.getTracks()) {
            track.stop();
        }
        localStream = null;

        // kill local video.
        localVideo.pause();
        localVideo.srcObject = null;
    }

    function connect() {
        if (peerConnection) {
            console.log('already connecting.')
            return;
        }
        makeOffer();
    }

    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');
        }).catch(function(error) {
            console.error(error);
        });
    }

    function setAnswer(sessionDescription) {
        if (!peerConnection) {
            return;
        }
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            console.log('setRemoteDescription(answer) succsess in promise');
        }).catch(function(error) {
            console.error('setRemoteDescription(answer) ERROR: ', error);
        });
    }

    function setOffer(sessionDescription) {
        if (peerConnection) {
            console.error('setOffer ERROR');
        }
        peerConnection = prepareNewConnection();
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            makeAnswer();
        }).catch(function(error) {
            console.error('setRemoteDescription(offer) ERROR: ', error);
        });
    }

    function makeAnswer() {
        if (!peerConnection) {
            return;
        }
        peerConnection.createAnswer()
        .then(function(sessionDescription) {
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function() {
            console.log('setLocalDescription() succsess in promise');
        }).catch(function(error) {
            console.log(error);
        });
    }


    function prepareNewConnection() {
        let pcConfig = {"iceServers":[]};
        let peer = new RTCPeerConnection(pcConfig);

        if ('ontrack' in peer) {
            console.log('-- ontrack');
            peer.ontrack = function(event) {
                let stream = event.streams[0];
                remoteVideo.srcObject = stream;
                remoteVideo.play();
            };
        } else {
            console.log('-- onaddstream');
            peer.onaddstream = function(event) {
                let stream = event.stream;
                remoteVideo.srcObject = stream;
                remoteVideo.play();
            }
        }

        peer.onicecandidate = function(event) {
            if (event.candidate) {
                //
            } else {
                sdpValue = peer.localDescription.sdp;

                // sending server.
                // オブジェクトをJSONの文字列に置き換え.
                let message = JSON.stringify(peer.localDescription);
                ws.send(message);
            }
        }

        peer.oniceconnectionstatechange = function() {
            if (peer.iceConnectionState === 'disconnected') {
                hangUp();
            }
        };

        peer.onremovestream = function(event) {
            // kill remote video.
            remoteVideo.pause();
            remoteVideo.srcObject = null;
        }

        // localStreamの追加.
        if (localStream) {
            peer.addStream(localStream);
        }

        return peer;
    }

    function hangUp() {
        if (peerConnection) {
            peerConnection.close();
            peerConnection = null;
            // kill remote video.
            remoteVideo.pause();
            remoteVideo.srcObject = null;
        }
    }
</script>
</body>
</html>

解説

次の箇所はシグナリングサーバのアドレスとポート番号にします。 本サンプルはWebサーバーとシグナリングサーバを同じマシンに配置しているため、ローカルホストを指定しています。

let wsUrl = 'wss://localhost:3010';
wsのイベントについて
イベント 内容
onclose 切断が完了した時に発生
onerror 接続に失敗した時に発生
onmessage サーバーからのメッセージ受信した時に発生
onopen 接続が確立した時に発生
接続開始した側の処理
  1. Offerを生成しsetLocalDescription()で覚えます
  2. ピアのonicecandidateイベントで覚えておいたOfferを送信します
  3. Answerを受信したらsetRemoteDescription()で覚えます
接続を受けた側の処理
  1. メッセージでOfferを受信したらsetRemoteDescription()で覚えます
  2. Answerを生成しsetLocalDescription()で覚えます
  3. ピアのonicecandidateイベントで覚えておいたAnswerを送信します

実行

シグナリングサーバ側で次のコマンドを実行します。

node ssl-signaling.js

Webサーバー側で次のコマンドを実行します。シグナリングサーバと同じマシンを使っている場合はコマンドプロンプトなどを2つ起動して実行します。

node https.js

https://{サーバー名}:3001/にGoogle Chromeでアクセスします。
ローカルマシンの場合はhttps://localhost:3001/となります。

Startボタンを押すと自分の映像が表示されます。音声を出力した場合、ハウリングするので注意してください。
一方の画面でConnectボタンを押すとお互いの映像が表示されます。
1台のマシンで試す場合は、Google Chromeのタブを2つ開き、それぞれでアクセスしてください。

Trickle ICE を使う

上記の場合はVanilla ICEという方式でした。今度はTrickle ICEという方式にします。各方式の詳細はより詳しいサイトを見ていただければと思います。ざっくりこんな感じです。

Vanilla ICE 全ての接続候補を収集してからピアを決定
Trickle ICE 候補が見つかったらすぐに接続する

index.htmlを次のように変更するとTrickle ICE方式になります。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC P2P</title>
    <style>
        .video-box {
            border: 1px solid #000;
            height: 240px;
            width: 320px;
        }

        .outlined-button {
            background-color: #2196f3;
            border-radius: 4px;
            border: none;
            color: #fff;
            font-size: 16px;
            font-weight: 600;
            line-height: 36px;
            margin-left: 4px;
            outline: none;
            padding: 0 16px;
            text-align: center;
        }
    </style>
</head>
<body>
    <div>
        <p>シグナリングサーバを利用して2つのマシンで通信を行います。</p>
    </div>
    <div id="main-container">
        <button onclick="startVideo()" class="outlined-button">Start</button>
        <button onclick="stopVideo()" class="outlined-button">Stop</button>
        <button type="button" onclick="connect()" class="outlined-button">Connect</button>
        <button type="button" onclick="hangUp()" class="outlined-button">Hang Up</button> 
        <section class="video">
            <video id="local-video" autoplay control class="video-box"></video>
            <video id="remote-video" autoplay control class="video-box"></video>
        </section>
    </div>
<script type="text/javascript">
    let localVideo = document.getElementById('local-video');
    let localStream = null;
    let remoteVideo = document.getElementById('remote-video');
    let peerConnection = null;

    RTCPeerConnection = window.RTCPeerConnection;

    // connect-server
    let wsUrl = 'wss://localhost:3010';
    let ws = new WebSocket(wsUrl);
    ws.onopen = function(event) {
        // nop.
    };
    ws.onerror = function(error) {
        // nop.
    };
    ws.onclose = function(event) {
        // nop.
    };
    ws.onmessage = function(event) {
        let message = JSON.parse(event.data);
        if (message.type === 'offer') {
            let offer = new window.RTCSessionDescription(message);
            setOffer(offer);
        }
        if (message.type === 'answer') {
            let answer = new window.RTCSessionDescription(message);
            setAnswer(answer);
        }
        if (message.type === 'candidate') {
            let candidate = new window.RTCIceCandidate(message.ice);
            addIceCandidate(candidate);
        }
    };

    function startVideo() {
        navigator.mediaDevices.getUserMedia({ video:true, audio:true })
        .then(function(stream) {
                localStream = stream;
                localVideo.srcObject = stream;
                localVideo.play();
        }).catch(function(error) {
            console.error('mediaDevice.getUserMedia() error:', error);
            return;
        });
    }

    function stopVideo() {
        if (localStream == null) {
            return;
        }
        for (let track of localStream.getTracks()) {
            track.stop();
        }
        localStream = null;

        // kill local video.
        localVideo.pause();
        localVideo.srcObject = null;
    }

    function connect() {
        if (peerConnection) {
            console.log('already connecting.')
            return;
        }
        makeOffer();
    }

    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(error) {
            console.error(error);
        });
    }

    function setAnswer(sessionDescription) {
        if (!peerConnection) {
            return;
        }
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            console.log('setRemoteDescription(answer) succsess in promise');
        }).catch(function(error) {
            console.error('setRemoteDescription(answer) ERROR: ', error);
        });
    }

    function setOffer(sessionDescription) {
        if (peerConnection) {
            console.error('setOffer ERROR');
        }
        peerConnection = prepareNewConnection();
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            makeAnswer();
        }).catch(function(error) {
            console.error('setRemoteDescription(offer) ERROR: ', error);
        });
    }

    function makeAnswer() {
        if (!peerConnection) {
            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(error) {
            console.log(error);
        });
    }


    function prepareNewConnection() {
        let pcConfig = {"iceServers":[]};
        let peer = new RTCPeerConnection(pcConfig);

        if ('ontrack' in peer) {
            console.log('-- ontrack');
            peer.ontrack = function(event) {
                if (remoteVideo.srcObject) {
                    // video と audioで2回届くので2回目を無視する.
                    console.log('already stream attached.')
                } else {
                    let stream = event.streams[0];
                    remoteVideo.srcObject = stream;
                    remoteVideo.play();
                }
            };
        } else {
            console.log('-- onaddstream');
            peer.onaddstream = function(event) {
                let stream = event.stream;
                remoteVideo.srcObject = stream;
                remoteVideo.play();
            }
        }

        peer.onicecandidate = function(event) {
            if (event.candidate) {
                // Trickle ICE > ICE candidateを送る.
                sendIceCandidate(event.candidate);

                // Vanilla ICE > 何もしない.
            } else {
                // Trickle ICE > 何もしない.

                // Vanilla ICE > candidateを含んだSDPを送る.
                //sendSdp(peer.localDescription);
            }
        }

        peer.oniceconnectionstatechange = function() {
            if (peer.iceConnectionState === 'disconnected') {
                hangUp();
            }
        };

        peer.onremovestream = function(event) {
            // kill remote video.
            remoteVideo.pause();
            remoteVideo.srcObject = null;
        }

        // localStreamの追加.
        if (localStream) {
            peer.addStream(localStream);
        }

        return peer;
    }

    function sendSdp(sessionDescription) {
        // sending server.
        // オブジェクトをJSONの文字列に置き換え.
        let message = JSON.stringify(sessionDescription);
        ws.send(message);
    }

    function sendIceCandidate(candidate) {
        let obj = { type: 'candidate', ice: candidate };
        let message = JSON.stringify(obj);
        ws.send(message);
    }

    function addIceCandidate(candidate) {
        if (!peerConnection) {
            console.error('PeerConnection is not exist');
            return;
        }
        peerConnection.addIceCandidate(candidate);
    }

    function hangUp() {
        if (peerConnection) {
            peerConnection.close();
            peerConnection = null;
            // kill remote video.
            remoteVideo.pause();
            remoteVideo.srcObject = null;
        }
    }
</script>
</body>
</html>

参考

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

このエントリーをはてなブックマークに追加
にほんブログ村 IT技術ブログへ

コメント

メールアドレスが公開されることはありません。 が付いている欄は必須項目です