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モジュールをインストールしてください。
ws
はsocket.io
よりシンプルな作りで高速に動作します。
npm install ws
シグナリングサーバのソース
シグナリングサーバとして次のファイルを用意します。
クライアントから受け取ったメッセージをそのまま他のクライアントに送信しています。
"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ファイルを用意します。
"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...');
<!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 |
接続が確立した時に発生 |
接続開始した側の処理
Offer
を生成しsetLocalDescription()
で覚えます- ピアの
onicecandidateイベント
で覚えておいたOffer
を送信します Answer
を受信したらsetRemoteDescription()
で覚えます
接続を受けた側の処理
- メッセージで
Offer
を受信したらsetRemoteDescription()
で覚えます Answer
を生成しsetLocalDescription()
で覚えます- ピアの
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>