Control de versión con revalidación desde caché

Problemas al cachear los documentos HTML

Cómo funciona la caché web

No hay ninguna duda de que tenemos que implementar una caché web. Esta implementación no es díficil. Lo más complejo es configurar que recursos se cachean y con que períodos. En el enlace anterior comenté que iba a cachear los documentos HTML, CSS y JS, así como, por supuesto, las imágenes. Muchos recomiendan no cachear el HTML, pero es una lástima no aprovechar la ventaja de la caché también para estos documentos. Así que sigo empeñado en cachearlos pero tengo que arreglar el problema de las modificaciones y actualizaciones de un documento y/o de su recursos vinculados como los CSS y JS. Dos de estos archivos básicos son base.css y general.js que se usan en todos los HTML del sitio.

Mis documentos HTML con temas se cachean con un período semanal. Una vez cacheado, el navegador lo servirá desde ahí a no ser que el usuario recargue la página. En este caso y también cuando sobrepase la fecha de caducidad, el navegador enviará una consulta If-Modified-Since. Si recibe un código de estado 304 es que la página no se ha modificado y vuelve a usar el contenido en caché. En otro caso el servidor le envía un 200 y el navegador actualiza su caché y sirve el nuevo contenido. El problema es que durante el período de cacheado de una semana el navegador no consulta si hay una nueva versión disponible.

El archivo base.css contiene todo el estilo genérico del sitio. Para evitar bloqueos CSS no lo vinculo en las páginas con el clásico <link rel="stylesheet"...>. Ese archivo está seccionado en componentes que PHP se encarga de inyectar de forma particular para cada documento, tal como explico en el tema CSS-FOLD: Priorizar CSS "above the fold". El problema es que el estilo inyectado queda dentro de elementos <style> en el documento HTML. Si el navegador ha cachedado la página no solicitará de nuevo la misma hasta que pase el período de caducidad de una semana. Tras ese período el navegador hará una consulta If-Modified-Since y el servidor le dará la fecha de modificación del documento HTML. Pero si lo que yo he modificado es el archivo base.css y no el documento HTML, la fecha de modificación de éste seguirá siendo la misma y el servidor le devolverá un código de estado 304 con lo cual el navegador volverá a usar la versión de su caché cuyo estilo CSS inyectado es ahora obsoleto.

Otro problema sucede con general.js que contiene utilidades genéricas y otras complementarias en la carga de la página. Se vincula en el documento HTML con el clásico <script src="/res/inc/general.js" async>, pero si este JS está cacheado no lo recuperará hasta la fecha de caducidad (le he puesto también una semana igual que para los HTML). Aunque ahora la ventaja es que ante una consulta If-Modified-Since el servidor ya si podrá servirle el nuevo general.js si fue modificado. Pero durante la semana de cacheado las modificaciones en este JS no llegarán al navegador. A excepción de que el usuario recargue manualmente la página, en cuyo caso el navegador solicitará de nuevo y/o consultará la validez de su caché con If-Modified-Since para todos los recursos de la página.

Para arreglar estos problemas necesito hacer lo siguiente:

  1. Enviar como cabecera de fecha de modificación del recurso HTML la más reciente entre el propio HTML y el recurso base.css para arreglar el problema de contenidos CSS inyectados en el HTML y cacheados que no resultan detectados con una consulta If-Modified-Since.
  2. Implementar un control de versiones asociado con alguna técnica para revalidación desde caché, lo que intentará arreglar el problema del propio documento HTML o su vinculado general.js servidos desde caché, no caducados, aunque sobre los cuales hay una nueva versión pero que el navegador no ha solicitado dado que no se ha vencido el periodo de cacheado.
Como muchas cosas que implemento en este sitio, esta técnica es algo experimental. Aunque he probado esto y veo que funciona, es posible que algunas cosas se me escapen y se presenten problemas en ciertos casos. Pero toda mejora supone aceptar la posibilidad de cometer errores. Es el precio que tenemos que pagar si queremos evolucionar.

Servir cabeceras de fecha de modificación de un recurso

Caché web con Cache-Control y Last-Modified

El servidor envía la cabecera Last-Modified para notificar al navegador de la última fecha de modificación del recurso. Así el navegador podrá compararla con la que tiene en su caché y saber si esa es una versión más reciente. El servidor Apache envía la fecha de modificación de cada recurso, es decir, la fecha de modificación del archivo tal como está registrada en el sistema de archivos del sistema operativo. Pero como comenté en el apartado anterior, si inyecto en un documento HTML contenidos de otro archivo CSS y es éste el que tiene una modificación posterior, entonces he de cambiar la fecha de modificación que se envía al navegador.

Con PHP podemos enviar una cabecera Last-Modified. En el tema sobre la caché web que antes indiqué explico como uso el script PHP prehjc.php para preprocesar algunas tareas antes de enviar el documento al navegador. A continuación se expone el código de ese script, pero quitando algunas líneas de código y comentarios que ahora no vienen al caso. Además tenga en cuenta que aquel enlace conduce al código actual que puede diferir del presentado a continuación por motivos de actualizaciones y mejoras.

<?php
$url = $_SERVER["PHP_SELF"];
$ruta = pathinfo($url);
$extension = $ruta["extension"];
$time = 0;
$fecha_doc_num = 0;
if ($extension == "html"){
    header("Content-Type: text/html; charset=utf-8");
    //1 semana
    $time = 604800;
} else if($extension == "css") {
    header("Content-type: text/css");
    //1 mes
    $time = 2592000;
} else if($extension == "js") {
    header("Content-type: text/javascript");
    //1 semana
    $time = 604800;
}
if ($time > 0){
    $ruta_doc = $_SERVER["DOCUMENT_ROOT"].$url;
    $fecha_doc_num = filemtime($ruta_doc);
    if ($extension == "html"){
        $recursos = array("/res/sty/base.css", "/res/inc/general.js");
        $fechas = array($fecha_doc_num);
        for ($i=0,$maxi=count($recursos); $i<$maxi; $i++){
            $fechas[] = filemtime($_SERVER["DOCUMENT_ROOT"].$recursos[$i]);
        }
        $fecha_doc_num = max($fechas);
    }
    $fecha_doc = gmdate("D, d M Y H:i:s", $fecha_doc_num)." GMT";
    $si_modificado_desde = "";
    if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
        $si_modificado_desde = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
    }
    $cad_cache = "max-age=".$time.", private";
    header("Cache-Control: ".$cad_cache);
    header("Last-Modified: ".$fecha_doc);
    if ($si_modificado_desde == $fecha_doc){
       header("Vary: Accept-Encoding");
       header($_SERVER["SERVER_PROTOCOL"]." 304 Not Modified");
       exit;
    }
}
?>

Ese script se usa para enviar cabeceras de caché para los recursos HTML, CSS y JS. Recuperamos la fecha de modificación del archivo con la función de PHP filemtime(). Pero ahora introduzco la mejora resaltada en amarillo. Se trata de buscar el máximo de las fechas de modificación entre el HTML y los recursos base.css y general.js. El máximo será el más reciente y es el que enviaremos al servidor.

Esto arreglará el problema de contenidos CSS del archivo base.css inyectados en el HTML cuando el archivo CSS se modifique. No arreglará el problema de nuevas versiones del propio documento HTML o del vinculado general.js, por lo que en principio sólo tendríamos que comprobar la fecha más reciente entre el documento HTML y base.css, pero agregué también general.js dado que en el control de versiones que veremos en los siguientes apartados usaremos un código exactamente igual a este resaltado en otro script PHP distinto.

Servir contenido de caché caducado con stale-while-revalidate

Antes de poner manos a la obra en busca de una solución para el control de versiones con revalidación desde caché indagué para ver si ya existía algo que me pudiera ayudar. La especificación HTTP 1.1 en su apartado 13.1.1 Cache Correctness viene a decir que en determinadas circunstancias el navegador puede servir el contenido caducado. Esas circunstancias pueden ser la no posibilidad de comunicación con el servidor o cuando éste le devuelve un código de estado que no le permite chequear la validez del contenido en caché (como un código de estado 5xx).

El problema es que cuando el contenido está caducado, el navegador hará una consulta If-Modified-Since y esperará por una respuesta 304 o 200, lo que consume un tiempo durante el cual el navegador debe esperar necesariamente, es decir, bloquea el navegador. Una idea que se propone para evitar este bloqueo es servir contenido caducado y realizar en segundo plano esa consulta y, si fuera el caso, actualizar el contenido de caché para que en la siguiente petición tengamos ya en caché un contenido actualizado.

Desde el año 2010 existe una especificación HTTP Cache-Control Extensions for Stale Content para controlarlo, donde se propone agregar la nueva cabecera stale-while-revalidate. En resumen viene a decir que el navegador sirva contendido caducado mientras revalida el recurso. Su autor Mark Nottingham lo explica en su blog Two HTTP Caching Extensions. No es una especificación obligatoria y los navegadores no lo incorporan. En mayo de este año 2014 Chrome está considerando agregarlo al funcionamiento del navegador, tal como explica este post de Ilya Grigorik titulado Chrome is considering adding stale-while-revalidate support.

Aunque estuviese implementada esa mejora tampoco vendría a solucionar mi problema sobre los documentos HTML y otros recursos que se cachean con un período semanal. Pues si el navegador sirviera el HTML caducado desde caché y realizara una consulta en segundo plano para actualizar la caché, una vez actualizada es posible que el usuario no vuelva a requerir ese documento en corto espacio de tiempo. O incluso, una vez leída la página no vuelva a acceder a ella posteriormente, con lo que habría leído una versión obsoleta.

Esta mejora es útil para recursos con una duración de vida muy corta, cuestión de segundos y que se están requeriendo de forma repetitiva en el documento. Por ejemplo, un componente que cada 10 s recuperara contenido que a su vez cambia en origen con una frecuencia de 10 s también. Podríamos entonces enviar el recurso con Cache-Control: max-age=10, stale-while-revalidate=10. En una solicitud se serviría contenido caducado a la vez que actualiza la caché en segundo plano. En la siguiente petición tendría contenido actualizado antes. Y así sucesivamente con lo que lograremos no bloquear el navegador en ninguno de los pasos.

Control de versiones con revalidación desde caché

Comprobar versión caché

Como no he encontrado algo que me pueda solucionar este problema, implementaré un control de versiones con revalidación desde caché usando PHP y JavaScript con XHR. En primer lugar necesitaré incorporar la fecha de la versión de cada documento HTML. Con PHP y mediante el script de pre-ejecución prehjc.php comentado más arriba inyectaré algo como lo siguiente en el <head> de todos los documentos HTML:

<script>var wxZ = {f:"25-07-2014-20-30-28"};</script>

Esa fecha-hora será la mayor entre los archivos base.css, general.js y el propio documento HTML. En un proceso de carga en el navegador puede suceder que la página no hubiera sido solicitada antes, en cuyo caso hará una petición al servidor devolviendo un código de estado 200 y guardaría en caché estos recursos. En una petición posterior con la caché caducada enviará una consulta If-Modified-Since. Si se obtiene un 304 es que la página no se modificó. En estos dos casos nuestro control de versiones no debe hacer nada, pues el HTML se está recuperando o consultando en el servidor.

El control de versiones debe entrar en acción durante el período de caducidad, que es cuando el navegador recupera el HTML desde la caché. No he podido encontrar alguna manera de verificar con JavaScript cuando un documento se obtiene desde caché y he tenido que echar mano del tiempo de carga. La idea es que si para una página se tarda menos de 100 ms en obtener la respuesta es que lo está haciendo desde caché. Los 100 ms son más que suficientes para obtener la respuesta desde caché y en todo caso resulta improbable que una página en mi sitio cargue antes de ese tiempo en un petición al servidor. No me gusta del todo esta solución, pero no he podido encontrar otra cosa.

En el navegador tendremos la función comprobarCache() que ejecutaremos desde el window.onload. El diagrama de flujo anterior esquematiza este código.

/* COMPROBAR CACHE:
Los HTML se cachean. Esta función se ejecuta en la carga para comprobar si el contenido
que se recupera de caché es de una versión inferior al original, para lo cual se hace una
petición XHR a cache-date.php?u=URL siendo URL la ruta del HTML. Se recupera la fecha más
reciente entre este documento, base.css y general.js, pues estos dos pueden modificar el
documento. Si hay versión más reciente se recarga la página automáticamente, a excepción
de algunas páginas (como herramientas) que se solicita al usuario la recarga.
*/
comprobarCache: function(){
    //En los navegadores que soporten window.performance podemos medir el tiempo que transcurre
    //desde el inicio de la petición hasta que se recibe la respuesta. Si se hace desde caché
    //ese tiempo será insignificante comparado con una petición al sitio en producción con un
    //código 200 o incluso 304, en cuyo caso comprobaremos la cache. Si el tiempo es superior
    //entonces será un 200 o 304 y no hará falta comprobar la cache. Si no se soporta performance
    //comprobaremos siempre la caché.
    var tiempoRespuesta = 0;
    var tiposNav = {0:"NAVIGATENEXT",1:"RELOAD",2:"BACK_FORWARD",255:"UNDEFINED"};
    var tipoNavega = "?";
    try {
        var wpt = window.performance.timing;
        tiempoRespuesta = wpt.responseEnd - wpt.fetchStart;
        tipoNavega = tiposNav[window.performance.navigation.type];
    } catch(e){}
    //Con esta actualización ahora habrá en un script inicial inyectado con PHP en cada
    //página wxZ.f = d-m-Y-H-i-s con la fecha de la versión del documento. Para no tener
    //que modificar todas las páginas, las que no tengan esa fecha le pondremos una fecha
    //del pasado para forzar la actualización
    var af = [1,1,2001,0,0,0];
    if (typeof wxZ !== "undefined") af = wxZ.f.split("-");
    var fechaDoc = new Date(af[2],af[1]-1,af[0],af[3],af[4],af[5],0);
    var fechaDocPres = wxG.formatoFecha(fechaDoc," ","00","aaa","0000",":","00","00","00"," ");
    //Esto es para los elementos que se crean como mensajes
    var contenido = document.getElementById("contenido");
    //Menos de 100 ms para detectar que se recuperó de caché, en cuyo caso comprobamos caché.
    //Esto tengo que revisarlo ¿es posible 200 o 304 en menos de 100ms desde producción?
    //¿Cuál es el máximo recuperando desde caché?
    if (tiempoRespuesta<100) {
        var req;
        if (window.XMLHttpRequest){
            req = new XMLHttpRequest();
        } else {
            req = new ActiveXObject("Microsoft.XMLHTTP");
        }
        if (req){
            var ruta = "/res/srv/cache-date.php?u=" + window.location.pathname;
            req.open("GET", ruta, true);
            req.onreadystatechange = function(){
                if (req.readyState == 4) {
                    if (req.status == 200){
                        var text = req.responseText;
                        if (text != ""){
                            af = text.split("-");
                            if (af.length == 6){
                                var fechaOrigen = new Date(af[2],af[1]-1,af[0],af[3],af[4],af[5],0);
                                if (fechaOrigen > fechaDoc){
                                    var controlCache = document.createElement("div");
                                    controlCache.className = "mensaje-cache";
                                    controlCache.style.cssText = 'text-align:right; ' +
                                    'margin: 0.2em auto 0 auto; ' +
                                    'max-width: 718px; ' +
                                    'min-width: 285px; ' +
                                    'font-family: Arial, Helvetica, sans-serif; ' +
                                    'color: maroon; ' +
                                    'font-weight: bold; ' +
                                    'max-width: ' + contenido.style.maxWidth + ';"';
                                    controlCache.innerHTML = "Hay una nueva versión de esta página: " +
                                        '<button type="button" class="btntxt" ' +
                                        'style="color: yellow; ' +
                                        'font-weight: bold; ' +
                                        'background-color: maroon; ' +
                                        'padding: 0.2em 0.4em; ' +
                                        'border: rgb(49, 99, 98); ' +
                                        'border-radius: 0.35em; " ' +
                                        'onclick="window.location.reload(true)" ' +
                                        '>Actualizar</button>';
                                    contenido.insertBefore(controlCache, contenido.firstChild);
                                 }
                            }
                        }
                    } else {
                        //No mostramos errores, porque cuando no hay conexión saldría este error
                    }
                }
            };
            req.send();
        }
    }
    //Agregamos una frase de traza al final del body
    var traceSpeed = document.createElement("div");
    traceSpeed.style.cssText = "color: darkgray; text-align: center;";
    traceSpeed.style.maxWidth = contenido.style.maxWidth;
    var tresp = "?";
    if (tiempoRespuesta > 0) tresp = tiempoRespuesta;
    traceSpeed.innerHTML = 'Versión: ' + fechaDocPres + '; ' +
                           'Tiempo respuesta: ' + tresp + ' ms; '+
                           'Tipo navegación: ' + tipoNavega;
    document.body.appendChild(traceSpeed);
}

Para medir el tiempo usaré el window.performance de la nueva API Navigation Timing, con la que podemos consultar var wpt = window.performance.timing. Haciendo una simple resta wpt.responseEnd - wpt.fetchStart obtenemos el tiempo en el que se completó la respuesta.

En Julio 2014 todos los navegadores actuales a excepción de Safari y Opera Mini soportan Navigation Timing (puede ver soporte en Caniuse). Para los que no lo soporten se ejecutará siempre la consulta XHR, pero es una acción asíncrona y no perjudica excesivamente en la carga de la página.

En el caso de una respuesta menor de 100 ms haremos una consulta al servidor para obtener la fecha de última modificación del recurso. Dispondré del siguiente script PHP para este cometido (cuyo original es cache-date.php):

<?php
header("Cache-Control: no-store, no-cache, must-revalidate");
header("Expires: Thu, 19 Nov 1981 08:52:00 GMT");
header("Pragma: no-cache");
//Ponemos una fecha antigua para sacar alguna si hay error
$fecha = "01-01-2001-00-00-00";
if (isset($_GET["u"])){
    $ruta = $_GET["u"];
    //filtramos un poco la ruta
    $longi = strlen($ruta);
    if (($longi>0)&&($longi<200)&&(!preg_match("#(?:[^\w\.\-/]|\.{2,}|/{2,})#", $ruta))){
        if ($ruta[$longi-1] == "/") $ruta .= "index.html";
        $fecha_doc_num = 0;
        //Si el documento no existe saltará esta línea
        $fecha_doc_num = @filemtime($_SERVER["DOCUMENT_ROOT"].$ruta);
        if ($fecha_doc_num > 0){
            $recursos = array("/res/sty/base.css", "/res/inc/general.js");
            $fechas = array($fecha_doc_num);
            for ($i=0,$maxi=count($recursos); $i<$maxi; $i++){
                $fechas[] = filemtime($_SERVER["DOCUMENT_ROOT"].$recursos[$i]);
            }
            $fecha_doc_num = max($fechas);
            $fecha = gmdate("d-m-Y-H-i-s", $fecha_doc_num);
        }
    }
}
echo $fecha;
?>

La ruta del documento HTML vendrá en un GET. Usando la función de PHP filemtime() obtenemos la fecha de su última modificación así como la de los recursos asociados base.css y general.js, devolviendo la fecha mayor de las tres. Vea que el trozo de código resaltado es igual que el que puse más arriba y que hace la comprobación en el sript prehjc.php de pre-ejecución. Allí no teníamos necesidad de comprobar la fecha de general.js, pero ahora es necesario puesto que una modificación de ese JS puede afectar al propio HTML. Pero es evidente que ambos scripts deben comportarse igual puesto que están evaluando lo mismo, el primero antes de enviar una página desde el servidor y este segundo después de obtenerla desde caché en un momento posterior.

Actualizar versión página web

Esta consulta al servidor se hace en el window.onload mediante XHR, por lo que es una acción asíncrona y no bloqueará el curso de la carga. Cuando el servidor responda enviando la fecha, ésta se comprobará con la almacenada en la variable wxZ.f que ya existe en el documento cacheado. Si es más reciente es que hay una nueva versión y se lo comunicaremos al usuario con un mensaje y un botón para que recargue la página.

Me planteé la posibilidad de hacer una recarga automática con JavaScript usando window.location.reload(true), pero al final opté por presentar un mensaje al usuario y darle a él esa posibilidad. La razón principal es evitar el repintado de pantalla que se produce en toda carga, lo que se agrava en conexiones lentas. Otro motivo es evitar un bucle sin fin en caso de que la consulta con PHP devuelva una fecha errónea superior a la del documento.

La función comprobarCache() traza algún detalle de la acción y lo pone al pie del documento:

Traza del control de versión

Esta imagen es una captura de una petición a una página de este sitio, donde se observa que la respuesta se produjo en 20 ms, indicativo de recuperación desde caché. Incluyo también las constantes del tipo de navegación del objeto Window.Performance:

  • 0: NAVIGATENEXT para una carga a otra página.
  • 1: RELOAD para recargar la misma página.
  • 2: BACK_FORWARD cuando la carga se hace desde los botones del historial (atrás/adelante).
  • 255: UNDEFINED

Por último no está de más recordar que cuando un navegador no tiene conexión cargará los contenidos desde caché aunque estén caducados (a no ser que haya una cabecera must-revalidate). Sin conexión también se ejecutará comprobarCache(), pero el XHR nos dará un código de estado distinto de 200, por lo que no podremos mostrar ningún mensaje en ese punto. Así el usuario podrá ver la página y este mecanismo de control de versión no interferirá en la carga desde caché.