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-age
e ETag
. Por nós, quero dizer eu mesmo, Václav Novotný e Jenkins .
idade máxima
Sempre defina a max-age
diretiva. Não espere que o max-age
está 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: public
que 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 -gzip
problema 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 ( isNotModified
retorna 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 None
If-None-Match
str_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