WebRTC PeerConnection и DataChannel: обмен данными между браузерами

от автора

image

Многие слышали о проекте 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 оставляет разработчику. Обычно это сервер, через который клиенты узнают обмениваются информацией, после чего устанавливают прямое соединение. Эта схема хорошо показана на иллюстрации, которую я позаимствовал отсюда:

image

  • первый клиент через сервер отправляет 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 байт (разбивку я не делал).

Источники информации:

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *