Cache HTTP no Symfony2 – max-age, ETag e gzip

Symfony é legal, o cache HTTP é legal, então o cache HTTP no Symfony é ainda mais legal. Mesmo assim, as estimativas foram ruins quando implementamos max-agee ETag. Por nós, quero dizer eu mesmo, Václav Novotný e Jenkins .

idade máxima

Sempre defina a max-agediretiva. Não espere que o max-ageestá definido. O exemplo seguinte da documentação do Symfony parece razoável, mas o Jenkins (Ubuntu, Apache 2.4, Symfony 2.5.6) retornou, o Cache-Control: publicque fez com que o navegador carregasse a página do cache sem solicitar o aplicativo! O código da documentação não funcionou para nós:

// create a Response with an ETag and/or a Last-Modified header
$response
= new Response();
$response
->setETag($article->computeETag());
$response
->setLastModified($article->getPublishedAt());
$response
->setPublic();
// Check that the Response is not modified for the given Request
if ($response->isNotModified($request)) {
return $response;
}

ETag

Certifique-se de que as ETags funcionem conforme o esperado, especialmente se você usar o gzip. Tom Panier encontrou o -gzipproblema antes de nós. Por exemplo, você define ETag ABCDE. Mas quando você olha para o sniffer de rede, você vê o cabeçalho ABCDE-gzip. Adivinhe o que acontece quando o navegador envia este cabeçalho:

If-None-Match: "ABCDE-gzip"

Claro que a resposta será gerada mais uma vez ( isNotModifiedretorna falso). A solução de Tom Painer não funcionou para nós (talvez porque setEtag use internamente código semelhante ?). O problema está combinando ETags da solicitação . Decidimos corrigir o pedido:

function fixETag(Request $r)
{
$oldETag
= $r->headers->get('if_none_match');
$etagWithoutGzip
= str_replace('-gzip"', '"', $oldETag);
$r
->headers->set('if_none_match', $etagWithoutGzip);
}

Substituir substring é um hack feio, mas é a solução mais simples que resolveu nosso problema:

/** @dataProvider provideIfNoneMatchHeader */
public function testShouldFixETagHeader($header)
{
$r
= new Request();
$r
->headers->set('if_none_match', $header);
fixETag
($r);
assertThat
($r->getETags(), hasItemInArray('"094b34ae543043a951185b2c7c0f145b"'));
}

public function provideIfNoneMatchHeader()
{
return array(
'pure' => array('"094b34ae543043a951185b2c7c0f145b"'),
'gzipped' => array('"094b34ae543043a951185b2c7c0f145b-gzip"'),
);
}

Quem causou os problemas? Symfony, Apache ou nós?

Nenhuma menção sobre anexar ETag em RFC2616 , então provavelmente não é culpa do Symfony. Dizemos ao Apache que nos preocupamos em gerar ETag ( ). Ainda assim, o Apache modifica nossa ETag , mas não modifica o cabeçalho. Se você souber o motivo, compartilhe a explicação / solução nos comentários. Estamos ansiosos para excluir o hack!FileETag NoneIf-None-Matchstr_replace

Por que e como geramos ETags?

As ETags são poderosas, porque calcular o cabeçalho pode ficar complicado quando você tenta envolver todos os eventos possíveis que devem causar a invalidação do cache. Exceto pelos motivos óbvios, como alterar o conteúdo, também queremos invalidar o cache quando:Last-Modified

  • nova versão é implantada, então o usuário deve usar o estilo CSS mais recente
  • usuário conectado, para que possa ver o link Editar no menu
  • a data da última modificação é alterada (só para ter certeza, o que aconteceria se o Apache cancelasse o cabeçalho Last-Modified 🙂

Gerar e testar essa ETag “complexa” é muito transparente:

use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentSecurityCoreRoleRole;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;

class ResponseFactory
{
private $repository;
private $appVersion;

public function __construct(ModificationDatesRepository $r, $appVersion)
{
$this
->repository = $r;
$this
->appVersion = $appVersion;
}

// token is retrived from "@security.context".getToken()
public function cached(Article $a, TokenInterface $user)
{
$lastModified
= $this->repository->findLastModifiedDate($a);
$r
= new Response();
$r
->setPrivate();
$r
->setMaxAge(0);
$r
->headers->addCacheControlDirective('must-revalidate', true);
$r
->setLastModified($lastModified);
$r
->setETag($this->getEtag($a, $lastModified, $user));
return $r;
}

private function getEtag(Article $a, DateTime $lastModified, TokenInterface $user)
{
$source
=<<<STRING
{$lastModified->format('c')}
{$this->rolesToString($user)}
{$this->appVersion}
{$a->getTitle()}
{$a->getContent()}
STRING
;
return md5($source);
}

private function rolesToString(TokenInterface $user)
{
return array_reduce(
$user
->getRoles(),
function ($previousRoles, Role $r) {
return $previousRoles . ',' . $r->getRole();
},
''
);
}
}

Não fareje a rede (e instale vários servidores da web em versões X) para verificar se a ETag é diferente quando você implanta uma nova versão do seu aplicativo. Testes isolados são bons o suficiente:

public function testShouldGenerateCacheableResponse()
{
$response
= $this->createResponse($this->builder);
assertThat
($response->getMaxAge(), sameInstance(0));
assertThat
($response->headers->getCacheControlDirective('public'), is(nullValue()));
assertThat
($response->headers->getCacheControlDirective('private'), sameInstance(true));
assertThat
($response->headers->getCacheControlDirective('must-revalidate'), sameInstance(true));
assertThat
(strlen($response->getEtag()), is(34)); // md5 length
assertThat
($response->getLastModified(), is($this->builder->dateModified));
}

/** @dataProvider provideDifferentArticles */
public function testEtagShouldBeDifferent(ArticleBuilder $thisArticle, ArticleBuilder $thatArticle)
{
$thisResponse
= $this->createResponse($thisArticle);
$thatResponse
= $this->createResponse($thatArticle);
assertThat
($thisResponse->getEtag(), not($thatResponse->getEtag()));
}

public function provideDifferentArticles()
{
$builder
= new ArticleBuilder();
return array(
'title' => array($builder->title('Hello'), $builder->title('Index')),
'content' => array($builder->content('Hello'), $builder->content('Index')),
'app version' => array($builder->version('1.0'), $builder->version('2.0')),
'date modified' => array($builder->dateModified('today'), $builder->dateModified('yesterday')),
'login/logout' => array($builder->roles(), $builder->roles('editor')),
'change role' => array($builder->roles('editor'), $builder->roles('admin')),
);
}

// createResponse - mocks repository, returns Symfony's response
// ArticleBuilder - test data builder