Знакомство с WebRTC

План


1) Введение

2) Что такое WebRTC и каким образом его можно реализовать?

3) Реализация WebRTC на примере react и nestjs

4) Выводы


Введение

Всем привет, меня зовут Александр, я являюсь фронтенд разработчиком более 4-х лет. В этой статье хочу поделится с вами историей о том, как я познакомился с WebRTC, зачем мне это нужно и что в итоге получилось.


С WebRTC я был знаком уже давно и хотел для себя разобраться, в качестве саморазвития, как работает эта технология. В конечном варианте у меня получилось реализовать peer to peer соединение и запустить обмен мета данными между браузерами. Далее я подробно и в деталях опишу, как у меня получилось этого добится.


Что такое WebRTC и каким образом его можно реализовать?

Давайте сначала поймем, что такое WebRTC — это стандарт, который описывает передачу потоковых аудио-, видеоданных и контента между браузерами в режиме реального времени. Передача данных осуществляется по следующему упрощенному алгоритму:


  • На стороне клиента необходимо захватить медиаданные (видео, аудио и т. п.);
  • после этого можно запускать функцию, которая будет запускать вызов собеседника;
  • в функции вызова собеседника необходимо отправить оффер и метаданные своему предполагаемому собеседнику;
  • на противоположной стороне собеседник должен принять оффер, после этого необходимо также захватить свои метаданные и отправить событие в ответ на оффер, оно называется answer;
  • после того как собеседники обменялись оферами и ответами, необходимо еще обменяться ice candidate, чтобы установить peer to peer (непрерывное соединение в реальном времени) соединение;
  • после обмена ice candidate данные можно помещать в тег video и воспроизводить.


Вот таким нехитрым образом можно организовать webRTC между двумя собеседниками.


Реализация WebRTC на примере react и nestjs


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

Давайте теперь начнем смотреть самое вкусное)


Начнем с бекенда. Для передачи данных между клиентами у меня выступал вебсокет socket.io, в этой библиотеке мне очень нравится разделение контекста, чем очень активно и пользовался.


Немного предыистории, у меня до этого был написан функционал на сокетах, где была логика проверки пользователя онлайн и передачи сообщения в режиме реального времени. Чтобы понимать, какие пользователи находились онлайн, я на стороне бекенда создал отдельный класс, который занимался этой задачей. Он представлял собой коллекцию типа Map изначально, где я мог брать, удалять, заносить данные. Эту коллекцию хотел использовать и для работы с соединением peer to peer. Но было решено расшить коллекцию и переделать ее под объект, в котором будет содержаться две коллекции.


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


Реализация объекта с двумя коллекциями


На картинке «Реализация объекта с двумя коллекциями» показана реализация сервиса с двумя коллекциями в объекте. Она нужна для того, чтобы понимать, что пользователь находится в онлайне или с ним можно соединится, чтобы провести беседу.

От хранилища перейдем к родительскому классу, в который вынес логику добавления пользователей в коллекцию, определения в онлайне собеседники или нет, добавление и выход из комнаты (еще одна фича библиотеки socket.io). Более подробно о работе этого класса я рассказывал в одной из прошлых статей по работе с сокетами. В этот раз я хочу показать, как расширился класс после того, как было добавлено разделение в коллекции.


Расширение родительского класса, чтобы можно было определить в какой модуль добавлять пользователя


На картинке «Расширение родительского класса, чтобы можно было определить в какой модуль добавлять пользователя» показано, что через сравнение namespace в сокете определяется для какого модуля пришел пользователь и далее после этого работа идет только конкретно с этим пользователем и в этой части класса рефакторинг был минимальным.


Инициализация класс для передачи данных для peer-to-peer соединения


На картинке «Инициализация класс для передачи данных для peer-to-peer соединения» сразу бросается во внимание название namespace-a, который ранее упоминали в этой статье. Также стоит обратить внимание на cors и то, что класс наследуется от родительского, который ранее в этой статье также упоминали.


Реализация offer и answer на стороне бекенда


В разделе статьи о то что такое WebRTC я объяснял для чего нужны offer и answer. На картинке «Реализация offer и answer на стороне бекенда» показано, что с ними происходит, когда данные по этим событиям обмениваются между собеседниками. Структура полученных данных в этих событиях одинакова, поэтому их работу можно вместе. На вход события получают объект, в котором есть свойство target — это свойство отвечает в какую комнату с собеседниками будет направлен ответ на событие. В моем случае комнаты выступают в качестве айдишников комнат из базы данных. В самом ответе ничего не меняется и данные просто пересылаются конечному пользователю.


Реализаци события ice-candidate на стороне бекенда.


В реализации события ice-candidate логика другая. На входе получаем в функцию обработки события получаем объект со свойствами target и candidate. Target, как и раньше, нужен для указания комнаты, в которую нужно отправить ответ на событие, а вот в ответе отправляется только candidate. Это нужно для того, чтобы добавить кандидата и инициализировать прямую связь между клиентскими браузерами.


Перейдем теперь к реализации на стороне клиента (браузера).

Верстка для отображения собеседников


На картинке «Верстка для отображения собеседников» показана простая и незамысловатая верстка. У меня не было цели красиво все оформить, более важен был результат получения опыта настройки WebRTC соединения, поэтому стили не стал применять для красивого оформления.


Сайдэффект, который запускает соединение WebRTC


Далее разберем сайдэффект, который запускает соединение WebRTC между браузерами и клик, по которому останавливается трансляция. В самом начале у нас происходит захват экрана через navigator. В функции getUserMedia необходимо указать, что будем захватывать и передавать в последствии. После этого в then происходит захват экрана с последующим передачей этих данных собеседнику. Стоит обратить внимание на то, что при запуске сайта браузер у пользователя будет спрашивать, что можно ли использовать видеокамеру и микрофон. Чтобы наш скрипт пошел выполнятся дальше пользователь должен ответить положительно и при этом сценарии как раз отработает ранее описанный then, в противном случае выпадет catch и дальше сценарий не пойдет, а упадет в ошибку. В моем примере этого нет, но в продакшине я бы ее добавил, чтобы сообщить пользователю о последствиях.


Теперь давайте обратим внимание на две функции: callUser и handleClick. Первая содержит в себе основную логику, которая занимается обменом данных для установления peer-to-peer соединения. Вторая обрабатывает закрытие модального окна, в котором расположены теги видео, в которых воспроизводится ранее полученная информация.


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

Пример реализации обработки события в одному хуке


На картинке «Пример реализации обработки события в одному хуке» можно увидеть каким образом я обрабатываю события через сокеты. Колбек handleEmitIceCandidate принимает в себя данные и отправляет их на сторону сервера. В этом случае меня не волнует, как эти данные собирались и обрабатывались — главное их передать на сервер, то есть, иными словами если какая-то логика есть для всех сценариев вызова этого события, то этот функционал можно оставить в этой функции, в противном случае его нужно выносить, чтобы колбек был не зависим от внешних данных. Теперь разберем сайд эффект, который обрабатывает прием событий от сервера и отписывает от них, если хук размантирован. Также сразу бросается в глаза, что хук принимает в параметрах колбек, он как раз в себе и содержит логику, которая будет обрабатывать данные в ответе на вызов сервера по событию.


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


По аналогии был сделан хук и для события answer. А вот реализация хука offer отличается.


Реализация события offer в двух хуках


По картинке «Реализация события offer в двух хуках» можно увидеть, каким образом был разделен этот хук, а именно в первом хуке написана логика для колбека, который отправляет данные на сервер, а во втором хуке описан функционал, который обрабатывает прием информации на стороне клиента от сервера. Далее в статье будет дано разъяснение по какой причине была необходимость разделения данного хука, если вышеописанная конструкция прекрасно себя показывала.


После того, как я описал реализацию хуков и принцип их работы, давайте вернемся к функции вызова собеседника.


Реализация функции callUser


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

Реализация функции createPeer


На картинке «Реализация функции createPeer» можно увидеть, что соединение создается через внутренний класс javascript RTCPeerConnection. В конце функции прописывается обработка эвентов через колбеки, в данном случае нас интересует handleNegotationNeededEvent. Поэтому по цепочке теперь переходим к рассмотрению этого колбека.


Реализация функции handleNegotationNeededEvent


На картинке «Реализация функции handleNegotationNeededEvent» показано, что в рамках этой функции идет создание оффера и его отправка собеседнику через сервер. В рамках колбека здесь выполняется функция handleOffer, которую ранее рассматривали. Здесь это сделано в рамках колбека, чтобы также функцию сделать максимально независимой от внешних зависимостей.


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


Реализация функции handleRecieveCall


На картинке «Реализация функции handleRecieveCall» показана функция, которая передавалась аргументом в ранее упомянутый хук, useHandleOfferOn. В ее рамках также создается соединение, принимаются данные от первого собеседника, захватываются медиа данные экрана и отсылается ответ через событие сокета answer. Принцип работы события answer был разобран выше, поэтому подробно на этому не буду останавливаться.


Давайте отвлечемся от колбека на обработку ответа события answer и обратим снова внимание на функцию createPeer. Из-за того, что эта функция была использована при вызове самого события offer и обработке при получении ответа на это событие на стороне клиента (браузера), был вынужден разбить хук обработки события offer на две части, чтобы избежать цикличной зависимости функции друг от друга. То есть, с помощью разделения хука на две части у меня получилось избавится от цикличной зависимости. Под цикличностью имеется ввиду, что вызываемая функция должна находится после ее описания.


Реализация функции handleAnswerOn


Возвращаемся теперь к событию answer и его ответу на событие, которое придет от сервера. На картинке «Реализация функции handleAnswerOn» четко видно, что через сервер пришел ответ от собеседника и он был занесен переменную, которая содержит данные о созданном соединении. После того, как собеседники обменялись оферами и ответами на него, то вызывается колбек, который содержит вызов события обмена ice-candidate.


Реализация функции handleICECandidateEvent


Для полного понимания картины нам снова придется вернутся в функцию createPeer. В ней было прописано еще два эвента, в самом начале я опустил описание этих функций для более простого понимания контекста событий. На картинке «Реализация функции handleICECandidateEvent» показано, что пользователь сначала дожидается обмена оферами и ответами и только после этого вызывается эмуляция событий по обмену ice candidate.


Реализация функции handleNewICECandidateMsg


На картинке «Реализация функции handleNewICECandidateMsg» показан ответ на ранее вызванное событие передачи ice candidate. Инициализируется кандидат с помощью встроенного класса RTCIceCandidate и этот кандидат добавляется в соединение. После этого идет инициализация соединения.


Реализация функции handleTrackEvent


На картинке «Реализация функции handleTrackEvent» показана функция, которая вызывается на событие ontrack, а оно, в свою очередь, вызывается после добавления кандидата в соединение. В ней мы видим, что в реферальную ссылку на второй тег видео передаются данные, которые в последствии будут показаны в нем.


Выводы

В данной статье мною был разобран пример реализации соединения peer-to-peer для реализации видеообщения двух участников диалога. Надеюсь, что вам понравилось эта статья и вы почерпнули для себя что-то новое. Спасибо за внимание и до встречи в следующих статьях.

© Все права защищены 2024