[HttpKernel] Correctly merging cache directives in HttpCache/ResponseCacheStrategy
This commit is contained in:
parent
3cfb558f03
commit
893118f978
@ -5,10 +5,6 @@
|
|||||||
*
|
*
|
||||||
* (c) Fabien Potencier <fabien@symfony.com>
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
*
|
*
|
||||||
* This code is partially based on the Rack-Cache library by Ryan Tomayko,
|
|
||||||
* which is released under the MIT license.
|
|
||||||
* (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801)
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
* For the full copyright and license information, please view the LICENSE
|
||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
@ -28,30 +24,69 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
*/
|
*/
|
||||||
class ResponseCacheStrategy implements ResponseCacheStrategyInterface
|
class ResponseCacheStrategy implements ResponseCacheStrategyInterface
|
||||||
{
|
{
|
||||||
private $cacheable = true;
|
/**
|
||||||
|
* Cache-Control headers that are sent to the final response if they appear in ANY of the responses.
|
||||||
|
*/
|
||||||
|
private static $overrideDirectives = ['private', 'no-cache', 'no-store', 'no-transform', 'must-revalidate', 'proxy-revalidate'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-Control headers that are sent to the final response if they appear in ALL of the responses.
|
||||||
|
*/
|
||||||
|
private static $inheritDirectives = ['public', 'immutable'];
|
||||||
|
|
||||||
private $embeddedResponses = 0;
|
private $embeddedResponses = 0;
|
||||||
private $ttls = [];
|
|
||||||
private $maxAges = [];
|
|
||||||
private $isNotCacheableResponseEmbedded = false;
|
private $isNotCacheableResponseEmbedded = false;
|
||||||
|
private $age = 0;
|
||||||
|
private $flagDirectives = [
|
||||||
|
'no-cache' => null,
|
||||||
|
'no-store' => null,
|
||||||
|
'no-transform' => null,
|
||||||
|
'must-revalidate' => null,
|
||||||
|
'proxy-revalidate' => null,
|
||||||
|
'public' => null,
|
||||||
|
'private' => null,
|
||||||
|
'immutable' => null,
|
||||||
|
];
|
||||||
|
private $ageDirectives = [
|
||||||
|
'max-age' => null,
|
||||||
|
's-maxage' => null,
|
||||||
|
'expires' => null,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function add(Response $response)
|
public function add(Response $response)
|
||||||
{
|
{
|
||||||
if (!$response->isFresh() || !$response->isCacheable()) {
|
++$this->embeddedResponses;
|
||||||
$this->cacheable = false;
|
|
||||||
} else {
|
|
||||||
$maxAge = $response->getMaxAge();
|
|
||||||
$this->ttls[] = $response->getTtl();
|
|
||||||
$this->maxAges[] = $maxAge;
|
|
||||||
|
|
||||||
if (null === $maxAge) {
|
foreach (self::$overrideDirectives as $directive) {
|
||||||
$this->isNotCacheableResponseEmbedded = true;
|
if ($response->headers->hasCacheControlDirective($directive)) {
|
||||||
|
$this->flagDirectives[$directive] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
++$this->embeddedResponses;
|
foreach (self::$inheritDirectives as $directive) {
|
||||||
|
if (false !== $this->flagDirectives[$directive]) {
|
||||||
|
$this->flagDirectives[$directive] = $response->headers->hasCacheControlDirective($directive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$age = $response->getAge();
|
||||||
|
$this->age = max($this->age, $age);
|
||||||
|
|
||||||
|
if ($this->willMakeFinalResponseUncacheable($response)) {
|
||||||
|
$this->isNotCacheableResponseEmbedded = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storeRelativeAgeDirective('max-age', $response->headers->getCacheControlDirective('max-age'), $age);
|
||||||
|
$this->storeRelativeAgeDirective('s-maxage', $response->headers->getCacheControlDirective('s-maxage') ?: $response->headers->getCacheControlDirective('max-age'), $age);
|
||||||
|
|
||||||
|
$expires = $response->getExpires();
|
||||||
|
$expires = null !== $expires ? $expires->format('U') - $response->getDate()->format('U') : null;
|
||||||
|
$this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,33 +99,124 @@ class ResponseCacheStrategy implements ResponseCacheStrategyInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove validation related headers in order to avoid browsers using
|
// Remove validation related headers of the master response,
|
||||||
// their own cache, because some of the response content comes from
|
// because some of the response content comes from at least
|
||||||
// at least one embedded response (which likely has a different caching strategy).
|
// one embedded response (which likely has a different caching strategy).
|
||||||
if ($response->isValidateable()) {
|
$response->setEtag(null);
|
||||||
$response->setEtag(null);
|
$response->setLastModified(null);
|
||||||
$response->setLastModified(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$response->isFresh() || !$response->isCacheable()) {
|
$this->add($response);
|
||||||
$this->cacheable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->cacheable) {
|
$response->headers->set('Age', $this->age);
|
||||||
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
|
|
||||||
|
if ($this->isNotCacheableResponseEmbedded) {
|
||||||
|
$response->setExpires($response->getDate());
|
||||||
|
|
||||||
|
if ($this->flagDirectives['no-store']) {
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
} else {
|
||||||
|
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->ttls[] = $response->getTtl();
|
$flags = array_filter($this->flagDirectives);
|
||||||
$this->maxAges[] = $response->getMaxAge();
|
|
||||||
|
|
||||||
if ($this->isNotCacheableResponseEmbedded) {
|
if (isset($flags['must-revalidate'])) {
|
||||||
$response->headers->removeCacheControlDirective('s-maxage');
|
$flags['no-cache'] = true;
|
||||||
} elseif (null !== $maxAge = min($this->maxAges)) {
|
}
|
||||||
$response->setSharedMaxAge($maxAge);
|
|
||||||
$response->headers->set('Age', $maxAge - min($this->ttls));
|
$response->headers->set('Cache-Control', implode(', ', array_keys($flags)));
|
||||||
|
|
||||||
|
$maxAge = null;
|
||||||
|
$sMaxage = null;
|
||||||
|
|
||||||
|
if (\is_numeric($this->ageDirectives['max-age'])) {
|
||||||
|
$maxAge = $this->ageDirectives['max-age'] + $this->age;
|
||||||
|
$response->headers->addCacheControlDirective('max-age', $maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_numeric($this->ageDirectives['s-maxage'])) {
|
||||||
|
$sMaxage = $this->ageDirectives['s-maxage'] + $this->age;
|
||||||
|
|
||||||
|
if ($maxAge !== $sMaxage) {
|
||||||
|
$response->headers->addCacheControlDirective('s-maxage', $sMaxage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_numeric($this->ageDirectives['expires'])) {
|
||||||
|
$date = clone $response->getDate();
|
||||||
|
$date->modify('+'.($this->ageDirectives['expires'] + $this->age).' seconds');
|
||||||
|
$response->setExpires($date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC2616, Section 13.4.
|
||||||
|
*
|
||||||
|
* @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function willMakeFinalResponseUncacheable(Response $response)
|
||||||
|
{
|
||||||
|
// RFC2616: A response received with a status code of 200, 203, 300, 301 or 410
|
||||||
|
// MAY be stored by a cache […] unless a cache-control directive prohibits caching.
|
||||||
|
if ($response->headers->hasCacheControlDirective('no-cache')
|
||||||
|
|| $response->headers->getCacheControlDirective('no-store')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-Modified and Etag headers cannot be merged, they render the response uncacheable
|
||||||
|
// by default (except if the response also has max-age etc.).
|
||||||
|
if (\in_array($response->getStatusCode(), [200, 203, 300, 301, 410])
|
||||||
|
&& null === $response->getLastModified()
|
||||||
|
&& null === $response->getEtag()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC2616: A response received with any other status code (e.g. status codes 302 and 307)
|
||||||
|
// MUST NOT be returned in a reply to a subsequent request unless there are
|
||||||
|
// cache-control directives or another header(s) that explicitly allow it.
|
||||||
|
$cacheControl = ['max-age', 's-maxage', 'must-revalidate', 'proxy-revalidate', 'public', 'private'];
|
||||||
|
foreach ($cacheControl as $key) {
|
||||||
|
if ($response->headers->hasCacheControlDirective($key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->headers->has('Expires')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store lowest max-age/s-maxage/expires for the final response.
|
||||||
|
*
|
||||||
|
* The response might have been stored in cache a while ago. To keep things comparable,
|
||||||
|
* we have to subtract the age so that the value is normalized for an age of 0.
|
||||||
|
*
|
||||||
|
* If the value is lower than the currently stored value, we update the value, to keep a rolling
|
||||||
|
* minimal value of each instruction. If the value is NULL, the directive will not be set on the final response.
|
||||||
|
*
|
||||||
|
* @param string $directive
|
||||||
|
* @param int|null $value
|
||||||
|
* @param int $age
|
||||||
|
*/
|
||||||
|
private function storeRelativeAgeDirective($directive, $value, $age)
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
$this->ageDirectives[$directive] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false !== $this->ageDirectives[$directive]) {
|
||||||
|
$value = $value - $age;
|
||||||
|
$this->ageDirectives[$directive] = null !== $this->ageDirectives[$directive] ? min($this->ageDirectives[$directive], $value) : $value;
|
||||||
}
|
}
|
||||||
$response->setMaxAge(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -237,4 +237,233 @@ class ResponseCacheStrategyTest extends TestCase
|
|||||||
$this->assertSame('60', $masterResponse->headers->getCacheControlDirective('s-maxage'));
|
$this->assertSame('60', $masterResponse->headers->getCacheControlDirective('s-maxage'));
|
||||||
$this->assertFalse($masterResponse->isValidateable());
|
$this->assertFalse($masterResponse->isValidateable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider cacheControlMergingProvider
|
||||||
|
*/
|
||||||
|
public function testCacheControlMerging(array $expects, array $master, array $surrogates)
|
||||||
|
{
|
||||||
|
$cacheStrategy = new ResponseCacheStrategy();
|
||||||
|
$buildResponse = function ($config) {
|
||||||
|
$response = new Response();
|
||||||
|
|
||||||
|
foreach ($config as $key => $value) {
|
||||||
|
switch ($key) {
|
||||||
|
case 'age':
|
||||||
|
$response->headers->set('Age', $value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'expires':
|
||||||
|
$expires = clone $response->getDate();
|
||||||
|
$expires->modify('+'.$value.' seconds');
|
||||||
|
$response->setExpires($expires);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'max-age':
|
||||||
|
$response->setMaxAge($value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 's-maxage':
|
||||||
|
$response->setSharedMaxAge($value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'private':
|
||||||
|
$response->setPrivate();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'public':
|
||||||
|
$response->setPublic();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$response->headers->addCacheControlDirective($key, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($surrogates as $config) {
|
||||||
|
$cacheStrategy->add($buildResponse($config));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $buildResponse($master);
|
||||||
|
$cacheStrategy->update($response);
|
||||||
|
|
||||||
|
foreach ($expects as $key => $value) {
|
||||||
|
if ('expires' === $key) {
|
||||||
|
$this->assertSame($value, $response->getExpires()->format('U') - $response->getDate()->format('U'));
|
||||||
|
} elseif ('age' === $key) {
|
||||||
|
$this->assertSame($value, $response->getAge());
|
||||||
|
} elseif (true === $value) {
|
||||||
|
$this->assertTrue($response->headers->hasCacheControlDirective($key), sprintf('Cache-Control header must have "%s" flag', $key));
|
||||||
|
} elseif (false === $value) {
|
||||||
|
$this->assertFalse(
|
||||||
|
$response->headers->hasCacheControlDirective($key),
|
||||||
|
sprintf('Cache-Control header must NOT have "%s" flag', $key)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->assertSame($value, $response->headers->getCacheControlDirective($key), sprintf('Cache-Control flag "%s" should be "%s"', $key, $value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cacheControlMergingProvider()
|
||||||
|
{
|
||||||
|
yield 'result is public if all responses are public' => [
|
||||||
|
['private' => false, 'public' => true],
|
||||||
|
['public' => true],
|
||||||
|
[
|
||||||
|
['public' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'result is private by default' => [
|
||||||
|
['private' => true, 'public' => false],
|
||||||
|
['public' => true],
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'combines public and private responses' => [
|
||||||
|
['must-revalidate' => false, 'private' => true, 'public' => false],
|
||||||
|
['public' => true],
|
||||||
|
[
|
||||||
|
['private' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'inherits no-cache from surrogates' => [
|
||||||
|
['no-cache' => true, 'public' => false],
|
||||||
|
['public' => true],
|
||||||
|
[
|
||||||
|
['no-cache' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'inherits no-store from surrogate' => [
|
||||||
|
['no-store' => true, 'public' => false],
|
||||||
|
['public' => true],
|
||||||
|
[
|
||||||
|
['no-store' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'resolve to lowest possible max-age' => [
|
||||||
|
['public' => false, 'private' => true, 's-maxage' => false, 'max-age' => '60'],
|
||||||
|
['public' => true, 'max-age' => 3600],
|
||||||
|
[
|
||||||
|
['private' => true, 'max-age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'resolves multiple max-age' => [
|
||||||
|
['public' => false, 'private' => true, 's-maxage' => false, 'max-age' => '60'],
|
||||||
|
['private' => true, 'max-age' => 100],
|
||||||
|
[
|
||||||
|
['private' => true, 'max-age' => 3600],
|
||||||
|
['public' => true, 'max-age' => 60, 's-maxage' => 60],
|
||||||
|
['private' => true, 'max-age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'merge max-age and s-maxage' => [
|
||||||
|
['public' => true, 's-maxage' => '60', 'max-age' => null],
|
||||||
|
['public' => true, 's-maxage' => 3600],
|
||||||
|
[
|
||||||
|
['public' => true, 'max-age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'result is private when combining private responses' => [
|
||||||
|
['no-cache' => false, 'must-revalidate' => false, 'private' => true],
|
||||||
|
['s-maxage' => 60, 'private' => true],
|
||||||
|
[
|
||||||
|
['s-maxage' => 60, 'private' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'result can have s-maxage and max-age' => [
|
||||||
|
['public' => true, 'private' => false, 's-maxage' => '60', 'max-age' => '30'],
|
||||||
|
['s-maxage' => 100, 'max-age' => 2000],
|
||||||
|
[
|
||||||
|
['s-maxage' => 1000, 'max-age' => 30],
|
||||||
|
['s-maxage' => 500, 'max-age' => 500],
|
||||||
|
['s-maxage' => 60, 'max-age' => 1000],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'does not set headers without value' => [
|
||||||
|
['max-age' => null, 's-maxage' => null, 'public' => null],
|
||||||
|
['private' => true],
|
||||||
|
[
|
||||||
|
['private' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'max-age 0 is sent to the client' => [
|
||||||
|
['private' => true, 'max-age' => '0'],
|
||||||
|
['max-age' => 0, 'private' => true],
|
||||||
|
[
|
||||||
|
['max-age' => 60, 'private' => true],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'max-age is relative to age' => [
|
||||||
|
['max-age' => '240', 'age' => 60],
|
||||||
|
['max-age' => 180],
|
||||||
|
[
|
||||||
|
['max-age' => 600, 'age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'retains lowest age of all responses' => [
|
||||||
|
['max-age' => '160', 'age' => 60],
|
||||||
|
['max-age' => 600, 'age' => 60],
|
||||||
|
[
|
||||||
|
['max-age' => 120, 'age' => 20],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'max-age can be less than age, essentially expiring the response' => [
|
||||||
|
['age' => 120, 'max-age' => '90'],
|
||||||
|
['max-age' => 90, 'age' => 120],
|
||||||
|
[
|
||||||
|
['max-age' => 120, 'age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'max-age is 0 regardless of age' => [
|
||||||
|
['max-age' => '0'],
|
||||||
|
['max-age' => 60],
|
||||||
|
[
|
||||||
|
['max-age' => 0, 'age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'max-age is not negative' => [
|
||||||
|
['max-age' => '0'],
|
||||||
|
['max-age' => 0],
|
||||||
|
[
|
||||||
|
['max-age' => 0, 'age' => 60],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'calculates lowest Expires header' => [
|
||||||
|
['expires' => 60],
|
||||||
|
['expires' => 60],
|
||||||
|
[
|
||||||
|
['expires' => 120],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'calculates Expires header relative to age' => [
|
||||||
|
['expires' => 210, 'age' => 120],
|
||||||
|
['expires' => 90],
|
||||||
|
[
|
||||||
|
['expires' => 600, 'age' => '120'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user