Pull to refresh

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

Reading time 7 min
Views 66K
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
Tags:
Hubs:
+18
Comments 9
Comments Comments 9

Articles