Многопользовательский чат с изпользованием WebRTC

Многопользовательский чат с изпользованием WebRTC

530
ПОДЕЛИТЬСЯ

В Вебе достаточно много руководств по написанию собственного видео-чата при помощи WebRTC. В данной статье я постараюсь поведать о том, как при помощи WebRTC организовать подключение и обмен сообщениями меж 3-мя и наиболее юзерами. WebRTC – это API , предоставляемое браузером и позволяющее организовать P2P соединение и передачу данных впрямую меж браузерами. Но, все они ограничиваются соединением 2-ух клиентов. К примеру, вот статья на Хабре.

Интерфейс RTCPeerConnection представляет собой peer-to-peer подключение меж 2-мя браузерами. Чтоб соединить 3-х и наиболее юзеров, нам придется организовать mesh-сеть (сеть, в которой каждый узел подключен ко всем остальным узлам).
Будем применять последующую схему:
При открытии странички проверяем наличие ID комнаты в location.hash
Раз ID комнаты не указано, генерируем новейший
Отправляем signalling server’у сообщение о том, что мы желаем присоединиться к указанной комнате
Signalling server разсылает остальным клиентам в данной комнате оповещение о новеньком юзере
Клиенты, уже находящиеся к комнате, посылают новенькому SDP offer
Новичок отвечает на offer’ы

Signalling server 0.
В этом примере в качестве такового транспорта выступает WebSocket сервер, написанный на Node.JS с внедрением socket.io: Как понятно, хоть WebRTC и предоставляет возможность P2P соединения меж браузерами, для его работы всё равно требуется доп транспорт для обмена сервисными сообщениями.

users[json.to].emit("webrtc", message);
} else {
// …по другому считаем сообщение широковещательным
socket.broadcast.to(socket.room).emit("webrtc", message);
}
});

// Кто-то отсоединился
socket.on("disconnect", function() {
// При отсоединении клиента, оповещаем о этом других
socket.broadcast.to(socket.room).emit("leave", socket.user_id);
delete users[socket.user_id];
});
});
}; var socket_io = require("socket.io");

module.exports = function (server) {
var users = {};
var io = socket_io(server);
io.on("connection", function(socket) {

// Желание новейшего юзера присоединиться к комнате
socket.on("room", function(message) {
var json = JSON.parse(message);
// Добавляем сокет в перечень юзеров
users[json.id] = socket;
if (socket.room !== undefined) {
// Раз сокет уже находится в какой-то комнате, выходим из нее
socket.leave(socket.room);
}
// Входим в запрошенную комнату
socket.room = json.room;
socket.join(socket.room);
socket.user_id = json.id;
// Отправялем остальным клиентам в данной комнате сообщение о присоединении новейшего участника
socket.broadcast.to(socket.room).emit("new", json.id);
});

// Сообщение, связанное с WebRTC (SDP offer, SDP answer либо ICE candidate)
socket.on("webrtc", function(message) {
var json = JSON.parse(message);
if (json.to !== undefined && users[json.to] !== undefined) {
// Раз в сообщении указан получатель и этот получатель известен серверу, отправляем сообщение лишь ему…
index.html 1.
Начальный код самой странички достаточно обычный. Раз кому-то захочется, сделать ее прекрасной, особенного труда не составит. Я сознательно не стал уделять внимание верстке и иным красивостям, так как это статья не о этом.

<html>
<head>
<title>WebRTC Chat Demo</title>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div>Connected to <span id="connection_num">0</span> peers</div>
<div><textarea id="message"></textarea><br/><button onclick="sendMessage();">Send</button></div>
<div id="room_link"></div>
<div id="chatlog"></div>
<script type="text/javascript" src="/javascripts/main.js"></script>
</body>
</html>
2. main.js
2.0. Получение ссылок на элементы странички и интерфейсы WebRTC
var chatlog = document.getElementById("chatlog");
var message = document.getElementById("message");
var connection_num = document.getElementById("connection_num");
var room_link = document.getElementById("room_link");
Нам по прежнему приходится применять браузерные префиксы для обращения к интерфейсам WebRTC.

var PeerConnection = window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
var SessionDescription = window.mozRTCSessionDescription || window.RTCSessionDescription;
var IceCandidate = window.mozRTCIceCandidate || window.RTCIceCandidate;
Определение ID комнаты 2.1.
Будем применять для этих целей UUID. Здесь нам пригодится функция, для генерации неповторимого идентификатора комнаты и юзера.

function uuid () {
var s4 = function() {
return Math.floor(Math.random() * 0x10000).toString(16);
};
return s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4();
}
Раз такового не задано, сгенерируем новейший. Сейчас попробуем вынуть идентификатор комнаты из адреса. Выведем на страничку ссылку на текущую комнату, и, за одно, сгенерируем идентификатор текущего юзера.

var ROOM = location.hash.substr(1);

if (!ROOM) {
ROOM = uuid();
}
room_link.innerHTML = "<a href=’#"+ROOM+"’>Link to the room</a>";

var ME = uuid();
WebSocket 2.2.
Сходу при открытии странички подключимся к нашему signalling server’у, отправим запрос на вход в комнату и укажем обработчики сообщений.

// Указываем, что при закрытии сообщения необходимо выслать серверу оповещение о этом
var socket = io.connect("", {"sync disconnect on unload": true});
socket.on("webrtc", socketReceived);
socket.on("new", socketNewPeer);
// Сходу отправляем запрос на вход в комнату
socket.emit("room", JSON.stringify({id: ME, room: ROOM}));

// Вспомогательная функция для отправки адресных сообщений, связанных с WebRTC
function sendViaSocket(type, message, to) {
socket.emit("webrtc", JSON.stringify({id: ME, to: to, type: type, data: message}));
}
Опции PeerConnection 2.3.
При разработке соединения нам необходимо указать перечень STUN и TURN серверов, которые браузер будет пробовать применять для обхода NAT. Большая часть провайдеров предоставляем подключение к Вебу через NAT. Так же укажем пару доп опций для подключения. Из-за этого прямое подключение становится не таковым уж элементарным делом.

var server = {
iceServers: [
{url: "stun:23.21.150.121"},
{url: "stun:stun.l.google.com:19302"},
{url: "turn:numb.viagenie.ca", credential: "your password goes here", username: "example@example.com"}
]
};
var options = {
optional: [
{DtlsSrtpKeyAgreement: true}, // требуется для соединения меж Chrome и Firefox
{RtpDataChannels: true} // требуется в Firefox для использования DataChannels API
]
}
Подключение новейшего юзера 2.4.
Когда в комнату добавляется новейший пир, сервер посылает нам сообщение new. Согласно обработчикам сообщений, указанным выше, вызовется функция socketNewPeer.

var peers = {};

function socketNewPeer(data) {
peers[data] = {
candidateCache: []
};

// Создаем новое подключение
var pc = new PeerConnection(server, options);
// Инициализирууем его
initConnection(pc, data, "offer");

// Сохраняем пира в перечне пиров
peers[data].connection = pc;

// Создаем DataChannel по которому и будет происходить обмен сообщениями
var channel = pc.createDataChannel("mychannel", {});
channel.owner = data;
peers[data].channel = channel;

// Устанавливаем обработчики событий канала
bindEvents(channel);

// Создаем SDP offer
pc.createOffer(function(offer) {
pc.setLocalDescription(offer);
});
}

function initConnection(pc, id, sdpType) {
pc.onicecandidate = function (event) {
if (event.candidate) {
// При обнаружении новейшего ICE кандидата добавляем его в перечень для предстоящей отправки
peers[id].candidateCache.push(event.candidate);
} else {
// Когда обнаружение кандидатов завершено, обработчик будет вызван еще раз, но без кандидата
// В этом случае мы отправялем пиру поначалу SDP offer либо SDP answer (в зависимости от параметра функции)… sendViaSocket(sdpType, pc.localDescription, id);
// …а потом все отысканные ранее ICE кандидаты
for (var i = 0; i < peers[id].candidateCache.length; i++) {
sendViaSocket("candidate", peers[id].candidateCache[i], id);
}
}
}
pc.oniceconnectionstatechange = function (event) {
if (pc.iceConnectionState == "disconnected") {
connection_num.innerText = parseInt(connection_num.innerText) — 1;
delete peers[id];
}
}
}

function bindEvents (channel) {
channel.onopen = function () {
connection_num.innerText = parseInt(connection_num.innerText) + 1;
};
channel.onmessage = function (e) {
chatlog.innerHTML += "<div>Peer says: " + e.data + "</div>";
};
}
SDP offer, SDP answer, ICE candidate 2.5.
При получении 1-го из этих сообщений вызываем обработчик соответственного сообщения.
function socketReceived(data) {
var json = JSON.parse(data);
switch (json.type) {
case "candidate":
remoteCandidateReceived(json.id, json.data);
break;
case "offer":
remoteOfferReceived(json.id, json.data);
break;
case "answer":
remoteAnswerReceived(json.id, json.data);
break;
}
}
2.5.0 SDP offer
function remoteOfferReceived(id, data) {
createConnection(id);
var pc = peers[id].connection;

pc.setRemoteDescription(new SessionDescription(data));
pc.createAnswer(function(answer) {
pc.setLocalDescription(answer);
});
}
function createConnection(id) {
if (peers[id] === undefined) {
peers[id] = {
candidateCache: []
};
var pc = new PeerConnection(server, options);
initConnection(pc, id, "answer");

peers[id].connection = pc;
pc.ondatachannel = function(e) {
peers[id].channel = e.channel;
peers[id].channel.owner = id;
bindEvents(peers[id].channel);
}
}
}
2.5.1 SDP answer
function remoteAnswerReceived(id, data) {
var pc = peers[id].connection;
pc.setRemoteDescription(new SessionDescription(data));
}
2.5.2 ICE candidate
function remoteCandidateReceived(id, data) {
createConnection(id);
var pc = peers[id].connection;
pc.addIceCandidate(new IceCandidate(data));
}
Отправка сообщения 2.6.
Всё, что она делает, это проходится по списку пиров, и пробует выслать всем указанное сообщение. При нажатии на клавишу Send вызывается функция sendMessage.

function sendMessage () {
var msg = message.value;
for (var peer in peers) {
if (peers.hasOwnProperty(peer)) {
if (peers[peer].channel !== undefined) {
try {
peers[peer].channel.send(msg);
} catch (e) {}
}
}
}
chatlog.innerHTML += "<div>Peer says: " + msg + "</div>";
message.value = "";
}
Отключение 2.7.
Ну и в завершении, при закрытии странички, отлично бы закрыть все открытые подключения.

window.addEventListener("beforeunload", onBeforeUnload);

function onBeforeUnload(e) {
for (var peer in peers) {
if (peers.hasOwnProperty(peer)) {
if (peers[peer].channel !== undefined) {
try {
peers[peer].channel.close();
} catch (e) {}
}
}
}
}
3. Перечень источников
http://www.html5rocks.com/en/tutorials/webrtc/basics/
https://www.webrtc-experiment.com/docs/WebRTC-PeerConnection.html
https://developer.mozilla.org/en-US/docs/Web/Guide/API/WebRTC/WebRTC_basics habrahabr.ru