Expires no PHP - Cache de arquivos no navegador

Artigo que apresenta uma solução em PHP para aproveitar o cacheamento pelo navegador de arquivos gerados dinamicamente. Para isso, utiliza a diretiva HTTP If-Modified-Since e Expires.

performance

Introdução

No artigo Expires no Apache - Cache de arquivos no navegador, vimos como configurar o servidor HTTP (mais especificamente o Apache) para aproveitar o cache do navegador para guardar conteúdo estático, tais como imagens, arquivos CSS ou arquivos JS. Com isso, a performance do seu site será muito melhor (page speed), especialmente se utiliza muito conteúdo estático.

Neste artigo, veremos como fazer com que arquivos dinâmicos gerados com PHP possam usufruir do mesmo mecanismo de cache, mas sem prejudicar o caráter dinâmico da aplicação. Com isso, haverá ainda mais ganhos na performance, trazendo benefícios para o usuário e também para SEO.

Como funciona o cache com Expires?

O cacheamento de dados no cliente (navegador) é baseados em algumas regras definidas pelo protocolo HTTP. O funcionamento básico para entendermos é:

  1. Cliente: pede um arquivo para o servidor pela primeira vez.
  2. Servidor: devolve o arquivo, incluíndo no cabeçalho HTTP a data de envio do arquivo, uma indicação de que o arquivo pode ser guardado em cache, e a data de validade do arquivo.
  3. Cliente: recebe o arquivo, guarda em cache (guardando também a data em que o arquivo foi obtido).
  4. Cliente: pede novamente para o servidor o mesmo arquivo, mas incluíndo no cabeçalho HTTP uma indicação de que ele possui o arquivo em cache obtido em determinada data.
  5. Servidor: verifica se o arquivo solicitado foi modificado desde a data que o cliente obteve o arquivo. Se o arquivo foi modificado: o servidor devolve o novo arquivo e o cabeçalho HTTP indicando que o arquivo pode ser guardado em cache, etc. Se o arquivo não foi modificado: devolve apenas um cabeçalho HTTP orientando o cliente que ele pode usar o arquivo que ele tem em cache.
  6. Cliente: se o servidor enviou um arquivo modificado: o cliente recebe o arquivo, guarda em cache, etc. Se o servidor informou que o arquivo em cache pode ser usado: o cliente recebe a orientação e renderiza o arquivo que está em cache.

Observação: o passo 4 é realizado apenas se o usuário clica em atualizar no navegador. Caso clique diretamente em um link de um documento cacheado, ele nem realiza a requisição ao servidor e apenas verifica se o documento que ele tem em cache está na validade.

Agora, entrando em questões mais técnicas, isso é feito da seguinte forma:

  • Servidor utiliza a diretiva HTTP Cache-Control para informar que o conteúdo pode ou não ser guardado em cache. O valor "public" indica que o conteúdo pode ser cacheado e não é específico de um usuário. O valor "private" indica que o conteúdo pode ser cacheado, mas é específico de um usuário. O valor "no-cache" indica que o conteúdo não deve ser cacheado.
  • Servidor utiliza a diretiva HTTP Date para informar a data em que o arquivo foi gerado.
  • Servidor utiliza a diretiva HTTP Last-Modified para informar a data em que o arquivo foi modificado pela última vez.
  • Servidor utiliza a diretiva HTTP Expires para informar a data em que o arquivo poderá se tornar obsoleto.
  • Cliente utiliza a diretiva HTTP If-Modified-Since para informar ao servidor a data do arquivo que ele possui guardado em cache. É a forma que o navegador pergunta ao servidor: "Posso utilizar o arquivo obtido em DD/MM/AAAA?"
  • O servidor utiliza o código HTTP 304 (Not Modified) para informar ao cliente que ele pode usar o arquivo que possui em cache.

Para mais detalhes, você pode ler a Sessão 14 da RFC 2616

Solução em PHP

Agora que conhecemos os recursos necessários para o negócio funcionar, vamos por a mão na massa. Primeiro, devemos saber como incluir os cabeçalhos HTTP que indicam para o cliente que ele pode guardar o conteúdo em cache:

// Tempo que o arquivo sera considerado obsoleto: 60 segundos
$tempo_cache = 60;

/*
 * Esta variavel pode ser o valor de $_SERVER['REQUEST_TIME'] ou, se for possivel,
 * o timestamp da ultima modificacao do arquivo dinamico.
 * Se o script PHP apenas retorna o conteudo de um arquivo estatico, podemos ver
 * a data de modificacao do arquivo estatico com a funcao mtime
 */ 
$time_ultima_modificacao = ...

header('Cache-Control: public');
header('Pragma: '); // Apenas para evitar um "Pragma: no-cache" definido anteriormente
header('Date: ' . gmstrftime('%a, %d %b %Y %T %Z', $_SERVER['REQUEST_TIME']));
header('Expires: ' . gmstrftime('%a, %d %b %Y %T %Z', $_SERVER['REQUEST_TIME'] + $tempo_cache));
header('Last-Modified: ' . gmstrftime('%a, %d %b %Y %T %Z', $time_ultima_modificacao));

O cliente vai receber este cabeçalho e o conteúdo, então vai guardar o conteúdo em cache, caso ele tenha suporte a isso. Se ele guardar em cache, provavelmente vai requisitar o arquivo PHP passando a diretiva If-Modified-Since com a data que ele obteve o arquivo e salvou em cache.

Agora precisamos saber como o servidor consegue capturar essa diretiva vinda da requisição do cliente. Isso pode ser feito com a seguinte função:

/**
 * Obtem o timestamp obtido da diretiva If-Modified-Since enviada pelo cliente ou false
 * @return int | bool
 */
function getUserAgentCacheTime() {
    $date = false;

    // Checar em $_SERVER
    if (array_key_exists('HTTP_IF_MODIFIED_SINCE', $_SERVER)) {
        $date = $_SERVER['HTTP_IF_MODIFIED_SINCE'];

    // Checar em request header
    } else {
        $headers = array();
        if (function_exists('getallheaders')) {
            $headers = getallheaders();
        } elseif (function_exists('apache_request_headers')) {
            $headers = apache_request_headers();
        } elseif (function_exists('http_get_request_headers')) {
            $headers = http_get_request_headers();
        }
        foreach ($headers as $key => $value) {
            if (strcasecmp('If-Modified-Since', $key) == 0) {
                $date = $value;
                break;
            }
        }
    }

    // Se nao encontrou
    if (!$date) {
        return false;
    }

    // Formatar a data obtida na forma de timestamp
    $d = strptime($date, '%a, %d %b %Y %T');
    if (!$d) {
        return false;
    }

    return gmmktime(
        $d['tm_hour'], $d['tm_min'], $d['tm_sec'],
        $d['tm_mon'] + 1, $d['tm_mday'], 1900 + $d['tm_year']
    );
}

E, com isso, agora só precisamos saber como o servidor deve informar para o cliente que o arquivo não foi modificado. É bem simples:

header('HTTP/1.0 304 Not Modified');
header('Date: ' . gmstrftime('%a, %d %b %Y %T %Z', $_SERVER['REQUEST_TIME']));

// Remover Cache-Control, Pragma e Expires
header('Cache-Control: ');
header('Pragma: ');
header('Expires: ');

Se você já utiliza o PHP 5.4, pode fazer assim:

http_response_code(304);
header('Date: ' . gmstrftime('%a, %d %b %Y %T %Z', $_SERVER['REQUEST_TIME']));
header_remove('Cache-Control');
header_remove('Pragma');
header_remove('Expires');

Com estas informações, você só precisa incluir uma lógica para retornar o código 304 sob as condições que quiser.

A classe AutoExpires

Para a felicidade de todos, já fiz uma classe que faz todo este trabalho de forma bastante simples. A classe está postada no site do PHPClasses e o link é esse: Classe AutoExpires.

A classe implementa nativamente duas formas de checagem de validade do arquivo: uma baseada na data de modificação e outra baseada na modificação do conteúdo gerado. Ele verifica se o arquivo foi modificado usando um mecanismo de ob_start e obtendo um hash do conteúdo. Veja os exemplos no site e confira o quanto é simples.

Uma forma de testar o comportamento da classe é instalando um plugin que visualize os cabeçalhos HTTP trafegados durante uma requisição do navegador a um arquivo PHP do servidor. Um plugin para Firefox muito útil é o Firebug. Basta acessar a página PHP, abrir o painel do plugin indo em "Ferramentas / Desenvolvedor Web / Firebug / Abrir Firebug", abrindo a aba "Rede" e ativando-a, depois requisitando novamente a página (F5).

3 comentários

Klawdyo disse...

Tu poderia usar

date('r', $time_ultima_modificacao)

ao invés de

gmstrftime('%a, %d %b %Y %T %Z', $time_ultima_modificacao)

Rubens Takiguti Ribeiro (autor do blog) disse...

Opa, valeu pela contribuição, Klawdyo.

De fato, o "r" da função "date" já é um belo atalho. Porém, precisamos gerar a data no timezone GMT (hora no meridiano de Greenwich), então o mais correto seria:

gmdate('r', $time_ultima_modificacao);