Многопользовательский чат с изпользованием 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="/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; 

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://www.html5rocks.com/en/tutorials/webrtc/basics/
  2. https://www.webrtc-experiment.com/docs/WebRTC-PeerConnection.html
  3. https://developer.mozilla.org/en-US/docs/Web/Guide/API/WebRTC/WebRTC_basics

ссылка на оригинал статьи http://habrahabr.ru/post/255833/