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

Стандарт поддерживается множеством браузеров, но они отличаются в некоторых деталях, реализация, описанная в этой статье, должна помочь вам начать работу в Google Chrome, а также в Mozilla Firefox, так как я все еще работаю над его использованием в других веб-браузеры, такие как Apple Safari и Microsoft Edge.

Концепция

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

Основные протоколы

Стандарт работает, по сути, поверх двух основных протоколов, во-первых, это SDP «Протокол описания сеанса», который определяет возможности и ограничения мультимедиа устройства, и протокол ICE «Установление подключения к Интернету», который определяет сетевую информацию, которую другие устройства могут использовать для связи с твое устройство.

Устройство Медиа

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

var localStream;
navigator.mediaDevices.getUserMedia({video: true, audio: true}).then(function(stream){
    localStream = stream;
    handlePendingData();   // Defined later on.
}).catch(function(error){
    console.log(error);
});

Обратите внимание на объект, присвоенный методу getUserMedia выше, он решает, получать ли аудио, видео или и то, и другое, для просмотра захваченного потока вам понадобится тег video в вашем представлении html, но с добавленным атрибутом muted, чтобы вы не услышите свой собственный звук, затем в сценарии вы напишете $('#your-video-id')[0].srcObject = localStream;, конечно, вам решать, как вы назовете и выберете свой видеоэлемент - и да, знак$ - это jQuery.

Предостережения, которые нужно знать, прежде чем мы продолжим

Первое, что следует иметь в виду, это то, что большинство связанных с WebRTC методов - это async функции, которые возвращают Promise, т.е. следующий шаг после вызова любого из них должен выполняться в закрытии, переданном методу then возвращенного Promise, или с использованием выражение await, если применимо.

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

Объект RTCPeerConnection - это то, что определяет соединение между вашим устройством и другим устройством, поэтому при вызове между двумя сторонами у каждой стороны будет один RTCPeerConnection, который связывает его с другим устройством, в групповом вызове из четырех сторон каждая сторона будет имея три RTCPeerConnection объекта, рассматривайте этот объект как линию, соединяющую две точки вместе, и каждая точка является одноранговым узлом или, другими словами, устройством.

STUN, TURN и серверы сигнализации

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

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

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

Эти два типа серверов собирают то, что называется RTCIceCandidate, который определяет возможный сетевой путь для удаленных одноранговых узлов - других устройств - для использования в их связи с вашим устройством, и, как вы заметили, собранные данные представляют собой детали вашего собственного устройства, Итак, как обмениваться этими данными с другими устройствами, включая SDP, - это то, что делает сервер сигнализации.

Вы можете использовать некоторые из серверов STUN и TURN, которые находятся здесь и здесь.

Давай начнем

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

// ICE candidates gathering servers, you can use up to five at once.
var iceServers = [
    {url: "stun:stun3.l.google.com:19302"},
    {url: "stun:stun3.l.google.com:19302"}
];
// Initializing the signaling server, which is basically a websocket.
var signalingChannel = new WebSocket("wss://your_websocket_server");
var peers = {}, pendingPeers = [], pendingMessages = [], pendingSdps = {}, pendingCandidates = {}, ready = false, currentId = Math.floor(Date.now() / 1000);

Серверы STUN и TURN предоставляются в виде облачных сервисов, некоторые будут бесплатными, а некоторые будут платными и потребуют учетных данных, но сервер сигнализации является нашим, чтобы реализовать, вам решать, как его реализовать, будь то в socket.io, node -js или php с Ratchet, как показано здесь.

Определим несколько обработчиков

Следующие функции будут обрабатывать различные события, происходящие в приложении, изначально для приложения установлено значение не ready, что означает, что мы еще не получили разрешение на доступ к мультимедийным устройствам пользователя, как только будет задано localStream, и следующая функция будет называться:

function handlePendingData(){
    ready = true;
    for(var i in pendingPeers)
        initPeer(pedingPeers[i])
    pendingPeers = [];
    setTimeout(function(){
        for(var i in pendingMessages)
            handleSignalingMessage(pendingMessages[i]);
        pendingMessages = [];
    }, 1000);
}
function initPeer(peerId){
    peers[peerId] = new RTCPeerConnection({
        iceServers: iceServers
    });
    initRemoteStream(peerId);
    addIceListeners(peerId);
    // Creating an offer SDP for the joining peer.
    if(peerId > currentId){
        var peer = peers[peerId];
        peer.createOffer().then(function(offer){
            return peer.setLocalDescription(offer);
        }).then(function(){
            pendingSdps[peerId] = {
                action: 'offer',
                id: peer.id,
                offer: peer.localDescription
            };
        });
    }
}

Функция в основном инициализирует всех существующих пиров, которые были в комнате, когда мы вошли, добавляя новые теги video в наше представление html для каждого из них и добавляя их соответствующие слушатели потока в функцию initRemoteStream, а также настраивая слушателей событий кандидатов ICE в функции addIceListeners давайте подробно рассмотрим эти две функции:

function initRemoteStream(peerId){
    var peer = peers[peerId];
    $('body').append('<video id="'+peerId+'"></video>');
    if(!sendLocalStream(peer))
        var localStreamSent = setInterval(function(){
            if(sendLocalStream(peer))
                clearInterval(localStreamSent);
        }, 100);
    var stream = new MediaStream();
    peer.addEventListener('track', function(e){
        var peerVideo = $('#' + peerId).find('video')[0];
        stream.addTrack(e.track);
        if(!peerVideo.srcObject)
            peerVideo.srcObject = stream;
        console.log('Remote Track Added', e.track);
    });
}
function addIceListeners(peerId){
    var peer = peers[peerId];
    peer.addEventListener('icecandidate', function(e){
        if(e.candidate)
            signalingChannel.send(JSON.stringify({
                action: 'candidate',
                id: peerId,
                candidate: e.candidate
            }));
    });
    peer.addEventListener('icecandidateerror', function(e){
        console.error('ICE error:', e);
    });
    peer.addEventListener('icegatheringstatechange', function(e){
        var connection = e.target;
        if(connection.iceGatheringState == 'complete' && pendingSdps[peerId] != null){
          signalingChannel.send(JSON.stringify(pendingSdps[peerId]));
            pendingSdps[peerId] = null;
        }
    });
}

Теперь, если вы внимательно посмотрите на последний добавленный прослушиватель событий icegatheringstatechange, вы увидите, что signalingChannel используется для отправки ожидающего SDP другому партнеру, который является объектом мультимедийных возможностей и ограничений вашего устройства, что означает мы отправляем SDP не после его создания, а скорее когда собраны все кандидаты ICE.

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

function sendLocalStream(peerId){
    var peer = peers[peerId];
    if(localStream != null){
        localStream.getTracks().forEach(function(track){
            peer.addTrack(track);
            console.log('Local Track Added', track);
        });
        return true;
    }
    return  false;
}`

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

Подводя итоги!

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

function handleSignalingMessage(message){
    switch(message.action){
        case 'offer':
            handleOffer(message.offer, message.senderId);
            break;
        case 'answer':
            handleAnswer(message.answer, message.senderId);
            break;
        case 'candidate':
            handleCandidate(message.candidate, message.senderId);
            break;
        case 'close':
            peers.splice(message.id, 1);
            $('#' + message.id).remove();
    }
}
function handleOffer(offer, senderId){
    var peer = peers[senderId];
    peer.setRemoteDescription(new RTCSessionDescription(offer)).then(function(){
        handlePendingCandidates(peerId);
        return peer.createAnswer();
    }).then(function(answer){
        return peer.setLocalDescription(answer);
    }).then(function(){
        pendingSdps[senderId] = {
            action: 'answer',
            id: senderId,
            answer: peer.localDescription
        };
    });
}
function handleAnswer(answer, peerId){
    var peer = peers[peerId];
    if(!peer.remoteDescription){
        var peer = peers[peerId];
        peer.setRemoteDescription(new RTCSessionDescription(answer)).then(function(){
            handlePendingCandidates(peerId);
        });
    }
}
function handleCandidate(candidate, peerId){
    var peer = peers[peerId];
    if(!peer.remoteDescription){
        if(pendingCandidates[peerId] === undefined)
            pendingCandidates[peerId] = [candidate];
        else
            pendingCandidates[peerId].push(candidate);
    } else
        peer.addIceCandidate(new RTCIceCandidate(candidate)).catch(function(e){
            console.error('Could not add received ICE candidate', e);
        });
}
function handlePendingCandidates(peerId){
    var peer = peers[peerId];
    pendingCandidates[peerId].forEach(function(candidate){
        peer.handleCandidate(candidate);
    });
    pendingCandidates[peerId] = [];
}
signalingChannel.onmessage = function(e){
    var message = JSON.parse(e.data);
    if(message.action == 'init'){
        if(ready)
            initPeer(message.id);
        else
            pendingPeers.push(message.id);
    } else if(ready)
        handleSignalingMessage(message);
    else
        pendingMessages.push(message);
};
signalingChannel.onopen = function(){
    signalingChannel.send(JSON.stringify({
        action: 'init',
        id: currentId
    }));
};

Вот и вся логика приложения, предыдущие обработчики handleOffer, handleAnswer и handleCandidate обрабатывают входящие SDP предложений, SDP ответов и кандидатов ICE от других одноранговых узлов, обработка некоторых из них откладывается до тех пор, пока приложение не будет установлено в состояние готовности, что происходит, когда доступ к веб-камере или экранному каналу предоставляется - что касается кандидатов, они не обрабатываются до тех пор, пока не будет установлен удаленный SDP однорангового узла - если обработать раньше, сбор кандидатов текущего однорангового узла остановится, как это видно в Google Chrome.

Начальной точкой приложения является обработчик onopen, установленный на signalingChannel, который отправляет идентификатор текущего однорангового узла всем другим одноранговым узлам, как только канал - веб-сокет - открывается, и все другие действия запускаются в onmessage обработчике канала.

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

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

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