Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial

WebRTC: Comunicazioni Real Time per il Web

Link copiato negli appunti

Le specifiche WebRTC (ad oggi in fase di sperimentazione), nascono con l'intento di offrire agli sviluppatori web uno strumento per gestire l'interscambio di flussi di dati tra due device in collegamento diretto (peer-to-peer).

Con il termine flussi di dati si vuole indicare in primo luogo tutta la comunicazione di carattere multimediale, come ad esempio video e audio; ma non solo, come vedremo più avanti, le specifiche contemplano anche la trasmissione di dati di tipo diverso, come potrebbero essere quelli derivanti dall'oscilloscopio presente sui moderni smartphone, o dati progettati per una particolare applicazione.

La ricetta per ottenere un risultato come questo è sicuramente complessa e coinvolge molte componenti e tematiche, che spaziano dal networking fino alla gestione appropriata dei codec audio e video per la gestione dei segnali e la cancellazione dell'eco del microfono; fortunatamente le API che stiamo per scoprire nascondono e gestiscono una parte, ma non tutta, di questa complessità.

WebRTC si compone, ad oggi, di 3 API:

API Descrizione
MediaStream Consente di ottenere un flusso audio/video proveniente dalla telecamera e dal microfono installato sul device dell'utente
RTCPeerConnection Gestisce la connessione e la comunicazione di flussi di dati tra due device in connessione peer-to-peer
RTCDataChannel Consente l'interscambio di flussi di dati arbitrari tra due device in connessione peer-to-peer

Prima di proseguire è importante ricordare che stiamo parlando di feature sperimentali, le cui specifiche potrebbero cambiare nel corso dei prossimi mesi ed il cui supporto ad oggi non è esteso a tutti i browser.

Per essere precisi a dicembre 2012 le MediaStream API sono implementate da Chrome, Opera e Firefox Nightly (anche se previa attivazione di un flag), le RTCPeerConnection API funzionano invece soltanto su Chrome e Firefox Nightly e, per finire le RTCDataChannel API sono operative solamente su Firefox Nightly.

Bene, detto questo possiamo iniziare la nostra sperimentazione partendo dalle MediaStream API.

MediaStream API

Le Media Stream API (dette anche getUserMedia) danno la possibilità, previo consenso dell'utente, di accedere al flusso video proveniente dalla webcam ed al flusso audio proveniente dal microfono situati sul device che l'utente sta utilizzando in quel momento.

La sintassi è semplicissima e si riassume in questo piccolo esempio funzionante, leggerissima variazione del codice di questo post di riferimento pubblicato su html5rocks.com:

// uniformo la chiamata a getUserMedia rispetto ai vari prefissi sperimentali
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
    navigator.mozGetUserMedia || navigator.msGetUserMedia;
// uniformo la chiamata a window.URL sempre rispetto ai prefissi sperimentali
window.URL = window.URL || window.webkitURL;
// invoco la richiesta di accedere sia al flusso video che quello audio, la funzione
// passata come secondo parametro viene lanciata in caso di successo, quella come
// parametro in caso di errore o di negata autorizzazione
navigator.getUserMedia({video: true}, function(localMediaStream) {
  // creo un elemento HTML5 video ed imposto come sorgente lo stream
  // proveniente dalla telecamera.
  var video = document.createElement("video");
  video.autoplay = true;
  video.src = window.URL ? window.URL.createObjectURL(localMediaStream) : localMediaStream;
  // aggancio l'elemento video al body della pagina HTML.
  document.body.appendChild(video);
  }, 
  function(error) {
	console.log(error);
  });

Se possedete un browser compatibile con il supporto alle Media Stream API potete testare lo script appena visto.

Figura 1. Risultato

Risultato

RTCPeerConnection

Queste API sono più complicate delle precedenti, in essenza la loro funzione è di mettere in contatto due device in modalità peer-to-peer. La procedura che ci consente di attuare questo collegamento è la seguente:

  • Abbiamo due device, Alice e Bob. Alice vuole contattare Bob ed aprire un canale peer-to-peer.
  • Alice crea un oggetto RTCPeerConnection e si mette in ascolto dell'evento onicecandidate; che viene lanciato quando l'oggetto è pronto a trasmettere.
  • Appena intercetta l'evento onicecandidate Alice comunica a Bob l'identificatore univoco del proprio canale peer-to-peer (in realtà composto da una serie di oggetti secondo le specifiche del framework ICE, usato alla base delle comunicazioni WebRTC).
  • Bob utilizza il metodo addIceCandidate sul proprio oggetto RTCPeerConnection per aggiungere Alice come destinatario.
  • Alice genera con il metodo createOffer una stringa SDP: Session Description Protocol, che descrive i vincoli al flusso audio/video (codec, etc.) del proprio device. Questa stringa viene inviata a Bob.
  • Bob riceve questa stringa e genera un SDP di risposta con il metodo createAnswer. Questa stringa viene inviata ad Alice.
  • Sia Alice che Bob ricevono l'evento addstream che contiene il flusso dati del partner e possono decidere di caricarlo in un elemento video come abbiamo visto nell'esempio precedente.

Per sperimentare queste API dobbiamo avvalerci di Chrome poiché il supporto su altri browser è, come abbiamo detto, limitato soltanto alla versione Nightly di Firefox. Per gestire lo scambio dei messaggi tra Alice e Bob precedenti all'apertura del canale P2P utilizziamo il protocollo websocket; per prima cosa quindi dotiamoci di un server websocket che sappia mettere in comunicazioni due browser per il tempo necessario all'apertura del canaler peer-to-peer.

Fortunatamente possiamo soddisfare questo prerequisito con uno script di poche righe in Ruby. Se non abbiamo mai installato questo interprete seguiamo le istruzioni. Completata l'installazione lanciamo dal terminale il comando gem install em-websocket e creiamo un file webserver.rb contenente:

require 'rubygems'
require 'em-websocket'
EventMachine.run do
    @channels = {}
    EventMachine::WebSocket.start(
      :host => "0.0.0.0", :port => 8080) do |ws|     
      ws.onmessage do |msg|
        command, params = msg.split(":",2)
        if(command == "c")
          @channels[msg[2..-1]] ||= EM::Channel.new
          @channels[msg[2..-1]].subscribe{ |msg| ws.send msg }
        else
          room, message = params.split(":",2)
          @channels[room].push message
        end
      end
    end
end

Anche se probabilmente stiamo osservando codice in un linguaggio sconosciuto, possiamo comunque cercare di intuirne il comportamento:

  • Il server si mette in ascolto sulla porta 8080; a ogni connessione ws è valorizzata con un riferimento al client;
  • Ogni qualvolta un client invia un messaggio al server il blocco di codice associato a ws.onmessage viene eseguito;
  • Se il messaggio è nella forma c:x, dove x è un numero arbitrario, viene cercato un canale di comunicazione (o creato, se inesistente) associato alla chiave x. Il client viene quindi aggiunto a questo canale di comunicazione;
  • Se il messaggio è nella forma m:x:txt, dove x è un numero arbitrario e txt rappresenta del testo, il messaggio txt viene inviato attraverso il canale di comunicazione associato alla chiave x a tutti i client ad esso connessi.

Eseguiamo il server digitando da linea di comando ruby webserver.rb e, mantenendo il server in esecuzione, prepariamoci alla seconda parte del progetto.

Una semplice chat peer-to-peer

Chiamiamo index.html il file che conterrà il codice della parte client del nostro progetto e cominciamo e definirne il markup; per gestire la comunicazione bilaterale saranno sufficienti due elementi video, uno contenente lo stream della nostra webcam e uno con quello della webcam del peer:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Una semplice video chat p2p</title>
  </head>
  <body>
    <video width="300" id="video_locale" autoplay="autoplay"></video>
    <video width="300" id="video_remoto" autoplay="autoplay"></video>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
    <script src="js/application.js"></script>
  </body>
</html>

Ora passiamo alla parte JavaScript: creiamo un file application.js in una cartella js e soffermiamoci un poco per pianificare il flusso che dovrà seguire l'intera connessione:

  1. L'utente che vuole iniziare una chat peer-to-peer accede ad index.html da un browser;
  2. la pagina web apre una connessione websocket e genera una chiave randomica come identificativo della connessione. Per comodità questa chiave viene aggiunta automaticamente in coda all'url nella barra degli indirizzi (es: http://miowebserver/webrtc/index.html#403 );
  3. la pagina invia attraverso la connessione websocket il messaggio c:x, dove x è la chiave randomica del punto precedente. In questo modo il websocket crea un canale di comunicazione identificato da x e vi aggiunge il client.
  4. la pagina si mette in ascolto di eventuali messaggi in arrivo attraverso il canale websocket appena creato.

Ora l'utente deve invitare un partner alla chat, per farlo può mandare tramite e-mail l'indirizzo della pagina web, comprensivo della chiave randomica. Il partner segue il link ricevuto e atterra sul medesimo index.html dell'utente con la differenza che in questo caso l'url contiene già una chiave.

L'applicazione web quindi può utilizzare e inviare al server websocket l'identificativo già presente nell'url invece che crearne uno nuovo. Quest'operazione mette di fatto entrambi i client sullo stesso canale di comunicazione websocket e li abilita in definitiva allo scambio di messaggi.

Vediamo il codice (da inserire in application.js) per realizzare questa prima parte:

// --- gestione del prefisso sperimentale ---
  window.RTCPeerConnection = window.webkitRTCPeerConnection;
  navigator.getUserMedia = navigator.webkitGetUserMedia ;
  window.URL = window.URL || window.webkitURL;
  // --- variabili iniziali ---
  var video_locale = document.getElementById("video_locale"),
      video_remoto = document.getElementById("video_remoto"),
      ws = new WebSocket('ws://__IP_SERVER_WEBSOCKET__:8080'),
      peer;
  if(location.hash === ""){
    // in questo caso l'URL non contiene la chiave e devo crearne una
    var stanza = Math.round(Math.random()*1000),
        chiamante = 0;
    location.hash = stanza;
  }else{
    // in questo caso l'URL contiene già la chiave
    var stanza = location.hash.substr(1),
        chiamante = 1;
  }
  // --- init dall'applicazione all'apertura del canale websocket ---
  ws.addEventListener('open', function(){
    // invio al server websocket l'identificativo del canale di
    // comunicazione al quale mi voglio connettere.
    ws.send("c:" + stanza);
    inizializza_video();  // dobbiamo ancora scrivere questa funzione.
  }, false);

La variabile chiamante è valorizzata sulla base della presenza o meno della chiave univoca ed è utilizzata nella prossima parte del codice per distinguere se l'utente sta aprendo una chat o sta cercando di accedere ad una chat esistente. Nel primo caso tutto ciò che è richiesto dalle API è rimanere in attesa della richiesta di connessione da parte del partner, nel secondo caso invece l'applicazione deve creare per prima un set di candidati ICE ed una offerta SDP ed inviare il tutto tramite il canale di comunicazione attraverso il server websocket.

Ora definiamo inizializza_video; i compiti di questa funzione sono molteplici:

  • Ottenere, tramite la chiamata getUserMedia, l'accesso alla webcam e al microfono
    dell'utente;
  • visualizzare lo stream video proveniente dalla webcam dell'utente nell'elemento con id video_locale;
  • istanziare una RTCPeerConnection ed associare una funzione all'evento icecandidate della stessa;
  • associare all'evento addstream una funzione che visualizzi lo stream remoto nell'elemento con id video_remoto;
  • aggiungere lo stream locale all'istanza RTCPeerConnection e, se l'applicazione è già in possesso di una chiave alla quale connettersi (chiamante = 1), invocare la funzione per la creazione dell'offerta SDP da inviare al partner.

Ed ecco il codice della funzione, da aggiungere all'interno di application.js

// --- configurazione ---
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};
var peer_config = {"iceServers": [{"url": "stun:stunserver.org"}]};
// --- richiesta accesso webcam e microfono e init della connessione P2P ---
function inizializza_video() {
  navigator.getUserMedia( {'audio':true, 'video':true},
    function(stream) {
      video_locale.src = URL.createObjectURL(stream);
      peer = new RTCPeerConnection(peer_config);
      peer.onicecandidate = onIceCandidate;
      peer.onaddstream = function(event){
        video_remoto.src = URL.createObjectURL(event.stream);
      };
      peer.addStream(stream);
      if (chiamante)
        peer.createOffer(sdpcreato, null, mediaConstraints);
    }
  );
}

Bene, a questo punto dobbiamo creare le due funzioni che abbiamo definito come callbacks nel listato precedente: onIceCandidate e sdpcreato. La prima delle due funzioni deve inviare il nuovo candidato ICE al partner mentre la seconda deve inviare sempre al partner il descrittore SDP prodotto, ecco il codice da accodare in application.js:

// --- invio l'SDP al peer ---
  function sdpcreato(sdp) {
    peer.setLocalDescription(sdp);
    messaggio_da_inviare(sdp);
  }
  // --- invio il candidato ICE al peer ---
  function onIceCandidate(event) {
    if (event.candidate) {
      messaggio_da_inviare(event.candidate);
    }
  }

Ora, per concludere, è necessario definire la funzione messaggio_da_inviare che, ricordiamo, deve inviare il messaggio attraverso il server websocket nel formato m:x:txt dove x è la chiave del canale di comunicazione attraverso il quale deve transitare il messaggio. Anche lo stesso messaggio txt è a sua volta strutturato in frammenti nel formato y:json dove y assume lo stesso valore della variabile chiamante mentre json è la serializzazione JSON del messaggio, che di volta in volta potrebbe essere un candidato ICE o il descrittore SDP.

Ecco la funzione, sempre da aggiungere ad application.js

// --- invio messaggi al websocket ---
  function messaggio_da_inviare(msg) {
    var msgjson = JSON.stringify(msg);
    ws.send("m:"+ stanza + ":" + chiamante + ":" + msgjson);
  }

Una semplice chat peer-to-peer

Chiamiamo index.html il file che conterrà il codice della parte client del nostro progetto e cominciamo e definirne il markup; per gestire la comunicazione bilaterale saranno sufficienti due elementi video, uno contenente lo stream della nostra webcam e uno con quello della webcam del peer:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Una semplice video chat p2p</title>
  </head>
  <body>
    <video width="300" id="video_locale" autoplay="autoplay"></video>
    <video width="300" id="video_remoto" autoplay="autoplay"></video>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
    <script src="js/application.js"></script>
  </body>
</html>

Ora passiamo alla parte JavaScript: creiamo un file application.js in una cartella js e soffermiamoci un poco per pianificare il flusso che dovrà seguire l'intera connessione:

  1. L'utente che vuole iniziare una chat peer-to-peer accede ad index.html da un browser;
  2. la pagina web apre una connessione websocket e genera una chiave randomica come identificativo della connessione. Per comodità questa chiave viene aggiunta automaticamente in coda all'url nella barra degli indirizzi (es: http://miowebserver/webrtc/index.html#403 );
  3. la pagina invia attraverso la connessione websocket il messaggio c:x, dove x è la chiave randomica del punto precedente. In questo modo il websocket crea un canale di comunicazione identificato da x e vi aggiunge il client.
  4. la pagina si mette in ascolto di eventuali messaggi in arrivo attraverso il canale websocket appena creato.

Ora l'utente deve invitare un partner alla chat, per farlo può mandare tramite e-mail l'indirizzo della pagina web, comprensivo della chiave randomica. Il partner segue il link ricevuto e atterra sul medesimo index.html dell'utente con la differenza che in questo caso l'url contiene già una chiave.

L'applicazione web quindi può utilizzare e inviare al server websocket l'identificativo già presente nell'url invece che crearne uno nuovo. Quest'operazione mette di fatto entrambi i client sullo stesso canale di comunicazione websocket e li abilita in definitiva allo scambio di messaggi.

Vediamo il codice (da inserire in application.js) per realizzare questa prima parte:

// --- gestione del prefisso sperimentale ---
  window.RTCPeerConnection = window.webkitRTCPeerConnection;
  navigator.getUserMedia = navigator.webkitGetUserMedia ;
  window.URL = window.URL || window.webkitURL;
  // --- variabili iniziali ---
  var video_locale = document.getElementById("video_locale"),
      video_remoto = document.getElementById("video_remoto"),
      ws = new WebSocket('ws://__IP_SERVER_WEBSOCKET__:8080'),
      peer;
  if(location.hash === ""){
    // in questo caso l'URL non contiene la chiave e devo crearne una
    var stanza = Math.round(Math.random()*1000),
        chiamante = 0;
    location.hash = stanza;
  }else{
    // in questo caso l'URL contiene già la chiave
    var stanza = location.hash.substr(1),
        chiamante = 1;
  }
  // --- init dall'applicazione all'apertura del canale websocket ---
  ws.addEventListener('open', function(){
    // invio al server websocket l'identificativo del canale di
    // comunicazione al quale mi voglio connettere.
    ws.send("c:" + stanza);
    inizializza_video();  // dobbiamo ancora scrivere questa funzione.
  }, false);

La variabile chiamante è valorizzata sulla base della presenza o meno della chiave univoca ed è utilizzata nella prossima parte del codice per distinguere se l'utente sta aprendo una chat o sta cercando di accedere ad una chat esistente. Nel primo caso tutto ciò che è richiesto dalle API è rimanere in attesa della richiesta di connessione da parte del partner, nel secondo caso invece l'applicazione deve creare per prima un set di candidati ICE ed una offerta SDP ed inviare il tutto tramite il canale di comunicazione attraverso il server websocket.

Ora definiamo inizializza_video; i compiti di questa funzione sono molteplici:

  • Ottenere, tramite la chiamata getUserMedia, l'accesso alla webcam e al microfono
    dell'utente;
  • visualizzare lo stream video proveniente dalla webcam dell'utente nell'elemento con id video_locale;
  • istanziare una RTCPeerConnection ed associare una funzione all'evento icecandidate della stessa;
  • associare all'evento addstream una funzione che visualizzi lo stream remoto nell'elemento con id video_remoto;
  • aggiungere lo stream locale all'istanza RTCPeerConnection e, se l'applicazione è già in possesso di una chiave alla quale connettersi (chiamante = 1), invocare la funzione per la creazione dell'offerta SDP da inviare al partner.

Ed ecco il codice della funzione, da aggiungere all'interno di application.js

// --- configurazione ---
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};
var peer_config = {"iceServers": [{"url": "stun:stunserver.org"}]};
// --- richiesta accesso webcam e microfono e init della connessione P2P ---
function inizializza_video() {
  navigator.getUserMedia( {'audio':true, 'video':true},
    function(stream) {
      video_locale.src = URL.createObjectURL(stream);
      peer = new RTCPeerConnection(peer_config);
      peer.onicecandidate = onIceCandidate;
      peer.onaddstream = function(event){
        video_remoto.src = URL.createObjectURL(event.stream);
      };
      peer.addStream(stream);
      if (chiamante)
        peer.createOffer(sdpcreato, null, mediaConstraints);
    }
  );
}

Bene, a questo punto dobbiamo creare le due funzioni che abbiamo definito come callbacks nel listato precedente: onIceCandidate e sdpcreato. La prima delle due funzioni deve inviare il nuovo candidato ICE al partner mentre la seconda deve inviare sempre al partner il descrittore SDP prodotto, ecco il codice da accodare in application.js:

// --- invio l'SDP al peer ---
  function sdpcreato(sdp) {
    peer.setLocalDescription(sdp);
    messaggio_da_inviare(sdp);
  }
  // --- invio il candidato ICE al peer ---
  function onIceCandidate(event) {
    if (event.candidate) {
      messaggio_da_inviare(event.candidate);
    }
  }

Ora, per concludere, è necessario definire la funzione messaggio_da_inviare che, ricordiamo, deve inviare il messaggio attraverso il server websocket nel formato m:x:txt dove x è la chiave del canale di comunicazione attraverso il quale deve transitare il messaggio. Anche lo stesso messaggio txt è a sua volta strutturato in frammenti nel formato y:json dove y assume lo stesso valore della variabile chiamante mentre json è la serializzazione JSON del messaggio, che di volta in volta potrebbe essere un candidato ICE o il descrittore SDP.

Ecco la funzione, sempre da aggiungere ad application.js

// --- invio messaggi al websocket ---
  function messaggio_da_inviare(msg) {
    var msgjson = JSON.stringify(msg);
    ws.send("m:"+ stanza + ":" + chiamante + ":" + msgjson);
  }

Gestire i messaggi dal server

L'ultima parte del progetto riguarda la gestione dei messaggi in arrivo dal server WebSocket, in questo caso le attività da fare sono:

  • Ignorare tutti i messaggi (ricordiamo nel formato m:x:y:json) il cui valore y sia lo stesso della variabile chiamante in quanto sono gli stessi messaggi inviati dall'applicazione al partner remoto. Questo accade perché l'implementazione del server websocket utilizzata invia il messaggio a tutti gli appartenenti al canale di comunicazione identificato compreso lo stesso mittente del messaggio.
  • Deserializzare i messaggi provenienti dal partner e valutarne la tipologia:
    • se la tipologia è di tipo offer si tratta di una offerta SDP, è quindi necessario produrre una corrispondente answer ed inviarla al partner;
    • se la tipologia è di tipo answer dobbiamo soltanto memorizzare la risposta;
    • in ogni altro caso il messaggio è sicuramente un candidato ICE inviatoci dal partner e per questo va incapsulato in una istanza di RTCIceCandidate e processato dall'istanza di RTCPeerConnection dell'applicazione.

Ed ora completiamo il progetto aggiungendo questa ultima porzione di codice ad application.js:

// --- ricezione messaggi dal websocket ---
  ws.addEventListener('message', function(evt){
    var msg = evt.data;
    if(parseInt(msg.substr(0,1),10) !== chiamante){
      processa(msg.substr(2));
    }
  });  
  // --- interpretazione dei messaggi ricevuti per stabilire la connessione P2P ---
  function processa(messaggio) {
    var msg = JSON.parse(messaggio);
    if (msg.type === 'offer') {
      peer.setRemoteDescription(new RTCSessionDescription(msg));
      peer.createAnswer(sdpcreato, null, mediaConstraints);
    } else if (msg.type === 'answer') {
      peer.setRemoteDescription(new RTCSessionDescription(msg));
    } else {
      var candidate = new RTCIceCandidate(msg);
      peer.addIceCandidate(candidate);
    }
  }

Bene, sincerandoci di aver eseguito webserver.rb possiamo testare la nostra realizzazione navigando con Chrome alla pagina index.htm che abbiamo sviluppato, dando il consenso all'utilizzo di microfono e webcam e condividendo il link generato con un amico o un collega all'interno della nostra stessa LAN (a meno di non aver pubblicato il server websocket in Internet, in qual caso l'invito è estendibile a chiunque sia connesso alla rete).

Ti consigliamo anche