2011/04/22

Longpolling

Como os dije ayer, la técnica del longpolling merece una mención aparte principalmente porque es una técnica universal, es decir, si tu navegador soporta AJAX, también soporta longpolling y lo más importante, funciona y lo hace muy bien.

Solo tiene una pega, que mantiene una conexión permanentemente abierta con el servidor con lo que si tenemos muchas visitas simultáneas, podemos llegar a tener problemas a medida que nos vayamos acercando al límite de conexiones del servidor... pero vamos, que en la práctica no vamos a hacer nuestro Google particular donde hay que optimizar cada conexión y cada bit.

Si realmente el número de conexiones es un problema, habrá que recurrir a otras tecnologías como por ejemplo websockets, que es la que tiene más posibilidades (ver el artículo de ayer).

Bueno, pues vamos al lío. En esta técnica hay dos partes, el código del cliente y el del servidor.

En el servidor, vamos a tener un PHP que se encarga de la lógica de negocio cuya pieza clave es la posibilidad de hacer un fork poniendo el proceso en estado de sleep mientras no hayan cambios.
usleep(20000);    // sleep 20ms to unload the CPU
clearstatcache();
Al estar el proceso dormido, no estará bloqueando la ejecución y liberará la CPU. Ahora, la segunda clave, es esperar x segundos mientras no haya que enviar nada al cliente siendo x suficientemente largo como para no tener que estar reabriendo la petición por parte del cliente y suficientemente corto como para que la petición al PHP no de timeout del servidor. En la práctica, el tope son 30 segundos aproximadamente (depende de la configuración del servidor), así que 25 segundos es un buen número. El ejemplo que vamos a ver es el de un chat así que ahí va el código de al parte servidora.

<?php
$fileName = 'chatdata.txt'; //    File to save latest chat in
$maxTime = 25; // Maximum loop time in seconds before we end the script
// Note: The front end will wait 5 seconds, and then re-connect.

if( ! is_file( $fileName ) ) file_put_contents( $fileName, "Hello from the chat server!" ); // Controla que exista el fichero

header('Content-Type: application/javascript');
if( isset( $_REQUEST['msg'] ) ) {
    //    Only 1024 chars per chat
    $msg = substr( $_REQUEST['msg'], 0, 1024 );
    file_put_contents( $fileName, $msg );
    exit();
}

$serverTime = isset( $_REQUEST['ts'] )? $_REQUEST['ts']: 0;
$fileTime = filemtime( $fileName );

$started = time();
$timedOut = false;

// Loop till the file is modified or loop times out
while( ( $fileTime <= $serverTime ) && ( ! $timedOut ) ) {
    usleep(20000);    // sleep 20ms to unload the CPU
    clearstatcache();
    $fileTime = filemtime($fileName);
    $timedOut = ( ( time() - $started ) >= $maxTime );
}

// JSON response, only if we didn't time out
if( ! $timedOut ) {
    print $_REQUEST['callback'] . "(" . json_encode( array(
        "data" => file_get_contents( $fileName ),
        "ts" => $fileTime
    ) ) . ")";
}
else
    header("HTTP/1.0 204 No Content");

flush();
?>

Cómo funciona. Todo se basa en que el PHP del servidor está vigilando un archivo "compartido" que crea y que es usado por todos los clientes del chat. Cuando un cliente escribe un texto y lo manda al PHP, éste lo cobreescribe inmediatamente en el fichero. Como el resto de los clientes tienen sus propios procesos PHP a la escucha, en el momento que se detecta un cambio en la fecha del fichero con filemtime, automáticamente vuelcan el contenido del fichero a sus respectivos clientes mediante JSONP que están a la espera mediante el longpolling. De este modo en el fichero siempre está la última línea de texto escrita en el chat.

Ahora la parte cliente. Para su funcionamiento, se ha usado jQuery, y un plugin llamado jquery-longpoll-0.0.1.js que se encarga de la comunicación con el servidor y que a su vez necesita tener el plugin de jQuery de JSONP para funcionar.

/*
 jQuery Long Poll plugin 0.0.1 (02-05-2010)
 
 Note: requires jQuery-JSONP: http://code.google.com/p/jquery-jsonp/

 Copyright (c) 2010 JavaScript Guy

 This script is licensed as free software under the terms of the
 LGPL License: http://www.opensource.org/licenses/lgpl-license.php
 
 TODO: Implement a way to hide the spinner in FF and IE, possibly use an iFrame?
 

*/
(function($){
 
 $.longPoll = function( args ) {
  args = args || {};
  var self = $.extend( {
   url: '',    // URL To connect to
   tsParameter: 'ts',  // Parameter used for the timestamp
   reconnect: true,  // If we're automatically reconnecting
   errorTimeout: 60,  // Timeout for errors, eg: server timeout, execution time limit, etc.
   errorTimeoutDelay: 5, // Seconds to delay, if the connection failed in an error
   hasConnection: false, // If we have a connection
   timestamp: 0,
   error: false,
   request: null,
   reconnectFunc: function() {
    if( self.reconnect ) {
     self.disconnect();
     // Reconnect - with longer error timeout delay (in case we have an intermittent connection)
     if( self.error ) {
      setTimeout( function() { self.connect(); }, self.errorTimeoutDelay * 1000 );
     } else {
      setTimeout( function() { self.connect(); }, 100 );
     }
    }
   },
   connect: function() {
    self.reconnect = true;
    // self.request = $.ajax( {
    self.request = $.jsonp( {
     url: self.url + '?' + self.tsParameter + '=' + self.timestamp + '&callback=?',
           timeout: self.errorTimeout * 1000,  // Timeout and reconnect
           error: function(XHR, textStatus, errorThrown) {
      self.reconnectFunc();
      self.error = true;
           },
     dataType: "jsonp",
     success: function( data ) {
      self.timestamp = (data[self.tsParameter])? data[self.tsParameter]: self.timestamp;
      self.success( data );
      self.error = false;
     },
     complete: function( data ) {
      self.reconnectFunc();
     }
     
    } );
    self.hasConnection = true;
   },
   
   // Aborts request
   disconnect: function() {
    self.reconnect = false;
    if( self.request )self.request.abort();
    self.hasConnection = false;
   },
   
   // User defined
   success: function( data ) {}
   
  }, args );
  return self;
 };

})(jQuery);

La aplicación de chat en sí, tiene dos partes y está hecha como se deben hacer las aplicaciones en javascript mediante contextos, aunque generalmente somos demasiado vagos como para hacerlo. La primera parte encapsula la aplicación de chat que se encargará de realizar las peticiones JSONP mediante el plugin de longpolling.

// Chat app
var chatApp = function( url ) {
 
 var self = {
  connection: $.longPoll( {
   url: url,
   success: function( data ) {
    // Ignore server timeouts, the script will handle it.
    if( data['data'].indexOf( "Maximum execution time of" ) == -1 ) {
     jQuery('#content').append( '
' + data['data'] + '
' ); } } } ), // Sends a chat message sendChat: function(request) { jQuery.ajax( { url: url, type: "GET", dataType: "jsonp", data: { 'msg' : request } } ); }, // Toggle connection button toggleConnect: function( button ) { if( self.connection.hasConnection ) { self.connection.disconnect(); jQuery( button ).val("Connect"); } else { self.connection.connect(); jQuery( button ).val("Disconnect"); } } }; return self; };

Básicamente, lo que hace es implementar una clase que controla la conexión al chat, el envío de mensajes y la puesta en espera de nuevos mensajes del resto de los clientes. La segunda parte es el uso de la clase chatApp definida en la primera parte.

// Chat app initialisation
// var chatAppContext = chatApp( 'http://paxjs.com/labs/longpolljsonp/chat.php' );
var chatAppContext = chatApp( 'chat.php' );

var sendMessage = function() {
 chatAppContext.sendChat( jQuery('#message').val() );
 jQuery('#message').val('');
};

jQuery( '#connect' ).click( function() {
 chatAppContext.toggleConnect( jQuery( this ).get(0) );
} );

jQuery( '#sendMsg' ).click( sendMessage );
jQuery( '#message').keyup(function(e) {
 if(e.keyCode == 13)sendMessage();
});

Todo junto en jsfiddle y funcionando (pinchad en la pestaña de "Result" y pulsad el botón "Connect")


Las posibilidades de comunicación son infinitas ya que es realmente sorprendente la velocidad de respuesta ya que los cambios son instantáneos en todos los clientes.

Como conclusión, decir que la aproximación óptima debería ser mixta de modo que los clientes que soporten técnicas más modernas que no mantengan una conexión permanentemente abierta como websockets de HTML5 los usen y los clientes más viejos usen longpolling. Con la fusión de estas dos técnicas se soportaría el 100% de los navegadores del mercado y nos permitiría el desarrollo de aplicaciones de tiempo real con comunicación bidireccional cliente-servidor.

No hay comentarios:

Publicar un comentario