click fraud detection
click fraud detection
Blog Case

Как создать простое WebRTC-приложение

BLOG
CASE
553
0
5/ 5stars
5/5

Мы продолжаем наш цикл статей о WebRTC, в прошлый раз мы говорили об использовании технологии в веб-приложениях: для чего она нужна и как работает. Это вторая публикация, где речь пойдет о разработке WebRTC-приложения.

Основная задача любого WebRTC-приложения – это установка RTCPeerConnection. Для его создания, необходимо понимать внутреннюю работу браузера, как он создает peer-to-peer соединение.

Для хорошего качества, требуется соединение, обеспечивающее передачу видео-звуковых фреймов со скоростью 40-60 в секунду.  При такой скорости потеря нескольких фреймов не особо критична. Это значит, что получить свежие фреймы намного важнее, чем обеспечить доставку абсолютно всех и в нужное время. Наш мозг заполнит недостающие провалы (будем надеятсья). Это основная причина – почему технология WebRTC, в основном, использует протокол UDP вместо TCP. UDP (User Datagram Protocol) это тот протокол, который не гарантирует доставку каждого пакета.

WebRTC API

Рассмотрим функции и объекты WebRTC API, обеспечивающие создание и работу peer-соединения:

  • RTCPeerConnection – объект коннекта;
  • сигнальные функции взаимодействия между браузерами;
  • объект, описывающий сессию – Session Description Protocol (SDP);
  • ICE-кандидат – объект, описывающий возможности соединения конкретного браузера, их формируется много и выбирается наиболее подходящий.

Объект RTCPeerConnection

Это основная точка входа в WebRTC API, которая позволяет установить и инициализировать процесс соединения. Большинство действий мы будем совершать именно с ним. После его создания, к нему подсоединяется информацию о медиа-потоках. Главная задача этого объекта – начальная настройка соединения, поддержка сессии и контроль её состояние в браузере. Все эти функции инкапсулированы внутри объекта. Все они имеют событийную природу, по мере изменения состояния соединения, срабатывают соответствующие функции обратного вызова. Эти события дадут возможность изменять конфигурацию соединения и реагировать на события системы.

Объект RTCPeerConnection – это простой объект JS, который может быть создан оператором new.

var myConnection = new RTCPeerConnection(configuration);
         myConnection.onaddstream = function (stream) {
         // Использование потока stream
    };

В качестве аргумента он принимает конфигурационный объект. После создания мы вешаем колбек на событие, при котором удаленный пользователь подсоединяет медиа-поток к объекту RTCPeerConnection.

Сигнальные функции взаимодействия между браузерами

Информация о местоположении браузера в сети – это первое, что нужно знать для соединения с ним. Эта информация включает IP-адрес и порт компьютера или мобильного устройства, на котором запущен браузер. После того, как эта информация получена, необходимо выяснить, каким образом браузер может передавать потоки. Это предполагает наличие процесса диалога между браузерами до того как соединение будет установлено. В процессе такого общения и передается информация о местоположении, аудио-видео кодеках, протоколах, доступных медиа-устройствах и т.д.

Все эти функции по коммуникации берет на себя сигнальный сервер, а сам процесс коммуникации состоит из следующих шагов:

 Создание локального медиа потока.

  1. Генерация списка ICE-кандидатов для соединения.
  2. Выбор необходимого пользователя для контакта;
  3. Отсылка сигнала-приглашения удаленному пользователю о том, что кто-то хочет с ним соединиться;
  4. Пользователь соглашается или отвергает приглашение;
  5. Если приглашение принято, то первый пользователь инициализирует RTCPeerConnection-соединение с удаленным пользователем и генерирует свои ICE кандидаты;
  6. Оба пользователя начинают обмен ICE-кандидатами с информацией о конфигурации их устройств, программного окружения и местоположения в сети;
  7. Установка либо провал соединения.

Однако, спецификация WebRTC не содержит никаких стандартов относительно того, каким образом эта информация будет передаваться между пользователями и на каких технологиях должен быть построен сигнальный сервер. На сегодняшний день существует множество решений на Java, Python, NodeJS, PHP, позволяющих создать сигнальный сервер на ряду с такими технологиями из телефонии как XMPP и SIP.

Session Description Protocol (SDP)

Этот объект представляет собой подобие визитной карточки компьютера, в которой содержится вся информация о его конфигурации, поддерживаемом транспортном протоколе, ICE кандидатах и прочих возможностях.

SDP – это текстовые данные, предоставляемые браузером в формате ключ-значение и разделенные переносом строк,.

Если вывести в консоль эту строку, то она может выглядеть так:

v=0
    o=- 1167826560034916900 2 IN IP4 127.0.0.1
    s=-
    t=0 0
    a=group:BUNDLE audio video
    a=msid-semantic: WMS K44HTOZVjyAyAlvUVD3pOLu8i0LdytHiWRp1
    m=audio 1 RTP/SAVPF 111 103 104 0 8 106 105 13 126
    c=IN IP4 0.0.0.0
    a=rtcp:1 IN IP4 0.0.0.0
    a=ice-ufrag:Vl5FBUBecw/U3EzQ
    a=ice-pwd:OtsNG6FzUH8uhNEhOg9/hprb
    a=ice-options:google-ice
    a=fingerprint:sha-256
    FB:56:7D:B6:E0:C7:E7:39:FE:47:5A:12:6C:B4:4E:0E:2D:18:CE:AE:33:92:
    A9:60:3F:14:E4:D9:AA:0D:BE:0D
    a=setup:actpass
    …

Как видим, это не простой объект. Он содержит много разной информации. Абсолютно не обязательно понимать суть каждого параметра т.к. вам не нужно будет работать с этим объектом напрямую.

Поиск оптимального маршрута между пользователями

Существует 2 способа установки соединения STUN и TURN. Схематически их можно отобразить следующим образом:

Первым шагом мы должны определить местонахождение удаленного компьютера в сети, узнав его реальный  IP-адрес. Проблему создает тот факт, что между вашим компьютером и интернетом может быть несколько IP-адресов, включая роутеры и фаерволы. Сначала используется STUN-сервер, который возвращает наш внутренний IP-адрес за роутером внутри приватной подсети. На основании этой информации и устанавливается соединение.

В данном случае это соединение будет использовать систему NAT и в качестве STUN-сервера можно использовать как свой собственный, так и поставляемые производителями браузера публичные сервера.

NAT (Network Address Translation) – это механизм автоматического преобразования роутером адресов источника в отсылаемых пакетах, и адресов приемника в принимаемых на реальный внутренний адрес сети.

В некоторых случаях, политика в подсетях запрещает работу по STUN-протоколу. В этом случае, происходит попытка использовать TURN-сервер как коммутатор, в котором эмулируется удаленное peer соединение для обоих клиентов. Это называется – в обход NAT. При этом клиент получает данные от TURN сервера – как будто это удаленный браузер. Из за того, что использование TURN включает в себя дополнительные расходы, данный способ считается менее привлекательным и используется «на крайний случай». Однако, это вопрос спорный и использование TURN-шлюза зачастую может быть оправдано.

Интерактивная установка соединения

Теперь, когда мы разобрались со STUN и TURN, нужно понять как их совместить. Для этого существует стандарт, называемый ICE. Это процесс использования STUN или TURN для установки соединения, которое происходит благодаря существованию диапазона возможных адресов (кандидатов) для соединения и поиска среди них оптимального для двух клиентов. Этот диапазон строится на предположениях браузера относительно возможной конфигурации удаленного хоста, и задействует много разных технологий. Каждый ICE-кандидат строится на основании запросов к STUN/TURN-серверам и циклическом переборе и поиске наилучшего из них.

Построение базового WebRTC-приложения

Попробуем создать соединение между двумя пользователя внутри одной страницы. Приложение будет выполнять последовательность действий:

  • создание 2-х RTCPeerConnection объектов;
  • создание SDP offer и передача его между ними;
  • поиск ICE кандидата для соединения;
  • установка WebRTC-соединения.

Определим шаблон html страницы с двумя элементами video и кнопками для создания видеопотока, вызова удаленного абонента и завершения соединения.

Теперь определим переменные этих элементов, их начальное состояние, колбэки и блок функций для дебага.

'use strict';

    const startButton = document.getElementById('startButton');
    const callButton = document.getElementById('callButton');
    const hangupButton = document.getElementById('stopButton');
    callButton.disabled = true;
    hangupButton.disabled = true;
    startButton.addEventListener('click', start);
    callButton.addEventListener('click', call);
    hangupButton.addEventListener('click', stop);
    let startTime;
    const localVideo = document.getElementById('localVideo');
    const remoteVideo = document.getElementById('remoteVideo');

    async function start() {
        console.log('Start')
    };

    async function call() {
        console.log('Calling')
    };

    async function stop() {
        console.log('Stop')
    };


    /// Дебаг функции 

    localVideo.addEventListener('loadedmetadata', function() {
      console.log(`Local video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
    });

    remoteVideo.addEventListener('loadedmetadata', function() {
      console.log(`Remote video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
    });

Теперь определим переменные для локального медиапотока, объектов RTCPeerConnection, и параметров offer запроса.

let localStream;
    let pc1;
    let pc2;
    const offerOptions = {
      offerToReceiveAudio: 1,
      offerToReceiveVideo: 1
    };

    function getName(pc) {
      return (pc === pc1) ? 'pc1' : 'pc2';
    }

    function getOtherPc(pc) {
      return (pc === pc1) ? pc2 : pc1;
    }

Последние две функции вспомогательные, по переданному соединению они возвращают противоположное соединение и его название.

Наполним функцию start, забрав видеопоток с локальной камеры и добавив его к элементу localVideo.

async function start() {
        console.log('Start')
        console.log('Requesting local stream');
        startButton.disabled = true;
      try {
          const stream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
          console.log('Received local stream');
          localVideo.srcObject = stream;
          localStream = stream;
          callButton.disabled = false;
      } catch (e) {
          alert(`getUserMedia() error: ${e.name}`);
      }
    };


После нажатия Start мы должны увидеть себя в маленьком контейнере video.

Опишем функцию call и то, что она использует:

let localStream;
    let pc1;
    let pc2;
    const offerOptions = {
      offerToReceiveAudio: 1,
      offerToReceiveVideo: 1
    };

    async function call() {
      // Установим состояние кнопок
      callButton.disabled = true;
      hangupButton.disabled = false;
      console.log('Starting call');
      // Получаем и выводим информацию о медиа-потоках
      const videoTracks = localStream.getVideoTracks();
      const audioTracks = localStream.getAudioTracks();
      if (videoTracks.length > 0) {
        console.log(`Using video device: ${videoTracks[0].label}`);
      }
      if (audioTracks.length > 0) {
        console.log(`Using audio device: ${audioTracks[0].label}`);
      }
      
      // Создаем объекты RTCPeerConnection c пустой конфигурацией
      const configuration = {};
      console.log('RTCPeerConnection configuration:', configuration);
      pc1 = new RTCPeerConnection(configuration);
      console.log('Created local peer connection object pc1');
      pc2 = new RTCPeerConnection(configuration);
      console.log('Created remote peer connection object pc2');
      
      // Добавляем обработчики на событие добавления ICE кандидата
      pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));
      pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
      
      // Обработчик добавления потока на второе соединение
      pc2.addEventListener('track', gotRemoteStream);

      // Достаем потоки из текущего stream объекта и передаем их в объект RTCPeerConnection
      localStream.getTracks().forEach(track => pc1.addTrack(track, localStream));
      console.log(localStream.getTracks());

      // Формируем offer из pc1
      try {
        console.log('pc1 createOffer start');
        const offer = await pc1.createOffer(offerOptions);
        await onCreateOfferSuccess(offer);
      } catch (e) {
         console.log(`${e}`);
      }
    }

Чтобы понять, что происходит в этой части, разберем некоторые ключевые функции объекта RTCPeerConnection.

addTrack(track,stream) добавляет новые медиадорожки в коллекцию stream, которые должны быть переданы клиенту на удаленный peer. Дело в том, что по сети передаются не потоки stream, а медиадорожки. Вы вправе добавить несколько дорожек из разных stream-объектов (в случае если у вас много камер и микрофонов). Функция addTrack служит для их группировки и последующей синхронизации. В нашем случае мы оперируем одним объектом stream и и группируем в нем его же дорожки. Получить текущие дорожки можно из объекта stream stream.getTracks(). Этот метод вызывает событие negotiationneeded, но мы это не используем.

createOffer([options]) формирует объект SDP offer для инициализации нового WebRTC-соединения. Этот объект содержит:

  • информацию о всех медиадорожках, подключенных в сессию WebRTC;
  • кодеки;
  • опции браузера;
  • ICE кандидаты.

Вся эта информация предназначена для передачи через сигнальный сервер на удаленный хост для конфигурации или обновления удаленного соединения. В параметрах options мы определяем какие типы медиапотока нам нужно получить параметрами offerToReceiveAudio и offerToReceiveVideo.

createAnswer([options]) похож на предыдущий метод createOffer, но вызывается в ответ на пришедший SDP offer от удаленного хоста.

setLocalDescription(SDP) и setRemoteDescription(SDP) – эти два метода устанавливают или обновляют информацию о локальной или удаленной конфигурации медиапотоков. Обычно эти методы вызываются сразу после createOffer() и передачи SDP offer-а через сигнальный сервер c целью обновить информацию.

В нашем примере мы пока не используем сигнальный сервер.

addIceCandidate(). Когда наше приложение получает ICE кандидата из удаленного хоста его необходимо передать ICE агенту внутри объекта RTCPeerConnection. За это отвечает функция addIceCandidate(). Если приходит пустая строка, это означает, что все кандидаты были доставлены. Обычно таких кандидатов много и каждый из них описывает свой собственный потенциальный способ соединения.

В нашем примере, после создания объектов:

      pc1 = new RTCPeerConnection(configuration);
      pc2 = new RTCPeerConnection(configuration);

Мы подвязываем функцию onIceCandidate к событию icecandidate. Это событие будет инициировано после вызова метода setLocalDescription или setRemoteDescription.

      pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));
      pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));

В функции onIceCandidate мы добавляем переданный ICE кандидат в противоположное соединение, которое получаем ф-цией getOtherPc() см. выше .

// Добавление ICE кандидата.
    
    async function onIceCandidate(pc, event) {
      try {
        await (getOtherPc(pc).addIceCandidate(event.candidate));
        onAddIceCandidateSuccess(pc);
      } catch (e) {
        onAddIceCandidateError(pc, e);
      }
      console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
    }

Далее, вызываем ф-цию onCreateOfferSuccess() первого соединения.

// Функция формирования offer
    
    async function onCreateOfferSuccess(desc) {
      console.log(`Offer from pc1\n${desc.sdp}`);
      console.log('pc1 setLocalDescription start');
      try {
        await pc1.setLocalDescription(desc);
        onSetLocalSuccess(pc1);
      } catch (e) {
        console.log(`error setting description to pc1 ${error.toString()}`);
      }

      console.log('pc2 setRemoteDescription start');
      try {
        await pc2.setRemoteDescription(desc);
        onSetRemoteSuccess(pc2);
      } catch (e) {
        console.log(`error setting description to pc2 ${error.toString()}`);
      }

      console.log('pc2 createAnswer start');
     
      /*
        Так как у нас один видео-поток для двух соединений,
        мы формируем объект SDP offer прямо из второго соединения
      */
      
      try {
        const answer = await pc2.createAnswer();
        await onCreateAnswerSuccess(answer);
      } catch (e) {
        onCreateSessionDescriptionError(e);
      }
    }

В этой функции мы совершаем 3 действия:

  1. Устанавливаем setLocalDescription из поступившего SDP offer для pc1.
  2. Устанавливаем setRemoteDescription из поступившего SDP offer для pc2.
  3. Формируем SDP offer из соединения pc2.

Третий пункт вызывается в последнюю очередь и устанавливает SDP offer функциями pc2.setLocalDescription и pc1.setRemoteDescription SDP уже в обратном направлении.

Таким образом, у нас получается заполненными описания локального и удаленного SDP для обоих соединений.

// Функция формирования ответного SDP offer 
    
    async function onCreateAnswerSuccess(desc) {
      console.log(`Answer from pc2:\n${desc.sdp}`);
      console.log('pc2 setLocalDescription start');
      try {
        await pc2.setLocalDescription(desc);
        onSetLocalSuccess(pc2);
      } catch (e) {
        onSetSessionDescriptionError(e);
      }
      console.log('pc1 setRemoteDescription start');
      try {
        await pc1.setRemoteDescription(desc);
        onSetRemoteSuccess(pc1);
      } catch (e) {
        onSetSessionDescriptionError(e);
      }
    }

В конце привожу код сервисных функций, используемых для отладки.

/// Дебаг функции 
    
    function onSetLocalSuccess(pc) {
      console.log(`${getName(pc)} setLocalDescription complete`);
    }    
        
    function onSetRemoteSuccess(pc) {
      console.log(`${getName(pc)} setRemoteDescription complete`);
    }        
        
    function onCreateSessionDescriptionError(error) {
      console.log(`Failed to create session description: ${error.toString()}`);
    }    
    
    function onAddIceCandidateSuccess(pc) {
      console.log(`${getName(pc)} addIceCandidate success`);
    }
        
    function onAddIceCandidateError(pc, error) {
      console.log(`${getName(pc)} failed to add ICE Candidate: ${error.toString()}`);
    }

    localVideo.addEventListener('loadedmetadata', function() {
      console.log(`Local video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
    });

    remoteVideo.addEventListener('loadedmetadata', function() {
      console.log(`Remote video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
    });

Результат работы приложения:

На этом все. В следующей статье мы поговорим о том, как написать сигнальный сервис Tornado. Чтобы первыми узнать о новой публикации и получать больше полезного контента, подпишитесь на наш блог.

Если вас заинтересовали возможности, которые предоставляет WebRTC-приложение – оставьте заявку на сайте. Специалисты Wezom создадут действительно эффективное решение для вашего бизнеса.

 

5/5
Проголосовало людей: 3
СОДЕРЖАНИЕ
СТАТЬИ
WebRTC API
Объект RTCPeerConnection
Сигнальные функции взаимодействия между браузерами
Session Description Protocol (SDP)
Поиск оптимального маршрута между пользователями
Интерактивная установка соединения
Построение базового WebRTC-приложения
Создание карточной игры Blackjack
Дмитрий Жариков
Дмитрий Жариков
Зачем бизнесу имиджевый сайт
Термин «веб-представительство» как нельзя точнее передает суть имиджевого сайта. Это ваш онлайн-ресепшн, который формирует положительное…
Алексей Варламов
Алексей Варламов
IM на основе NodeJS Express и Angular 7
Дмитрий Жариков
Дмитрий Жариков
ПОЛУЧАТЬ ИНТЕРЕСНЫЕ СТАТЬИ
Уже подписались 242 человек
Автор
553
0
Дмитрий Жариков
Дмитрий
Жариков
Возможно
В мае 2016 года Яндекс.Вебмастер обновил функционал приоритетного обхода. Что появилось нового и зачем это…
Наталья Семенютенко
Наталья Семенютенко
UX это термин, который охватывает взаимодействие пользователя с интерфейсом веб-ресурса. Сайт радует или действует на…
Галина Назарова
Галина Назарова
Как продвигать сайт самостоятельно этот вопрос интересует многих. Мы описали все способы и методы продвижения…
Александр Осиевский
Александр Осиевский
Давайте начнем
беседу!
КОММЕНТАРИИ0
ОСТАВИТЬ КОММЕНТАРИЙ К СТАТЬЕ
ПОДПИСЫВАЙТЕСЬ НА РАССЫЛКУ АЙТЫЖБЛОГ
ХОТИТЕ ПОЛУЧАТЬ 
ИНТЕРЕСНЫЕ СТАТЬИ?
Уже подписались 242 человек
313
ПОПИСЧИКОВ
ЧИТАТЬ
4295
ПОПИСЧИКОВ
СЛЕДИТЬ
9307
ПОПИСЧИКОВ
СЛЕДИТЬ