¿Te gustaría colaborar en un proyecto de software libre? Conoce P2PSP

Chat Multiusuario con WebRTC

Comparte este artículo

Para entender un poco mejor como vamos a conseguir transmitir vídeo en el navegador mediante P2P sin necesidad de plugins gracias a WebRTC, primero necesitamos entender cómo funcionan los elementos básicos de la API que nos permitirán hacer esto, hablamos de RTCPeerConnection y RTCDataChannel. Para ello, vamos a construir un pequeño ejemplo de un chat multiusuario con el objetivo de entender como trabajar con la API WebRTC.

El chat estará compuesto por los siguientes elementos:

  • Peer: Es la parte del cliente, cada usuario ejecutará un peer en su navegador. El peer lo vamos a escribir en HTML5+JavaScript+WebRTC
  • Server (SignalingServer): Es la parte servidora, su misión es simple, hacer que los peer se conozcan entre sí. Sólo eso, los mensajes nunca pasarán por el servidor, estos serán intercambiados directamente entre los peers. El Server lo vamos a escribir en Python.

Nota: Si todo esto te suena a chino te recomiendo que eches un vistazo a WebRTC Comunicación en tiempo real sin plugins antes de seguir leyendo.

¡Manos a la obra!

El Server (SignalingServer)

Para que los clientes (peer) se conozca entre si es necesario hacer uso de un servidor de señalización que nos de la información necesaria de cada uno para poder establecer una comunicación directa entre ellos. Necesitamos una forma de comunicarnos con los peer que se están ejecutando en el navegador de cada usuario, algo que nos permita intercambiar la información entre el servidor y cada uno de los clientes. La forma más sencilla y directa es usar WebSocket, para ello haremos uso de la siguiente librería SimpleWebSocketServer by opiate, es software libre y nos facilita su implementación en Python.

Partiendo del módulo sólo tenemos que reescribir los meéodos handleMessage, handleConnected y handleClose para que hagan lo que nosotros búscamos, en este caso es muy sencillo, intercambiar la información de cada peer con el resto de peers.

#hanleMessage se ejecuta cuando un mensaje es recibido
def handleMessage(self):
      #recibimos un mensaje (json)
    datos=str(self.data) 
 
        #decodificamos el mensaje (json)
        try:
        decoded = json.loads(datos) 
    except (ValueError, KeyError, TypeError):
        print "JSON format error"
 
        #Reenviamos el mensaje a resto de clientes
    for client in self.server.connections.itervalues():
            if client != self:
              try:
                client.sendMessage(str(self.data))
              except Exception as n:
                  print n
 
#handleConnected se ejecuta cuando un nuevo cliente se conecta
def handleConnected(self):
    global nextid
 
    try:
          #enviamos al cliente su id de peer
      self.sendMessage(str('{"numpeer":"'+str(nextid)+'"}'))
            #enviamos al cliente la lista de peer actual
      self.sendMessage(str('{"peerlist":"'+str(peerlist)+'"}'))
            #agregamos el nuevo peer a la lista
      peerlist.append(nextid)
      peeridlist[self]=nextid
      nextid=nextid+1
    except Exception as n:
      print n
 
#handleClose se ejecuta cuando un cliente se desconecta
def handleClose(self):
      #eliminamos el peer de la lista
    peerlist.remove(peeridlist[self]);

Nota: El código completo del SignalingServer está actualizado y disponible en Launchpad -> P2PSP > Experimentos > ChatMultiusuario > Server.py

El Peer

Como ya hemos dicho, la parte del cliente se ejecuta en el navegador y toda la funcionalidad vamos a escribirla en JavaScript. A continuación sólo vamos a comentar algunas partes del código que son interesantes. Recuerda que puedes acceder al código fuente completo de este experimento en Launchpad -> WebRTCMultiPeerChat

Primero definimos el SignalingServer que será la URL donde se está ejecutando Server.py y configuration para RTCPeerConnection que será el servidor STUN que se encarga de proporcionarnos la información de red externa (IP, Puerto, etc).

var signalingChannel = new WebSocket("ws://127.0.0.1:9876/");
var configuration = {iceServers: [{ url: 'stun:stun.l.google.com:19302' }]}

A continuación se muestra el resto del código necesario para el peer, está comentado para una comprensión más sencilla:

// Inicializamos las conexión
// isInitiator = true o false
// i= id del peer
function start(isInitiator,i) {
     //Inicializamos RTCPeerConnection para el peer i.
  pcs[i] = new webkitRTCPeerConnection(configuration, {optional: []});
 
 
  // Enviar cualquier ICE candidate a los otros peer.
  pcs[i].onicecandidate = function (evt) {
    if (evt.candidate){
      signalingChannel.send(JSON.stringify({ "candidate": evt.candidate , "idtransmitter":'"'+idpeer+'"', "idreceiver":'"'+i+'"'}));
    }
  };
 
 
  // dejar a "negotiationneeded" generar ofertas (Offer)
  pcs[i].onnegotiationneeded = function () {
    pcs[i].createOffer(function(desc){localDescCreated(desc,pcs[i],i);});
    console.log("Create and send OFFER");
  }
 
  if (isInitiator) {
    // Crear el datachannel para ese peer
    channel[i] = pcs[i].createDataChannel("chat"+i);
    setupChat(i);
  } else {
      // Establecer el datachannel para ese peer
    pcs[i].ondatachannel = function (evt) {
      channel[i] = evt.channel;
      setupChat(i);
    };
  }  
  console.log("Saved in slot: "+i+" PeerConection: "+pcs[i]);
}
 
//Establecer localDescriction y y enviarla a los otros peer (ellos la estableceran como remoteDescription)
function localDescCreated(desc,pc,i) {
    pc.setLocalDescription(desc, function () {
  console.log("localDescription is Set");
        signalingChannel.send(JSON.stringify({ "sdp": pc.localDescription , "idtransmitter":'"'+idpeer+'"', "idreceiver":'"'+i+'"'}));
    }, logError);
}
 
//Se ejecuta cuando se recibe un mensaje desde el signalingChannel (WebSocket)
signalingChannel.onmessage = function (evt) {
  handleMessage(evt);
}
 
//Manipular el mensaje
function handleMessage(evt){
  var message = JSON.parse(evt.data);
 
    //Si es el ide del peer se almacena
    if (message.numpeer){    
    idpeer=message.numpeer;
    console.log('Peer ID: '+idpeer);
    return;    
    }  
 
    //Si es la lista de peer se almacena
  if (message.peerlist){    
    console.log('Peer List '+message.peerlist);
    peerlist=JSON.parse(message.peerlist);
    for (i in peerlist){
      console.log("Peer: "+peerlist[i]);
    }
    return;    
    }  
 
    //guardamos el id del que envia el mensaje y el id del que debe recibirlo
    var id=(message.idtransmitter).split('"').join(''); 
  var idreceiver=(message.idreceiver).split('"').join(''); 
    console.log("Received from: "+id+" and send to: "+idreceiver);
 
    //Si es nuevo para este peer se configura la RTCPeerConection y se añade a la lista de peer.
    if (!pcs[id]) { 
    console.log('%cCreate a new PeerConection','background: #222; color: #bada55');
    peerlist.push(id);
    console.log("PEER LIST UPDATE: "+peerlist);
    start(false,id);
    }   
 
    //Si el mensaje va dirigido a mi y es SDP (informacion de la sesion)
    if (message.sdp && idreceiver==idpeer){
        //Estableco la información de la conexión remota (remoteDescription)
    pcs[id].setRemoteDescription(new RTCSessionDescription(message.sdp), function () {
      console.log("remoteDescription is Set");
        // Si recibimos una oferta enviamos una respuesta.
      if (pcs[id].remoteDescription.type == "offer"){
        console.log("Create and send ANSWER");
            pcs[id].createAnswer(function(desc){localDescCreated(desc,pcs[id],id);});
        }
        });
    }
 
    //Si el mensaje va dirigido a mi y es un ICE candidate
    if (message.candidate && idreceiver==idpeer){
        //agrego el candidato a la lista de candidatos de ese peer.
    console.log("Received ice candidate: "+ message.candidate.candidate); 
    pcs[id].addIceCandidate(new RTCIceCandidate(message.candidate));
    }
 
}
 
//Configuracion del chat
function setupChat(i) {
    //Cuando se abra el dataChannel con ese peer se habilita el boton enviar.
    channel[i].onopen = function () {
        btnSend.disabled=false;
  document.getElementById("chatcontrols").style.display="inline";
    };
 
    //Cuando se reciba un mensaje por DataChannel (WebRTC) se muestra el mensaje.
    channel[i].onmessage = function (evt) {
       document.getElementById("receive").innerHTML+="<br />"+evt.data;
    };
}
 
//Enviar un mensaje por DataChannel (al resto de peer)
function sendChatMessage() {
    document.getElementById("receive").innerHTML+="<br />"+document.getElementById("login").value+ ": "+msg.value;
  //Para cada peer de la lista...
    for (i in peerlist){  
    if (peerlist[i]!=idpeer){
      console.log("send to "+peerlist[i]);  
            //Se envial el mensaje con el nombre de usuario y el texto a enviar.
      try{
        channel[peerlist[i]].send(document.getElementById("login").value+ ": "+msg.value);
      }catch(e){
        console.log(i+" said bye!");
      }
 
    }
  }
}

Nota: El código completo del Peer está actualizado y disponible en Launchpad -> P2PSP > Experimentos > ChatMultiusuario > Peer.js

Una versión de este experimento está disponible para probarlo en http://www.p2psp.org/chat. Si al introducir un nickname aparece el mensaje "Conecting..."  pero no aparece el cuadro de texto con el botón "send" es posible que no haya ningún otro peer (o esté detrás de un NAT simétrico), para comprobar que funciona correctamente puedes abrir otra vez la misma URL en otra pestaña de tu navegador o incluso en otro equipo de tu red.

Si tienes cualquier duda o sugerencia, usa los comentarios :-)

Este experimento, por el momento, sólo funciona en Google Chrome (incluido Chrome for Android)

Comparte este artículo

Comentarios   

0 # Camilo Ortiz 27-02-2015 14:47
Buenos días,
Excelente el tutorial, pero lo subí al servidor donde tengo mi pagina web y no he podido lograr que funcioné, revisé el ejemplo que tiene en el tutorial y ese si funciona, que puede estar pasando?
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 28-02-2015 12:40
Hola Camilo,
Asegurate de estar ejecutando correctamente server.py y de que el websocket creado en signalingChanne l del fichero peer.js tenga la IP correcta apuntando al servidor que ejecuta server.py.

Suerte!
Gracias por visitar el blog.
Responder | Responder con una citación | Citar
0 # jairo 03-10-2015 01:05
felicito la idea de esta página, necesito orientación, logre descargar la libreria y cuando ejecuto el server.py queda la linea esperando algo, no se que hacer luego.
Responder | Responder con una citación | Citar
0 # jairo 04-10-2015 15:10
he ido resolviendo, pero el problema radica aqui, en la linea 33 del peer.js

btnSend.disabled=true; cuando se ejecuta la página, se intrude el nickname, en el inspeccionador de elemento dice "Uncaught TypeError: Cannot set property 'disabled' of null"
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 04-10-2015 15:40
Hola Jairo,

Con el objetivo de comprobar que no se trata de un problema de compatibilidad con el navegador web, ¿podría probar la versión de prueba que tengo subida a www.p2psp.org/chat/?

La prueba sería simplemente abrir dos instancias del navegador Web Chrome, teclear esa URL, escribir un nickname y pulsar el botón "Enter" en cada una de ellas. Debería aparecer un campo de texto y un botón "Send". Usando el campo de texto se podrían enviar mensajes entre las instancias del navegador.

Espero que pueda realizar la prueba y comentar si funciona. Así podremos centrarnos en el problema.

Muchas gracias por visitar el Blog.
Saludos!
Responder | Responder con una citación | Citar
0 # jairo 04-10-2015 23:38
ya lo resolví, era un problema de un id en html, que en java buscaba otro id. me corre bien, lo estoy validando para que no acepte nick en blanco.
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 05-10-2015 07:48
Genial!!

Estoy pensando que podríamos retomar este proyecto y mejorarlo. Este ejemplo realmente es solo una prueba de concepto.

Voy a separarlo del conjunto de experimentos y crearé un proyecto nuevo en GitHub para el chat. Sería genial que pudieras colaborar con tus mejoras :)

Muchas gracias,
Saludos!
Responder | Responder con una citación | Citar
0 # jairo 12-11-2015 19:41
como estas cristobal, retome el proyecto, y ahora no genera conexión, pensé que habia echado a perder algo, pero nada. enbtocnes vi tu ejemplo en la pagina y tienes el mismo problema, es decir despues que se ejecuta el enter cuando ingresas el login, se queda en conectando. chequea tu sitio y me dices
Responder | Responder con una citación | Citar
0 # jairo 12-11-2015 19:42
el proyecto me corría igual que el tuyo en la página, será que los servidores stun cambiaron algo, probé los 5 de google y nada
Responder | Responder con una citación | Citar
0 # jairo 13-11-2015 21:09
el proyecto ya no funciona, será problema con los servidores stun, la copia que yo estaba modificando dejó de funcionar, y el demo que tienes montado en el sitio web, tampoco funciona.
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 13-11-2015 21:20
Hola Jairo,

Gracias por el aviso. Voy a intentar revisarlo y te digo algo. Es posible que sea como dices y exista algún problema en el servidor.

Saludos!
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 29-12-2015 18:19
Hola Jairo,

Por fin he sacado un rato para revisar el código y he encontrado el problema. No funciona debido a un cambio en la sintaxis de la API WebRTC, en concreto, en la linea:

pcs = new webkitRTCPeerCo nnection(config uration, {optional: [{RtpDataChanne ls: true}]});

Ya no está permitido pasar RtpDataChannels como opcional, por tanto, el error se resuelve simplemente sustituyendo la linea anterior por:

pcs = new webkitRTCPeerCo nnection(config uration, {optional: []});

Espero que te sirva de ayuda.
Como siempre, gracias por aportar tus ideas y comentarios en el blog.

Saludos!
Responder | Responder con una citación | Citar
0 # doces e brigadeiros 08-09-2017 07:50
Tudo bem? tenho uma pergunta totalmente fora do tópico.

Poderia me ajudar? Você sabe como tornar o seu site responsivo?
É porque meu site parece estranho quando navegado pelo meu iphone4.
Estou tentando encontrar um plugin que seja capaz de resolver este contratempo.

Se você tiver qualquer sugestão, por favor, compartilhe
comigo, ok? Obrigado !
Responder | Responder con una citación | Citar
0 # Cristóbal Medina 19-09-2017 07:45
En principio el sitio debería adaptarse automáticamente a su pantalla sin realizar ninguna configuración adicional. No sé que podría estar ocurriendo.
Responder | Responder con una citación | Citar

Escribir un comentario


Código de seguridad
Refescar

Suscripción RSS

feed-image Blog