...

среда, 15 апреля 2015 г.

[Из песочницы] Многопользовательский чат с изпользованием WebRTC

image

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



Интерфейс RTCPeerConnection представляет собой peer-to-peer подключение между двумя браузерами. Чтобы соединить трех и более пользователей, нам придется организовать mesh-сеть (сеть, в которой каждый узел подключен ко всем остальным узлам).

Будем использовать следующую схему:



  1. При открытии страницы проверяем наличие ID комнаты в location.hash

  2. Если ID комнаты не указано, генерируем новый

  3. Отправляем signalling server'у сообщение о том, что мы хотим присоединиться к указанной комнате

  4. Signalling server разсылает остальным клиентам в этой комнате оповещение о новом пользователе

  5. Клиенты, уже находящиеся к комнате, отправляют новичку SDP offer

  6. Новичок отвечает на offer'ы




0. Signalling server




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

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) {
// Если в сообщении указан получатель и этот получатель известен серверу, отправляем сообщение только ему...
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];
});
});
};




1. index.html




Исходный код самой страницы довольно простой. Я сознательно не стал уделять внимание верстке и прочим красивостям, так как это статья не об этом. Если кому-то захочется, сделать ее красивой, особого труда не составит.

<html>
<head>
<title>WebRTC Chat Demo</title>
<script src="/http://ift.tt/1aeIZU4"></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;




2.1. Определение ID комнаты



Тут нам понадобится функция, для генерации уникального идентификатора комнаты и пользователя. Будем использовать для этих целей 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();




2.2. WebSocket



Сразу при открытии страницы подключимся к нашему 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}));
}




2.3. Настройки PeerConnection



Большинство провайдеров предоставляем подключение к Интернету через NAT. Из-за этого прямое подключение становится не таким уж тривиальным делом. При создании соединения нам нужно указать список STUN и TURN серверов, которые браузер будет пытаться использовать для обхода 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>";
};
}




2.5. SDP offer, SDP answer, ICE candidate



При получении одного из этих сообщений вызываем обработчик соответствующего сообщения.

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. Список источников





  1. http://ift.tt/Ra3p9x

  2. http://ift.tt/1xrfAVe

  3. http://ift.tt/1CN1Kfx


This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий