Member of The Internet Defense League Últimos cambios
Últimos Cambios
Blog personal: El hilo del laberinto Geocaching

Compresión "al vuelo" entre el servidor HTTP y el navegador

Última Actualización: 4 de Febrero de 2.000 - Viernes

Los navegadores modernos incorporan una funcionalidad poco conocida pero tremendamente útil: la posibilidad de recibir los datos que le envía el servidor HTTP en formato comprimido, descomprimiéndolos automáticamente de forma local. Ni que decir tiene que esta funcionalidad beneficia a ambos extremos: el servidor necesita menos ancho de banda para atender a un usuario, y el usuario recibe la información solicitada considerablemente más rápido.

Aunque ya conocía esta posibilidad desde hace mucho tiempo, no fue hasta Noviembre de 1.999 que me decidí a explotarla en un sistema en producción. Como suele ocurrir, la necesidad es la madre del desarrollo.

Motivación

Todo empezó a principios de Noviembre de 1.999, cuando uno de los clientes de Argo, la empresa en la que trabajo, acudió a nosotros para que le diésemos cobertura en un evento Internet en curso. Estaba teniendo problemas con su proveedor e intentaba migrar el desarrollo a nuestras infraestructuras. Existían dos problemas, no obstante:

  1. Se trataba de una migración "para ayer", ya que el evento estaba en curso.

    Ello suponía que no podíamos permitirnos el invertir una semana en implementar un desarrollo realmente adptado a las necesidades y al funcionamiento de Internet.

  2. Todo el desarrollo estaba hecho ya, lo heredábamos, y nuestra libertad de acción era prácticamente nula.

    El desarrollo heredado era pésimo desde una perspectiva Internet. Por ejemplo, suponía que decenas de usuarios simultaneos tendrían que descargarse una página HTML de más de 63Kbytes (decenas de miles de descargas al día). Obviamente ello nunca es práctico, pero mucho menos en un evento puntual con gran nivel de saturación.

La combinación de demasiados usuarios concurrentes y páginas demasiado grandes es un cóctel explosivo.

Fundamentos

Los navegadores modernos tienen la capacidad de recibir los datos en formato comprimido y descomprimirlos en local, todo ello de manera absolutamente transparente para el usuario. ¿Cómo sabe el servidor si puede enviar al cliente los datos en forma comprimida o no?. Sencillamente empleando lo que se denomina negociación de contenidos.

Cuando un navegador web se conecta a un servidor HTTP, envía a éste una gran cantidad de información, que éste puede utilizar para personalizar la respuesta en función de sus características: versión del navegador, idiomas favoritos, resolución y número de colores, etc. Veamos un ejemplo: (los navegadores antiguos proporcionan todavía más información)

Netscape Communicator:
GET /dds HTTP/1.0
Connection: Keep-Alive
User-Agent: Mozilla/4.7 [en] (Win98; I)
Host: www.argo.es
Accept: image/gif,image/x-xbitmap, image/jpeg, image/pjpeg, image/png, */*
Accept-Encoding: gzip
Accept-Language: es,en,gl
Accept-Charset: iso-8859-1,*,utf-8
Authorization: Basic ***********
Microsoft Internet Explorer:
GET /dds HTTP/1.1
Accept: application/vnd.ms-excel, application/msword, application/vnd.ms-powerpoint, image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, */*
Accept-Language: es
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 5.0; Windows 98; DigExt)
Host: www.argo.es
Connection: Keep-Alive

Como puede verse, los navegadores indican ostensiblemente qué formatos están más dispuestos a aceptar, los idiomas, etc.

Entre las cabeceras encontramos una especialmente interesante: "Accept-Encoding: gzip" y "Accept-Encoding: gzip, deflate". gzip y deflate son formatos de compresión de datos.

Si un servidor recibe una conexión desde un cliente que indica su disposición a utilizar compresión, puede responder de manera normal (lo más habitual) o bien puede enviarle el documento solicitado de forma comprimida, sin más que indicar en la cabecera de respuesta "Content-Encoding: gzip", por ejemplo.

Ojo con las cachés

A priori el asunto parece bastante sencillo: basta hacer pasar las peticiones por un CGI o una tecnología similar, y devolver al cliente los datos de forma normal o de forma comprimida sencillamente viendo si el cliente nos indica que es capaz de soportarlo.

Pero las cosas no son tan sencillas.

Probablemente el problema más grave y difícil de resolver en el tema de la negociación de contenidos es el hecho de que no siempre es posible, fiable o rentable asegurarse que todas las peticiones de los navegadores son servidos por un servidor, cuyas "versiones" de los documentos varían en función de las características del cliente. En muchos casos existen cachés intermedias que pueden resolver las peticiones de forma local, sin involucrar al servidor original. Y, obviamente, dichas cachés desconocen completamente la lógica tras la negociación de contenidos inicial.

Así, por ejemplo, es perfectamente posible que un cliente con capacidad de recibir datos de forma comprimida solicite un documento, y que éste quede almacenado en uno o más sistemas de caché intermedia a lo largo de la ruta de comunicaciones. Si otro cliente, éste sin capacidad de compresión, solicita el mismo documento, puede encontrarse con una versión comprimida del mismo, devuelto desde una caché.

El resultado es que el usuario visualiza una página con "basura" y, sencillamente, no puede acceder a la información deseada.

Existen varias formas de enfrentarse al problema, sin prescindir de la negociación de contenidos:

  1. Marcar el documento como "no-cache"

    Esta solución es la más sencilla, y nos asegura que todos los clientes recibirán una copia directa desde el servidor. Como contrapartida, el tráfico en el servidor se incrementa, y el usuario tarda más en recibir la información, ya que no puede ser proporcionada por una caché local.

    Esta opción es la más recomendable cuando se están sirviendo documentos generados de forma dinámica y que varían entre usuarios distintos, ya que los clientes -de todas maneras- tendrían que conectarse al servidor original para recibir sus versiones personalizadas.

  2. Tratar las conexiones desde cachés de forma distinta

    Si podemos identificar las conexiones desde cachés, podemos enviarles una versión descomprimida, válida para todos sus clientes.

    El problema de este esquema es que no siempre se puede saber a ciencia cierta si el cliente que se conecta al servidor es un cliente final o una caché. La mayoría de las cachés se identifican de manera más o menos clara con cabeceras tipo "Via", "X-Forwarded-For" o "Cache-Control" pero, ¿y si nos equivocamos?.

  3. Redirecciones

    Podemos tener un documento de entrada para discriminar el tipo de cliente (¿admite compresión o no?) y redirigirlo con una cabecera "Location" hacia la versión que más le convenga. La redirección debe hacerse de tal forma que no se haga caché de ella.

  4. Utilizar cabeceras "Vary"

    Ésta sería la mejor opción de todas si no fuera porque no está implementada en la mayoría de las cachés actuales, aunque espero que esta situación cambie pronto.

    Con las cabeceras "Vary" el servidor informa de qué cabeceras del cliente depende el contenido del documento que se está sirviendo. De esta forma se marca explícitamente qué versiones de documentos existen y de qué cabeceras depende su elección.

    Lamentablemente, muchas cachés consideran que un documento con cabeceras "Vary" sencillamente no es apto para ser almacenado, lo que a efectos prácticos implica un "no-cache" implícito.

    Es de esperar que la situación cambie poco a poco.

  5. Utilizar negociación dirigida por el cliente

    En algunas ocasiones un cliente es capaz de seleccionar una URI automáticamente y de forma transparente, en función de una selección planteada por el servidor. Lamentablemente la mayoría de los navegadores no parecen contemplar esta posibilidad.

  6. Negociación transparente entre la caché y el cliente final Si la caché es lo bastante lista, puede conservar una copia del documento comprimido, que empleará para satisfacer las peticiones de los navegadores que admitan compresión. Si la caché recibe una petición por parte de un navegador antiguo, sencillamente puede descomprimir el documento "al vuelo" mientras se lo envía.

    En el momento de escribir este artículo no conozco ninguna caché con esta funcionalidad.

Ejemplo completo en PHP y SHELL

Lo que sigue es nuestra implementación para el proyecto del cliente referido al principio de este documento. Consta de dos programas, uno escrito en PHP y otro escrito en SHELL. El primero de ellos es invocado por el servidor HTTP, y realiza todo el trabajo duro. El segundo programa sencillamente se encarga de hacer limpieza de los ficheros temporales que se hayan quedado huérfanos.

  • Código PHP de difusión de datos comprimidos

    <? 
    
    function salida($idioma) {
    global $HTTP_REFERER;
    global $HTTP_ACCEPT_ENCODING;
    
    set_time_limit(0);
    
    $HORA=date("Y-m-d H:i:s", time());
    
    $cadena="<input type=\"hidden\" name=\"referer\" value=\"".$HTTP_REFERER."\">\n";
    $cadena=$cadena."<input type=\"hidden\" name=\"entrada\" value=\"".$HORA."\">\n";
    
    $HTTP_ACCEPT_ENCODING=" ".$HTTP_ACCEPT_ENCODING;
    
    if(!strpos(strtolower($HTTP_ACCEPT_ENCODING),"gzip")) {
      readfile($idioma."-pre");
      echo $cadena;
      readfile($idioma."-post");
    } else { // GZIP
      header("Content-Encoding: gzip");
      
      $fich="/export/home/usuarios/b/CLIENTE/enc/tmp/encuesta99-".md5(microtime().getmypid());
    
      $err=error_reporting(0); // ?BUG PHP?
      $id=gzopen($fich,"wb9"); 
      error_reporting($err);
    
      $fich2=$idioma."-pre";
      $id2=fopen($fich2,"rb");
      gzwrite($id,fread($id2,999999));
      fclose($id2);
      gzwrite($id,$cadena);
      $fich2=$idioma."-post";
      $id2=fopen($fich2,"rb");
      gzwrite($id,fread($id2,999999));
      gzclose($id); 
      readfile($fich);
      unlink($fich);
    }
    }
    
    ?>
    

    En este caso, necesitábamos recomprimir el documento a enviar en cada caso, porque incluye campos personalizados para cada cliente en concreto. Si la página fuera completamente estática, bastaría con tener en disco dos versiones de la página, una comprimida y otra normal, y enviar el cliente la versión que más le convenga.

    Debido también al hecho de que la página se genera de forma dinámica, no incluye fecha de modificación, por lo que implícitamente se le dá un "no-cache", tal y como se describió en la sección anterior.

    Básicamente, la página a servir se divide en tres secciones. La sección intermedia es la sección que se genera de forma dinámica. La sección anterior y posterior son estáticas.

    Cuando se recibe una conexión, se construye la parte variable (la variable "$variable" en el código anterior), y se verifica si el cliente acepta o no documentos comprimidos.

    Si no es así (navegador antiguo), sencillamente se le envía el documento en las tres partes que lo forma: prefijo, parte variable y sufijo.

    Si, por el contrario, el cliente acepta datos comprimidos, se le envía un "Content-Encoding: gzip" y se genera un fichero temporal que contendrá el documento en formato comprimido. El nombre de ese fichero se calcula de forma pseudoaleatoria, ya que cada petición concurrente debe tener su propio fichero de datos. Una vez generado dicho fichero, se envía al cliente y se borra del disco.

    Algunos detalles a tener en cuenta:

    • Como el documento no tiene fecha de modificación, implícitamente es "no-cache"

    • Si un cliente aborta la recepción del documento, Apache termina la ejecución del script PHP de forma abrupta. Ello provoca, entre otras cosas, que no se borre el fichero temporal que se ha generado. Es por ello por lo que se necesita también el script que adjunto más abajo.

    • Es necesario generar un documento temporal porque, lamentablemente, la versión PHP utilizada no permite comprimir una cadena directamente en memoria. Sólo permite leer o escribir datos comprimidos desde/hacia disco.

    • Es necesario usar el "set_time_limit(0)" porque si un cliente tarda más de 30 segundos en recibir el documento, el PHP matará el script por considerarlo en un bucle. Naturalmente, para que "set_time_limit(0)" funcione, debemos tener deshabilitado el "safe_mode" del PHP, lo que puede suponer un compromiso de seguridad importante, si el sistema no está configurado de forma correcta.

    • Adicionalmente, la versión utilizada del PHP parece tener un bug que provoca que al intentar crear el fichero temporal comprimido, se visualice un error, aunque la operación tenga éxito. Esa es la razón de los "error_reporting()".

    • Finalmente, el algoritmo de compresión GZIP permite comprimir de forma separada varios ficheros, y enviarlos todos de una vez uno detrás de otro. Según esto, se podría haber comprimido de forma separada los ficheros "pre" y "post", y sólo realizar compresión de la parte variable de cada conexión. Lamentablemente las pruebas de campo mostraron que muchos navegadores sólo descomprimen el primer segmento de datos GZIP, no todos los datos que le llegan.

      Se trata de un bug del navegador, pero no hay mucho que nosotros podamos hacer al respecto.

  • Código Shell de limpieza

    #!/bin/sh
    #
    # Borra los ficheros con mas de una hora
    
    cd /export/home/usuarios/b/CLIENTE/enc/tmp
    
    fecha=`/usr/local/bin/awk 'BEGIN {a=strftime("%b +%e +%H:"); print a}'`
    
    /usr/bin/rm -f `/usr/bin/ls -lA| /usr/bin/grep " encuesta99-" | \
      /usr/bin/egrep -v "$fecha"|/usr/local/bin/awk '{print $NF}'`
    

    Este sencillo script se ejecuta vía CRON en el minuto 55 de cada hora, y elimina los ficheros temporales que se hayan creado (y no borrado) durante la hora anterior. Es decir, elimina los ficheros que tengan más de 55 minutos de antigüedad, bajo el supuesto de que cualquier transferencia de más de 55 minutos ha terminado ya hace tiempo.

    No debería ocurrir nunca, pero es bastante frecuente que el script PHP deje ficheros temporales de vez en cuando en disco, debido a que Apache termina su ejecución de forma prematura.

Estadísticas

Obviamente, los datos de la experiencia de Noviembre de 1.999 son privados y están protegidos por la política de confidencialidad que tenemos con nuestros clientes. No obstante podemos mostrar alguna conclusiones, avaladas por números, de los beneficios que nos reportó esta técnica:

  • Tamaño de los datos sin comprimir: aproximadamente 66Kbytes

  • Tamaño de los datos comprimidos: aproximadamente 11Kbytes

  • Versiones de navegadores que implementan GZIP:

    • Microsoft Internet Explorer 4.0 o superior

    • Netscape Navigator 4.07 o superior

  • Porcentaje de accesos con capacidad de compresión: 21%

    Evidentemente, esta cifra aumentará a medida que los usuarios vayan actualizando sus navegadores.

Bibliografía

Historia

  • 04/Feb/00: Primera versión de este documento.



Python Zope ©2000 jcea@jcea.es

Más información sobre los OpenBadges

Donación BitCoin: 19niBN42ac2pqDQFx6GJZxry2JQSFvwAfS