Commit ff622b59 authored by alexpott's avatar alexpott

Issue #2527126 by Wim Leers, dawehner, effulgentsia, anavarre, janusman,...

Issue #2527126 by Wim Leers, dawehner, effulgentsia, anavarre, janusman, Fabianx, alexpott, hass, catch, Berdir, znerol: Only send X-Drupal-Cache-Tags and -Contexts headers when developer explicitly enables them
parent 7a62888a
......@@ -16,6 +16,7 @@ parameters:
tags: []
factory.keyvalue:
default: keyvalue.database
http.response.debug_cacheability_headers: false
factory.keyvalue.expirable:
default: keyvalue.expirable.database
filter_protocols:
......@@ -1070,7 +1071,7 @@ services:
class: Drupal\Core\EventSubscriber\FinishResponseSubscriber
tags:
- { name: event_subscriber }
arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager']
arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager', '%http.response.debug_cacheability_headers%']
response_generator_subscriber:
class: Drupal\Core\EventSubscriber\ResponseGeneratorSubscriber
tags:
......
......@@ -61,6 +61,13 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
*/
protected $cacheContexts;
/**
* Whether to send cacheability headers for debugging purposes.
*
* @var bool
*/
protected $debugCacheabilityHeaders = FALSE;
/**
* Constructs a FinishResponseSubscriber object.
*
......@@ -74,13 +81,16 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
* A policy rule determining the cacheability of a response.
* @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
* The cache contexts manager service.
* @param bool $http_response_debug_cacheability_headers
* (optional) Whether to send cacheability headers for debugging purposes.
*/
public function __construct(LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory, RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, CacheContextsManager $cache_contexts_manager) {
public function __construct(LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory, RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, CacheContextsManager $cache_contexts_manager, $http_response_debug_cacheability_headers = FALSE) {
$this->languageManager = $language_manager;
$this->config = $config_factory->get('system.performance');
$this->requestPolicy = $request_policy;
$this->responsePolicy = $response_policy;
$this->cacheContextsManager = $cache_contexts_manager;
$this->debugCacheabilityHeaders = $http_response_debug_cacheability_headers;
}
/**
......@@ -130,11 +140,13 @@ public function onRespond(FilterResponseEvent $event) {
return;
}
// Expose the cache contexts and cache tags associated with this page in a
// X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags header respectively.
$response_cacheability = $response->getCacheableMetadata();
$response->headers->set('X-Drupal-Cache-Tags', implode(' ', $response_cacheability->getCacheTags()));
$response->headers->set('X-Drupal-Cache-Contexts', implode(' ', $this->cacheContextsManager->optimizeTokens($response_cacheability->getCacheContexts())));
if ($this->debugCacheabilityHeaders) {
// Expose the cache contexts and cache tags associated with this page in a
// X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags header respectively.
$response_cacheability = $response->getCacheableMetadata();
$response->headers->set('X-Drupal-Cache-Tags', implode(' ', $response_cacheability->getCacheTags()));
$response->headers->set('X-Drupal-Cache-Contexts', implode(' ', $this->cacheContextsManager->optimizeTokens($response_cacheability->getCacheContexts())));
}
$is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
......
......@@ -8,6 +8,7 @@
namespace Drupal\page_cache\StackMiddleware;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
......@@ -206,25 +207,55 @@ protected function lookup(Request $request, $type = self::MASTER_REQUEST, $catch
* A response object.
*/
protected function fetch(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
/** @var \Symfony\Component\HttpFoundation\Response $response */
$response = $this->httpKernel->handle($request, $type, $catch);
// Currently it is not possible to cache some types of responses. Therefore
// exclude binary file responses (generated files, e.g. images with image
// styles) and streamed responses (files directly read from the disk).
// see: https://github.com/symfony/symfony/issues/9128#issuecomment-25088678
// Drupal's primary cache invalidation architecture is cache tags: any
// response that varies by a configuration value or data in a content
// entity should have cache tags, to allow for instant cache invalidation
// when that data is updated. However, HTTP does not standardize how to
// encode cache tags in a response. Different CDNs implement their own
// approaches, and configurable reverse proxies (e.g., Varnish) allow for
// custom implementations. To keep Drupal's internal page cache simple, we
// only cache CacheableResponseInterface responses, since those provide a
// defined API for retrieving cache tags. For responses that do not
// implement CacheableResponseInterface, there's no easy way to distinguish
// responses that truly don't depend on any site data from responses that
// contain invalidation information customized to a particular proxy or
// CDN.
// - Drupal modules are encouraged to use CacheableResponseInterface
// responses where possible and to leave the encoding of that information
// into response headers to the corresponding proxy/CDN integration
// modules.
// - Custom applications that wish to provide internal page cache support
// for responses that do not implement CacheableResponseInterface may do
// so by replacing/extending this middleware service or adding another
// one.
if (!$response instanceof CacheableResponseInterface) {
return $response;
}
// Currently it is not possible to cache binary file or streamed responses:
// https://github.com/symfony/symfony/issues/9128#issuecomment-25088678.
// Therefore exclude them, even for subclasses that implement
// CacheableResponseInterface.
if ($response instanceof BinaryFileResponse || $response instanceof StreamedResponse) {
return $response;
}
// Allow policy rules to further restrict which responses to cache.
if ($this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
return $response;
}
// Use the actual timestamp from an Expires header, if available.
// The response passes all of the above checks, so cache it.
// - Get the tags from CacheableResponseInterface per the earlier comments.
// - Get the time expiration from the Expires header, rather than the
// interface, but see https://www.drupal.org/node/2352009 about possibly
// changing that.
$tags = $response->getCacheableMetadata()->getCacheTags();
$date = $response->getExpires()->getTimestamp();
$expire = ($date > time()) ? $date : Cache::PERMANENT;
$tags = explode(' ', $response->headers->get('X-Drupal-Cache-Tags'));
$this->set($request, $response, $expire, $tags);
// Mark response as a cache miss.
......
......@@ -79,6 +79,37 @@ function testPageCacheTags() {
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
}
/**
* Test that the page cache doesn't depend on cacheability headers.
*/
function testPageCacheTagsIndependentFromCacheabilityHeaders() {
$this->setHttpResponseDebugCacheabilityHeaders(FALSE);
$path = 'system-test/cache_tags_page';
$tags = array('system_test_cache_tags_page');
$this->drupalGet($path);
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
// Verify a cache hit, but also the presence of the correct cache tags.
$this->drupalGet($path);
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT');
$cid_parts = array(\Drupal::url('system_test.cache_tags_page', array(), array('absolute' => TRUE)), 'html');
$cid = implode(':', $cid_parts);
$cache_entry = \Drupal::cache('render')->get($cid);
sort($cache_entry->tags);
$expected_tags = array(
'config:user.role.anonymous',
'pre_render',
'rendered',
'system_test_cache_tags_page',
);
$this->assertIdentical($cache_entry->tags, $expected_tags);
Cache::invalidateTags($tags);
$this->drupalGet($path);
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
}
/**
* Tests support for different cache items with different request formats
* specified via a query parameter.
......@@ -418,22 +449,32 @@ public function testCacheableResponseResponses() {
$config->set('cache.page.max_age', 300);
$config->save();
// Try to fill the cache.
// GET a URL, which would be marked as a cache miss if it were cacheable.
$this->drupalGet('/system-test/respond-reponse');
$this->assertFalse(in_array('X-Drupal-Cache', $this->drupalGetHeaders()), 'Drupal page cache header not found');
$this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), 'Drupal page cache header not found.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'must-revalidate, no-cache, post-check=0, pre-check=0, private', 'Cache-Control header was sent');
// Still not cached, uncacheable response.
// GET it again, verify it's still not cached.
$this->drupalGet('/system-test/respond-reponse');
$this->assertFalse(in_array('X-Drupal-Cache', $this->drupalGetHeaders()), 'Drupal page cache header not found');
$this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), 'Drupal page cache header not found.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'must-revalidate, no-cache, post-check=0, pre-check=0, private', 'Cache-Control header was sent');
// Try to fill the cache.
// GET a URL, which would be marked as a cache miss if it were cacheable.
$this->drupalGet('/system-test/respond-public-response');
$this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), 'Drupal page cache header not found.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'max-age=60, public', 'Cache-Control header was sent');
// GET it again, verify it's still not cached.
$this->drupalGet('/system-test/respond-public-response');
$this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), 'Drupal page cache header not found.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'max-age=60, public', 'Cache-Control header was sent');
// GET a URL, which should be marked as a cache miss.
$this->drupalGet('/system-test/respond-cacheable-reponse');
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', 'Page was not cached.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'max-age=300, public', 'Cache-Control header was sent.');
// Should be cached now.
// GET it again, it should now be a cache hit.
$this->drupalGet('/system-test/respond-cacheable-reponse');
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', 'Page was cached.');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'max-age=300, public', 'Cache-Control header was sent.');
......@@ -443,13 +484,9 @@ public function testCacheableResponseResponses() {
$this->container->get('module_installer')
->uninstall(['page_cache']);
// Try to fill the cache.
$this->drupalGet('/system-test/respond-reponse');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'must-revalidate, no-cache, post-check=0, pre-check=0, private', 'Cache-Control header was sent');
// Still not cached, uncacheable response.
$this->drupalGet('/system-test/respond-reponse');
$this->assertEqual($this->drupalGetHeader('Cache-Control'), 'must-revalidate, no-cache, post-check=0, pre-check=0, private', 'Cache-Control header was sent');
// GET a URL that was cached by Page Cache before, it should not be now.
$this->drupalGet('/respond-cacheable-reponse');
$this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), 'Drupal page cache header not found.');
}
}
......@@ -806,6 +806,10 @@ protected function initSettings() {
// TestBase::restoreEnvironment() will delete the entire site directory.
// Not using File API; a potential error must trigger a PHP warning.
chmod(DRUPAL_ROOT . '/' . $this->siteDirectory, 0777);
// During tests, cacheable responses should get the debugging cacheability
// headers by default.
$this->setContainerParameter('http.response.debug_cacheability_headers', TRUE);
}
/**
......@@ -3013,4 +3017,18 @@ protected function assertNoCacheTag($cache_tag) {
$this->assertFalse(in_array($cache_tag, $cache_tags), "'" . $cache_tag . "' is absent in the X-Drupal-Cache-Tags header.");
}
/**
* Enables/disables the cacheability headers.
*
* Sets the http.response.debug_cacheability_headers container parameter.
*
* @param bool $value
* (optional) Whether the debugging cacheability headers should be sent.
*/
protected function setHttpResponseDebugCacheabilityHeaders($value = TRUE) {
$this->setContainerParameter('http.response.debug_cacheability_headers', $value);
$this->rebuildContainer();
$this->resetAll();
}
}
......@@ -91,6 +91,18 @@ public function testFinishResponseSubscriber() {
$headers = $this->drupalGetHeaders();
$this->assertEqual($headers['x-drupal-cache-contexts'], 'user.roles');
$this->assertEqual($headers['x-drupal-cache-tags'], '');
// Finally, verify that the X-Drupal-Cache-Contexts and X-Drupal-Cache-Tags
// headers are not sent when their container parameter is set to FALSE.
$this->drupalGet('router_test/test18');
$headers = $this->drupalGetHeaders();
$this->assertTrue(isset($headers['x-drupal-cache-contexts']));
$this->assertTrue(isset($headers['x-drupal-cache-tags']));
$this->setHttpResponseDebugCacheabilityHeaders(FALSE);
$this->drupalGet('router_test/test18');
$headers = $this->drupalGetHeaders();
$this->assertFalse(isset($headers['x-drupal-cache-contexts']));
$this->assertFalse(isset($headers['x-drupal-cache-tags']));
}
/**
......
......@@ -275,6 +275,13 @@ public function respondWithReponse(Request $request) {
return new Response('test');
}
/**
* A plain Symfony reponse with Cache-Control: public, max-age=60.
*/
public function respondWithPublicResponse() {
return (new Response('test'))->setPublic()->setMaxAge(60);
}
/**
* A simple page callback that uses a CacheableResponse object.
*/
......
......@@ -137,6 +137,13 @@ system_test.respond_response:
requirements:
_access: 'TRUE'
system_test.respond_public_response:
path: '/system-test/respond-public-response'
defaults:
_controller: '\Drupal\system_test\Controller\SystemTestController::respondWithPublicResponse'
requirements:
_access: 'TRUE'
system_test.respond_cacheable_response:
path: '/system-test/respond-cacheable-reponse'
defaults:
......
......@@ -115,6 +115,17 @@ parameters:
#
# @default []
tags: []
# Cacheability debugging:
#
# Responses with cacheability metadata (CacheableResponseInterface instances)
# get X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts headers.
#
# For more information about debugging cacheable responses, see
# https://www.drupal.org/developing/api/8/response/cacheable-response-interface
#
# Not recommended in production environments
# @default false
http.response.debug_cacheability_headers: false
factory.keyvalue:
{}
# Default key/value storage service to use.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment