Многие слышали о проекте WebRTC, поэтому я не буду углубляться в описание. На днях мне захотелось попробовать отправлять сообщения между браузерами, и чтобы разобраться в этом, я решил написать примитивный P2P-чат. Эксперимент удалался, и по мотивам я решил написать этот пост. На Хабре уже были статьи, освещающие вопросы использования WebRTC для передачи видео, однако меня в первую (и последнюю) очередь интересовала возможность обмена текстовыми или бинарными данными.
Для общения между клиентами мы будем использовать RTCPeerConnection (для установления соединения) и RTCDataChannel (для передачи данных). В процессе нам также понадобятся RTCIceCandidate и RTCSessionDescription, но об этом позже.
Поддержка протокола DataChannel появилась в браузерах совсем недавно, поэтому для того, чтобы все это работало, нужен Firefox 19+ или Chrome 25+. Однако в Firefox < 22 WebRTC по умолчанию отключен (нужно установить параметр media.peerconnection.enabled в true), а Chrome 25 нужно запускать с флагом —enable-data-channels. Я не стал оглядываться на них, и этот пост ориентирован на Firefox 22+ и Chrome 26+. Opera 15 поддержки WebRTC не имеет.
Поехали
Так как все это находится в разработке и конструкторы в Firefox и Chrome имеют префиксы moz и webkit соотвественно, давайте наведем небольшой порядок:
window._RTCPeerConnection = window.webkitPeerConnection00 || window.webkitRTCPeerConnection || window.mozRTCPeerConnection || window.RTCPeerConnection || window.PeerConnection; window._RTCIceCandidate = window.webkitRTCIceCandidate || window.mozRTCIceCandidate || window.RTCIceCandidate; window._RTCSessionDescription = window.webkitRTCSessionDescription || window.mozRTCSessionDescription || window.RTCSessionDescription;
Реализации в Firefox и Chrome на сегодняшний день отличаются, поэтому нам потребуется определять браузер.
var browser = { mozilla: /firefox/i.test(navigator.userAgent), chrome: /chrom(e|ium)/i.test(navigator.userAgent) };
Для того, чтобы клиенты узнали друг о друге, предлагается использовать «сигнальный» механизм, реализиацию которого WebRTC оставляет разработчику. Обычно это сервер, через который клиенты узнают обмениваются информацией, после чего устанавливают прямое соединение. Эта схема хорошо показана на иллюстрации, которую я позаимствовал отсюда:
- первый клиент через сервер отправляет Offer второму клиенту;
- второй клиент через сервер отправляет Answer первому;
- теперь клиенты знают друг о друге и могут установить соединение.
В моем случае я использовал WebSocket для общения с сервером на node.js. Если это чат, то наш сервер помнит о каждом подключенном клиенте, умеет передавать данные от клиента клиенту и возвращать список подключенных клиентов (для новоприбывших пользователей).
Здесь я не буду приводить код сервера, т.к. это выходит за рамки статьи. Пусть интерфейс общения с нашим сервером на стороне клиента будет таким:
var observer = { // ... send: function(evt, data) { // используется для отправки данных другому участнику this._send(evt, data); }, on: function(evt, callback) { // устанавливает обработчик события ("ICE" или "SDP", речь о них пойдет ниже) // ... } // ... }
Создание подключения
Представим, что есть два пользователя – Алиса и Боб. Боб зашел в чат, когда там никого не было, а Алиса зашла через минуту и с от сигнального сервера узнала, что Боб онлайн и ждет ее. В данном случае Алиса будет будет отправлять запрос на подключение Бобу, а Боб – отвечать ей.
Для начала соединения Алиса создает объект RTCPeerConnection (как вы помните, чуть выше мы сделали кроссбраузерный _RTCPeerConnection). Конструктору нужно передать два аргумента с параметрами, о которых я расскажу ниже.
var pc, channel; var config = { iceServers: [{ url: !browser.mozilla ? "stun:stun.l.google.com:19302" : "stun:23.21.150.121" }] }; var constrains = { options: [{ DtlsSrtpKeyAgreement: true }, { RtpDataChannels: true }] }; function createPC(isOffer) { pc = new _RTCPeerConnection(config, constrains); // Сразу установим обработчики событий pc.onicecandidate = function(evt) { if(evt.candidate) { // Каждый ICE-кандидат мы будем отправлять другому участнику через сигнальный сервер observer.send('ICE', evt.candidate); } }; pc.onconnection = function() { // Пока это срабатывает только в Firefox console.log('Connection established'); }; pc.onclosedconnection = function() { // И это тоже. В Chrome о разрыве соединения придется узнавать другим способом console.log('Disconnected'); }; if(isOffer) { openOfferChannel(); createOffer(); } else { openAnswerChannel(); } }
// Алиса создает соединение createPC(true);
Так как в реальном мире многие пользователи находятся за провайдерским NAT, в WebRTC предусмотрены способы его обхода (ICE, STUN, TURN). Первым параметром config передается объект с массивом STUN и/или TURN серверов. Можно использовать публичные, можно поднять свои. Я использовал STUN-сервер от Google. Кстати, если я правильно понял, сегодня в Firefox имеются проблемы с использованием доменных STUN-серверов, поэтому в нем рекомендуют использовать другие.
Параметр constrains необязательный, в нем передаются настройки соединения. Про опцию DtlsSrtpKeyAgreement можно почитать здесь, а опция RtpDataChannels, судя по всему, нужна для Chrome 25 (и, быть может, еще каких-то версий). В 28 у меня работало и без нее.
Для установки соединения участникам необходимо обменяться ICE-кандидатами через сигнальный сервер (в них содержатся данные о сетевом интерфейсе, адрес и др.). При появлении каждого кандидата будет срабатывать событие pc.onicecandidate (оно начнет срабатывать после установки локальной сессии методом setLocalDescription, о чем речь пойдет ниже).
Готовимся принимать кандидаты другого участника:
observer.on('ICE', function(ice) { // добавляем пришедший ICE-кандидат pc.addIceCandidate(new _RTCIceCandidate(ice)); });
Дальше Алиса создает канал. Именно этот канал и будет использоваться для передачи данных:
function openOfferChannel() { // Первый параметр – имя канала, второй - настройки. В настоящий момент Chrome поддерживает только UDP-соединения (non-reliable), а Firefox – и UDP, и TCP (reliable) channel = pc.createDataChannel('RTCDataChannel', browser.chrome ? {reliable: false} : {}); // Согласно спецификации, после создания канала клиент должен установить binaryType в "blob", но пока это поддерживает только Firefox (Chrome выбрасывает ошибку) if(browser.mozilla) channel.binaryType = 'blob'; setChannelEvents(); } function setChannelEvents() { channel.onopen = function() { console.log('Channel opened'); }; channel.onclose = function() { console.log('Channel closed'); }; channel.onerror = function(err) { console.log('Channel error:', err); }; channel.onmessage = function(e) { console.log('Incoming message:', e.data); }; }
Следующим шагом Алиса создает и отправляет Бобу «Offer» (описание сессии с различной служебной информацией, SDP).
function createOffer() { pc.createOffer(function(offer) { pc.setLocalDescription(offer, function() { // Отправляем другому участнику через сигнальный сервер observer.send('SDP', offer); // После завершения этой функции начнет срабатывать событие pc.onicecandidate }, function(err) { console.log('Failed to setLocalDescription():', err); }); }, function(err) { console.log('Failed to createOffer():', err); }); }
Теперь Алиса ждет сессию Боба от сигнального сервера. Когда это случится, вызовется функция setRemoteSDP.
function setRemoteSDP(sdp) { pc.setRemoteDescription(new _RTCSessionDescription(sdp), function() { if(pc.remoteDescription.type == 'offer') { // Это выполнится у Боба createAnswer(); } }, function(err) { console.log('Failed to setRemoteDescription():', err); }); } observer.on('SDP', function(sdp) { if(!pc) { // Пришел Offer от другого участника // Боб создает соединение createPC(false); } setRemoteSDP(sdp); });
Тем временем Боб получает от сигнального сервера сессию Алисы, со своей стороны создает объект RTCPeerConnection и готовится принять канал (это вызывается из функции createPC).
function openAnswerChannel() { pc.ondatachannel = function(e) { channel = e.channel; if(browser.mozilla) channel.binaryType = 'blob'; setChannelEvents(); }; }
Наконец, Боб сохраняет сессию Алисы, создает свою и отправляет ее Алисе.
function createAnswer() { pc.createAnswer(function(offer) { pc.setLocalDescription(offer, function() { // Отправляем другому участнику через сигнальный сервер observer.send('SDP', offer); }, function(err) { console.log('Failed to setLocalDescription():', err); }); }, function(err) { console.log('Failed to createAnswer():', err); }); }
Обмен сообщениями
После успешного выполнения setRemoteDescription() у обоих участников и обмена ICE-кандидатами соединение между Алисой и Бобом должно установиться. В этом случае в Chrome и Firefox сработает событие channel.onopen, а в Firefox – еще и pc.onconnection.
Теперь Алиса и Боб могут обмениваться сообщениями с помощью метода channel.send():
channel.send("Hi there!");
При получении сообщения сработает событие channel.onmessage.
Определение дисконнекта
Когда другой участник завершает соедниение, в Firefox срабатывает сразу два события: pc.onclosedconnection и channel.onclose.
А вот в Chrome не срабатывает ничего, однако у объекта pc значение свойства iceConnectionState меняется на «disconnected» (по моим наблюдениям, меняется не сразу, а через несколько секунд). Поэтому придется сделать небольшой костыль, пока разработчики не исправили вызов события.
if(browser.chrome) { setInterval(function() { if(pc.iceConnectionState == "disconnected") { console.log("Disconnected"); } }, 1000); }
Текущие проблемы
- Хочу обратить внимание, что на сегодняшний день Chrome может отправлять данные длиной не более ~1100 байт. Поэтому, чтобы отправить что-то большее, придется делить сообщение и отправлять частями. Firefox уже умеет отправлять большие сообщения, у него таких проблем нет.
- Еще одним серьезным недостатком является то, что пока Chrome и Firefox несовместимы между собой (setRemoteDescription() с сессией другого браузера выбросит ошибку, соединение не установится).
- Теоретически, таким способом можно отправлять как текстовые, так и бинарные данные. В Firefox с этим проблем нет, а ситуация с Chrome непонятная: в интернете пишут, что бинарные данные не отправляются, и ждут, когда разработчики это исправят, однако мне в Chrome 28 удалось как успешно отправить, так и принять их. Может быть, я чего-то не понимаю.
Заключение
Технология кажется мне очень перспективной, и уже сейчас можно начинать экспериментировать с ее внедрением, хоть и с существенными ограничениями.
А вот и ссылка на простенький чат, процесс создания которого и вдохновил меня на эту статью. Я писал его исключительно для тренировки и изучения WebRTC, и в Chrome он не сможет отправлять более ~1100 байт (разбивку я не делал).
Источники информации:
- черновик спецификации (там же есть пара примеров)
- познавательная статья на HTML5 Rocks
- также помогли исходники этого проекта
ссылка на оригинал статьи http://habrahabr.ru/post/187270/
Добавить комментарий