Многие слышали о проекте 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
- также помогли исходники этого проекта
Автор: luethus