Pull to refresh

На пальцах про WebRTC на примере своего мессенджера

Level of difficultyEasy
Reading time9 min
Views1.8K

Сегодня разберёмся, как сделать видеозвонки — ту самую фичу, без которой сложно представить современное общение в 2025 году, на примере реализации мессенджера.

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

В этой части мы:

  • разберёмся, как работает WebRTC под капотом;

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

  • научим клиентов подключаться друг к другу и передавать медиапотоки;

  • настроим поддержку STUN и TURN — чтобы звонки работали даже за NAT или в мобильных сетях;

Готовы? Поехали 🚀

Архитектура видео-звонков: как работает WebRTC

Прежде чем писать код, давайте разберёмся, что происходит "под капотом", когда вы запускаете видеозвонок.

WebRTC (Web Real-Time Communication) — это технология, которая позволяет двум клиентам (чаще всего браузерам или мобильным приложениям) обмениваться аудио, видео, а также текстовыми сообщениями. Такое соединение называется P2P (peer-to-peer) — в идеале медиа-трафик передаётся напрямую между клиентами, что снижает задержку и нагрузку на сервер. А если прямое соединение невозможно, в дело вступает TURN-сервер, который ретранслирует трафик между участниками.

В отличие от WebSocket, который работает поверх TCP и используется, например, для чатов, WebRTC использует в основном UDP. Это позволяет добиться минимальной задержки при передаче аудио и видео, что критично для звонков и стриминга.

WebRTC — это не только про видео и аудио. Он также поддерживает передачу любых данных между клиентами через RTCDataChannel. С его помощью можно, например, отправлять текстовые сообщения напрямую, реализовать файлообмен или синхронизацию действий между пользователями без участия сервера.

Вот из чего состоит типичный WebRTC-звонок:

1. Обмен техническими параметрами (SDP)

Когда один клиент хочет позвонить другому, он формирует специальное описание соединения — SDP (Session Description Protocol). Это обычный текст, в котором описано:

  • какие медиа (аудио/видео) будут передаваться,

  • какие кодеки поддерживаются,

  • какие порты слушать и т.д.

Этот SDP называется offer. Второй клиент отвечает своим SDP (answer), где говорит: «Окей, я тоже умею вот это». Но WebRTC не умеет сам доставлять SDP от одного клиента к другому — для этого нужен сигнальный сервер.

2. Поиск пути (ICE, STUN и TURN)

Теперь, когда стороны договорились «что» передавать, им нужно решить «как» соединиться. Ведь за NAT'ами у клиентов могут быть скрытые IP и порты.

Тут включается процесс ICE (Interactive Connectivity Establishment):

  • каждый клиент собирает список возможных адресов (называются ICE-кандидаты),

  • обменивается ими через сигнальный сервер,

  • WebRTC пробует разные пути, пока не найдёт рабочий.

Чтобы найти свой внешний IP и порт, клиент использует STUN-сервер. Если напрямую соединиться не удалось — используется TURN-сервер, который ретранслирует весь трафик.

Подробнее про STUN и TURN мы поговорим чуть ниже.

3. Прямое соединение и передача медиа

Когда соединение установлено, начинается передача аудио и видео — напрямую между клиентами или через TURN. Браузеры (или, в нашем случае, React Native) обрабатывают всё это автоматически: нам нужно лишь правильно сконфигурировать RTCPeerConnection и передать нужные данные через сигналинг.

Что такое SDP и ICE — простыми словами

Когда два клиента хотят начать звонок, им нужно договориться как это сделать: какие кодеки использовать, какие порты слушать, как передавать видео и т.д. Для этого используется SDP (Session Description Protocol) — текстовый формат описания соединения.

SDP передаётся между клиентами через сигнальный сервер. Один клиент создаёт "offer", второй отвечает "answer". Внутри — всё: от типа медиапотока до информации о сети.

Пример SDP offer может выглядеть так (сильно упрощённо):

v=0
o=- 123456 2 IN IP4 192.0.2.1
s=-
t=0 0
m=audio 49170 RTP/AVP 0
c=IN IP4 192.0.2.1
a=rtpmap:0 PCMU/8000

ICE (Interactive Connectivity Establishment) — это процесс поиска пути для соединения между клиентами. Так как оба могут находиться за NAT, им нужно "вычислить", как достучаться друг до друга: напрямую, через промежуточные адреса или через TURN-сервер.

Пример ICE-кандидата:

{
  "candidate": "candidate:842163049 1 udp 1677729535 192.0.2.3 54874 typ srflx raddr 10.0.0.2 rport 54874",
  "sdpMid": "0",
  "sdpMLineIndex": 0
}
  • candidate: описание маршрута (IP, порт, тип — host, srflx, relay);

  • sdpMid: ID медиалинии (аудио/видео);

  • sdpMLineIndex: индекс медиалинии (0 — обычно аудио, 1 — видео и т.п.).

Каждый клиент собирает список возможных адресов (ICE-кандидатов) и отправляет их другому. Затем WebRTC пробует соединиться по ним в порядке приоритета — и выбирает лучший.

Компоненты, которые нам нужны

Чтобы всё заработало, нам понадобятся:

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

    • описанием соединения (SDP),

    • и сетевыми адресами (ICE-кандидатами).

  2. Обычно используют WebSocket или любую другую двустороннюю связь.

  3. STUN-сервер — помогает клиентам определить свои внешние IP-адреса и порты за NAT. Это бесплатно и работает в большинстве случаев.

  4. TURN-сервер — если прямое соединение не получается (жёсткий NAT, корпоративные сети), трафик идёт через промежуточный сервер. Это дороже, но надёжнее.

  5. Клиенты — инициализируют соединение, получают доступ к камере/микрофону, обмениваются данными и отображают видео.

Поток действий

  1. Клиент A инициирует звонок → отправляет SDP на сервер.

  2. Сервер пересылает SDP Клиенту B.

  3. Клиент B отвечает своим SDP.

  4. Оба клиента обмениваются ICE-кандидатами — это информация о возможных путях соединения.

  5. WebRTC выбирает лучший путь (напрямую, через STUN или через TURN).

  6. Поток видео/аудио начинает передаваться напрямую (если повезёт) или через TURN.

Screenshot 2025-04-10 at 14.38.38.png
Схема WebRTC звонка

STUN и TURN: помощники в мире NAT'ов

В идеальном мире два клиента соединяются напрямую. Но в реальности оба часто находятся за NAT'ами — роутерами, которые скрывают их настоящие IP-адреса. И тут на помощь приходят STUN и TURN.

STUN (Session Traversal Utilities for NAT) — это лёгкий сервер, к которому клиент обращается, чтобы узнать свой публичный IP и порт. Получив эту информацию, клиент может передать её другому участнику звонка как один из ICE-кандидатов. STUN работает быстро и бесплатно, и этого часто достаточно, если хотя бы один клиент не за жёстким NAT.

TURN (Traversal Using Relays around NAT) — резервный план. Если напрямую соединиться не получается (например, оба клиента сидят за симметричными NAT'ами или в корпоративных сетях), TURN-сервер становится посредником: он принимает трафик от одного клиента и пересылает другому. Это медленнее и дороже (требуется мощный сервер с хорошим каналом), но обеспечивает соединение в 100% случаев.

📌 Поэтому хорошая конфигурация WebRTC всегда включает оба сервера:

Screenshot 2025-04-10 at 14.39.40.png
Связь через TURN сервер

Переходим к реализации

Мы уже разобрались, как устроен WebRTC: что такое SDP, ICE, STUN и TURN, зачем нужен сигнальный сервер и как строится соединение. Теперь давайте перейдём к самому интересному — реализации видеозвонков в нашем приложении на React Native. Мы будем использовать библиотеку react-native-webrtc — это популярная обёртка над WebRTC API для React Native. Она позволяет управлять медиапотоками, создавать соединения, слушать события ICE-кандидатов и отображать видео прямо в приложении.

В качестве сигнальной системы мы будет использовать graphql бекенд реализованный в прошлой части. Добавим в него следующее возможности пересылки сигналов между клиентами:

type CallSignal {
 type: CallSignalType!  # Тип сигнала: OFFER, ANSWER, ICE_CANDIDATE и т.п.
 payload: String!       # Содержимое сигнала — например, SDP или ICE-кандидат (в виде строки)
 chatID: String!        # ID чата (используется как "комната" для звонка)
}
      
input CallSignalInput {
 type: CallSignalType!  # Тип сигнала
 payload: String!       # Сам сигнал (JSON-строка)
 chatID: String!        # К какому чату/разговору относится
}

type Mutation {
 sendCallSignal(input: CallSignalInput!): Boolean!
 endCall(chatID: String!): Boolean!
}

type Subscription {
 callSignalReceived(chatID: String!): CallSignal!
 callEnded(chatID: String!): Boolean!
}

Все необходимые инструменты для работы с WebRTC в react-native содержатся в пакете react-native-webrtc.

Для инициализации WebRTC соединения нужно создать объект типа RTCPeerConnection и передать ему наши STUN и TURN сервера в качестве параметров.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: 'turn:your.turn.server:3478',
      username: 'user',
      credential: 'password'
    }
  ]
});

STUN пробует первым. Если не сработало — подключается TURN. Всё это происходит прозрачно для разработчика, но критично для стабильности видеозвонков.

После создания объекта RTCPeerConnection, мы можем начинать установку соединения между двумя клиентами. Этот процесс включает несколько шагов:

Где взять STUN и как настроить свой TURN

Бесплатные STUN-серверы

Для разработки и прототипов можно смело использовать публичные STUN-серверы:

  • stun:stun.l.google.com:19302

  • stun:stun1.l.google.com:19302

  • stun:stun.stunprotocol.org:3478

Они работают стабильно, и почти всегда позволяют установить прямое соединение.

TURN-сервер

Если оба клиента находятся за жёсткими NAT'ами, или в корпоративной сети, или мобильной сети с CGNAT — P2P просто не получится. И вот тут на сцену выходит TURN-сервер: он ретранслирует весь трафик между клиентами.

Чтобы обеспечить 100% успешные соединения, особенно в продакшене, нужен свой TURN-сервер. Один из самых популярных и бесплатных — coturn.

Или можно воспользоваться одним из облачных TURN-сервисов, цены зависят от трафика.

1. Добавляем медиапотоки (камера и микрофон)

Сначала получим доступ к камере и микрофону пользователя:

// Импортируем API для работы с медиа-устройствами
import {
    mediaDevices
} from 'react-native-webrtc';

// Запрашиваем доступ к аудио и видео (фронтальная камера, 30 fps)
const stream = await mediaDevices.getUserMedia({
	audio: true,
	video: {
	    frameRate: 30,
	    facingMode: 'user',
	},
});

// Добавляем все треки (аудио и видео) в соединение
stream.getTracks().forEach(track => {
    pc.addTrack(track, stream);
});

2. Создание SDP offer (для инициатора звонка)

Если вы инициируете звонок:

// Создаём offer — описание параметров соединения
const offer = await pc.createOffer({
	mandatory: {
		OfferToReceiveAudio: true,
		OfferToReceiveVideo: true,
		VoiceActivityDetection: true
	}
});

await pc.setLocalDescription(offer);

// Отправляем OFFER SDP через сигналинг собеседнику
sendCallSignal('OFFER', JSON.stringify(offer));

3. Получение offer и создание answer (для принимающей стороны)

Когда второй клиент получает offer через сигнальный канал:

await pc.setRemoteDescription(
	new RTCSessionDescription(offer),
);

// Создаём answer — ответ с нашими параметрами
const answer = await pc.createAnswer();

await pc.setLocalDescription(answer);

// Отправляем ANSWER SDP собеседнику
sendCallSignal('ANSWER', JSON.stringify(answer)); 

4. ICE-кандидаты (сетевые адреса)

WebRTC сам начинает собирать ICE-кандидаты. Каждый нужно отправлять другой стороне через сигналинг:

// Обработчик события: найден новый ICE-кандидат
pc.addEventListener('icecandidate', event => {
    if (event.candidate) {
        // Отправляем ICE кандидата собеседнику через сигналинг
        sendCallSignal('ICE_CANDIDATE', JSON.stringify(event.candidate));
    }
});

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

И при получении:

// При получении ICE-кандидата от собеседника:
const iceCandidate = new RTCIceCandidate(candidate);
await pc.addIceCandidate(iceCandidate)

💡 Совет: добавьте iceConnectionStateChange обработчик, чтобы отслеживать состояние соединения и показывать пользователю статус (подключается / отключено / подключено).

5. Отображение видео удалённого пользователя

Когда другой клиент начнёт передавать медиапоток:

// Обработчик события: получен удалённый медиапоток
pc.addEventListener('track', event => {
    // Сохраняем поток для отображения на экране
    if (event.streams && event.streams[0]) {
        setRemoteStream(event.streams[0]);
	}
});

После этих шагов два клиента будут видеть и слышать друг друга, если ICE-соединение удалось — напрямую (P2P) или через TURN.

Входящие звонки и push-уведомления

Чтобы пользователь мог получить звонок, даже если приложение закрыто — нужен push-механизм.

Идеальный вариант — push уведомление с интерактивным UI, как у Telegram или WhatsApp:

«Вам звонит Иван Иванов. Принять / Отклонить».

Групповые звонки: больше двух участников (SFU)

WebRTC из коробки отлично работает в модели "один на один" (P2P), но как только вы хотите созвониться втроём, начинаются проблемы. Каждый участник должен установить отдельное соединение с каждым другим: это mesh-схема.

Такой подход быстро упирается в потолок:

  • при 4 участниках каждый стримит 3 видео потока = ×4 нагрузки;

  • при 6 — уже 5 потоков на каждого;

  • и всё это по uplink’у телефона 😅

Решение — использовать SFU

SFU (Selective Forwarding Unit) — это сервер, который принимает потоки от участников и раздаёт их другим. Клиенты отправляют один исходящий поток (а не 5), а SFU уже «перекладывает» их между участниками.

Популярные SFU-решения:

  • mediasoup — мощный SFU на Node.js + C++;

  • Janus — лёгкий и гибкий;

  • Jitsi Videobridge — используется в Jitsi Meet;

  • LiveKit — современный open-source стек с mobile SDK.

Подключение SFU немного меняет логику:

  • звонок больше не P2P, а через сервер;

  • вам нужно управлять подписками на потоки (кто кого видит);

  • сигналинг становится сложнее — нужно координировать участников, управление layout'ом, mute/unmute и т.д.

Но в обмен вы получаете масштабируемость и контроль, что делает SFU незаменимым для групповых звонков.

Screenshot 2025-04-10 at 14.37.27.png
Групповые звонки

Безопасность в WebRTC-звонках

Когда речь заходит о звонках, особенно видео, безопасность — не опция, а необходимость. К счастью, WebRTC уже из коробки делает многое за нас, но есть и подводные камни, о которых стоит знать.

Шифрование

WebRTC всегда использует сквозное шифрование:

  • SRTP (Secure RTP) — для аудио/видео трафика;

  • DTLS (Datagram TLS) — для ключевого обмена и сигналов управления.

Это значит, что:

  • даже если кто-то перехватит трафик (например, в Wi-Fi-сети), он не сможет его расшифровать;

  • даже TURN-сервер, через который проходит весь поток, видит только зашифрованные данные.

А как насчёт сигналинга? Вот тут уже всё зависит от вас.

WebRTC не диктует, как реализовать сигналинг — вы можете использовать WebSocket, GraphQL, REST, почтовых голубей… Но именно через этот канал передаётся SDP и ICE-кандидаты — то есть информация о соединении.

Итого

В первой части мы начали создавать свой мессенджер с нуля: реализовали сервер на WebSocket, организовали обмен сообщениями между клиентами, и даже добавили хранение истории чатов.

Теперь наш мессенджер умеет делать видеозвонки. Мы разобрались, как работает WebRTC, настроили сигналинг через GraphQL, подключили камеру и микрофон, обменялись SDP и ICE-кандидатами, и наконец — получили рабочее P2P-соединение между клиентами.

Ссылка на пример

Tags:
Hubs:
+15
Comments2

Articles