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

в 19:51, , рубрики: html5, javascript, p2p, Peer-to-Peer, WebRTC, Веб-разработка, метки: , , ,

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

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

Автор: luethus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js