12 de Junio, 2006

Respuestas HTTP condicionales

Estoy muy contento con los cambios realizados al RSS de esta bitácora. No solo porque funciona ;), sino porque implementar el soporte de If-Modified-Since se me antojaba mucho más complicado de lo que al final ha resultado.

La técnica es bastante sencilla: en las peticiones HTTP 1.1 puede aparecer una cabecera que nos indica que el recurso pedido debe servirse sólo si se ha modificado desde una fecha dada.

Esta bitácora tiene un periodo de actualización medio de una vez al día (o cada varios días, depende :P), pero los agregadores suelen configurarse para consultar las fuentes una vez cada hora. Así que devolver siempre el mismo fichero RSS, que no se ha modificado, es un desperdicio evidente si podemos indicar en su lugar que el fichero no ha cambiado.

¿Qué diferencia supone en cifras? Bueno, antes mostrar un poco de código, voy a repasar un poco los logs de esta bitácora, del día 11 de Junio concretamente, para ver qué tal va funcionando mi implementación.

Para ir más rápido, primero guardo en un fichero aparte la actividad del día en cuestión:

$ grep "11/Jun/2006" < /var/www/logs/blog-access_log  > jun11.log

Las respuestas normales devuelven el código HTTP 200, y las del tipo contenido no modificado se indican con el código 304. Mientras que en el primer caso se registra la cantidad de bytes enviada, sin cabeceras (solo el tamaño del recurso), en el segundo no se envía contenido, con lo que no hay cantidad :).

Si despreciamos el tamaño de las cabeceras (son pocos bytes), podemos emplear el siguiente script AWK para analizar el log:

BEGIN { cm=0; bm=0; cnm=0; }

/GET\ \/(blackshell.rss|rss.php)\ HTTP/ {
        if($9=="200") { cm++; bm=bm + $10 }
        if($9=="304") { cnm++; }
}

END {
        print "Total de peticiones: " (cm + cnm);
        print "Respuesta 200: " cm " (" bm " bytes, " (bm/cm) " bytes/petición)";
        print "Respuesta 304: " cnm;
}

Es muy sencillo. Empleamos tres variables: cm (cuenta respuestas 200), bm (bytes enviados en respuestas 200) y cnm (cuenta respuestas 304). Al ejecutarlo contra el registro del 11 de Julio, tenemos:

$ awk -f procesa.awk < jun11.log
Total de peticiones: 808
Respuesta 200: 385 (4953410 bytes, 12866 bytes/petición)
Respuesta 304: 423

En efecto, ese día no se modificó el RSS, así que su tamaño fue siempre el que aparece de media por petición (12866 bytes). Si hay 423 resuestas del tipo no modificado, podemos decir que el ahorro ha sido de 5442318 bytes que no fue necesario enviar. Esto, en mi caso, con un número de visitas modesto y sirviendo las páginas desde mi conexión de casa, no es tampoco muy importante (algo sí, que tengo el ancho de banda de subida muy limitado :P), pero para casos en los que pagamos un hospedaje y nuestra bitácora tiene más éxito, puede suponer una diferencia al mes.

La implementación es sencilla, aunque yo tuve que hacer algunos cambios en el CMS. Por ejemplo: no guardaba la fecha de las modificaciones en las anotaciones, que resulta necesaria para saber cuándo se debe dar una nueva versión del RSS.

Aún con todo, fue fácil :). La estrategia es la siguiente:

Si existe la cabecera If-Modified-Since
    Si la fecha de la última modificación = If-Modified-Since
        Devuelve respuesta 302 y termina

Respuesta 200 con contenido y Last-Modified a fecha última modificación

El único problema es que necesitamos una fecha, y además que esté en formato GMT idéntico al que emplea HTTP (por ejemplo: Sat, 10 Jun 2006 16:42:27 GMT). En mi caso tuve que hacer una corrección de 2 horas porque almaceno la fecha en el curso horario local, no en GMT, pero para eso MySQL tiene DATE_FORMAT y DATE_SUB (lástima que no hayan anclas a las funciones). Así podemos obtener la fecha en formato adecuado desde el mismo query, si suponemos en actualizado la fecha de la actualización de cada anotación:

-- en mi caso, que guardo las fechas en CEST --
select date_format(date_sub(actualizado, interval 2 hour), '%a, %d %b %Y %T GMT')
from tblNoticia order by actualizado desc limit 1;

-- si guardamos las fechas en GMT --
select date_format(actualizado, '%a, %d %b %Y %T GMT')
from tblNoticia order by actualizado desc limit 1;

Esto claramente es un hack, y no de los buenos. Con ese query obtenemos la fecha de la última actualización de la bitácora, y a nosotros solo nos interesan las últimas actualizaciones de las 10 anotaciones más recientes (que en mi caso son las que aparecen en el RSS). Es un mal menor porque no ando editando anotaciones viejas ;), pero si podemos hacer una subconsulta, podemos solucionarlo fácil:

-- en mi caso, que guardo las fechas en CEST --
select date_format(date_sub(actualizado, interval 2 hour), '%a, %d %b %Y %T GMT')
from tblNoticia where id in (select id from tblNoticia order by id desc limit 10)
order by actualizado desc limit 1;

No he probado esa posibilidad porque en blackshell trabajo con MySQL 4.0.24, y no dispongo de subconsultas (recordemos lo de ir siempre ha cia adelante). Tampoco es tan terrible, pero que conste ;).

Ahora la comprobación de la cabecera contra la fecha obtenida:

<?php /* uso mi encapsulación OO de las llamadas a MySQL */
$sql=new sql();

/* obtenemos la fecha -supongo GMT-, además perfectamente formateada */
$lm_res=$sql->query(
	"select date_format(actualizado, '%a, %d %b %Y %T GMT')"
	." from tblNoticia order by actualizado desc limit 1"
	);

/* si tenemos que mandar el RSS, necesitamos la fecha de la última modificación
   para la cabecera de Last-Modified */
$lm=$sql->result($lm_res, 0);

/* hay que ser educado y liberar recursos ;) */
$sql->free($lm_res);

/* necesitamos todas las cabeceras para inspeccionar If-Modified-Since */
$headers=getallheaders();

/* ¿el cliente usa If-Modified-Since? */
if(isset($headers["If-Modified-Since"]))
	/* bien, ¿es la fecha que tenemos? */
        if($lm==$headers["If-Modified-Since"])
        {
        	/* bien! contenido no modificado, tenga un buen día */
                header("HTTP/1.1 304 Not Modified");
                $sql->close();
                exit;
        }

/* ...continua... */ ?>

Ahora generaríamos el RSS normalmente, enviando en primer lugar un par de cabeceras, una con la fecha de la última modificación y la otra indicando el tipo MIME (eso ya lo hacía, ojo :D):

<?php /* empleamos la fecha almacenada antes */
header("Last-Modified: " .$lm);
header("Content-Type: application/rss+xml");
header("Content-Disposition: attachment; filename=blackshell.rss");

/*...generamos el RSS...*/ ?>

Como se puede apreciar no es nada complicado realizar una respuesta HTTP condicional, y estoy seguro que la mayoría de los CMS del mercado lo implementan. No obstante saber cómo funciona puede sernos de utilidad, ya sea porque hemos programado nuestro propio gestor de contenidos, o porque devolvemos contenido dinámico al que se accede con más frecuencia de la que cambia y no queremos desperdiciar ancho de banda ;).

Anotación por Juan J. Martínez, clasificada en: programming, hacks, awk, php, blog.

Hay 2 comentarios

Gravatar

No está mal chequear que la fecha de If-Modified-Since sea igual a la de la última entrada (que es lo que se está haciendo), sin embargo, la semántica de If-Modified-Since es justamente si se ha modificado desde esa fecha. Por lo que un lector rss podría tranquilamente enviar el header If-Modified-Since con la fecha no de la última modificación, sino de la última consulta exitosa al rss.

A lo que voy es que la condición debería ser lm <= If-Modified-Since en vez de ==. Puede ahorrar aún un poco más de ancho de banda, si es que existe algún rss con ese comportamiento (que repito de nuevo, no sería incorrecto dado la semántica de If-Modified-Since).

Por supuesto para eso necesitarás un poco de magia php con los formatos de las fechas, pero es perfectamente realizable.

Espero que les sirva a todos.

por Des, en 2006-06-12 15:17:51

Gravatar

Podría ser como dices, aunque entonces con tu solución (<= en lugar de ==) sí se complicaría la comparación de fechas (ya no buscamos identidad, y en ese caso no es tan fácil como comprar cadenas).

He comprobado que los agragadores que están funcionando bien con esta implementación no verifican el campo pubDate general del RSS, sino la última respuesta indicada en Last-Modified (tiene sentido ya que hablamos de cabeceras del protocolo de transporte, no de los datos), así que aunque se dé el comportamiento que describes (me parece probable), solo supondría una respuesta completa de más... la siguiente ya sería no modificado.

Pero vamos, muy buen comentario. Gracias :)

por Juanjo, en 2006-06-12 16:13:11

Los comentarios están cerrados: los comentarios se cierran automáticamente una vez pasados 30 días. Si quieres comentar algo acerca de la anotación, puedes hacerlo por e-mail.

Algunas anotaciones relacionadas: