Commit 1e985b0d authored by alexpott's avatar alexpott

Issue #2348679 by znerol, Wim Leers: Move the remaining procedural page cache...

Issue #2348679 by znerol, Wim Leers: Move the remaining procedural page cache code to the page cache stack middleware
parent 5d20c57b
......@@ -124,6 +124,7 @@ services:
class: Drupal\Core\PageCache\ChainResponsePolicy
tags:
- { name: service_collector, tag: page_cache_response_policy, call: addPolicy}
lazy: true
page_cache_kill_switch:
class: Drupal\Core\PageCache\ResponsePolicy\KillSwitch
tags:
......@@ -455,7 +456,7 @@ services:
- { name: http_middleware, priority: 300 }
http_middleware.page_cache:
class: Drupal\Core\StackMiddleware\PageCache
arguments: ['@kernel']
arguments: ['@cache.render', '@page_cache_request_policy', '@page_cache_response_policy', '@content_negotiation']
tags:
- { name: http_middleware, priority: 200 }
http_middleware.kernel_pre_handle:
......
......@@ -68,25 +68,18 @@
const DRUPAL_BOOTSTRAP_KERNEL = 1;
/**
* Third bootstrap phase: try to serve a cached page.
* Third bootstrap phase: load code for subsystems and modules.
*
* @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0.
*/
const DRUPAL_BOOTSTRAP_PAGE_CACHE = 2;
/**
* Fourth bootstrap phase: load code for subsystems and modules.
*
* @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0.
*/
const DRUPAL_BOOTSTRAP_CODE = 3;
const DRUPAL_BOOTSTRAP_CODE = 2;
/**
* Final bootstrap phase: initialize language, path, theme, and modules.
*
* @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0.
*/
const DRUPAL_BOOTSTRAP_FULL = 4;
const DRUPAL_BOOTSTRAP_FULL = 3;
/**
* Role ID for anonymous users; should match what's in the "role" table.
......@@ -310,39 +303,6 @@ function drupal_get_path($type, $name) {
return dirname(drupal_get_filename($type, $name));
}
/**
* Gets the page cache cid for this request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request for this page.
*
* @return string
* The cid for this request.
*/
function drupal_page_cache_get_cid(Request $request) {
$cid_parts = array(
$request->getUri(),
\Drupal::service('content_negotiation')->getContentType($request),
);
return implode(':', $cid_parts);
}
/**
* Retrieves the current page from the cache.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request for this page.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response, if the page was found in the cache, NULL otherwise.
*/
function drupal_page_get_cache(Request $request) {
$cache = \Drupal::cache('render')->get(drupal_page_cache_get_cid($request));
if ($cache) {
return $cache->data;
}
}
/**
* Sets an HTTP response header for the current page.
*
......@@ -426,72 +386,6 @@ function _drupal_set_preferred_header_name($name = NULL) {
$header_names[strtolower($name)] = $name;
}
/**
* Sets HTTP headers in preparation for a cached page response.
*
* The headers allow as much as possible in proxies and browsers without any
* particular knowledge about the pages. Modules can override these headers
* using _drupal_add_http_header().
*
* If the request is conditional (using If-Modified-Since and If-None-Match),
* and the conditions match those currently in the cache, a 304 Not Modified
* response is sent.
*/
function drupal_serve_page_from_cache(Response $response, Request $request) {
// Only allow caching in the browser and prevent that the response is stored
// by an external proxy server when the following conditions apply:
// 1. There is a session cookie on the request.
// 2. The Vary: Cookie header is on the response.
// 3. The Cache-Control header does not contain the no-cache directive.
if ($request->cookies->has(session_name()) &&
in_array('Cookie', $response->getVary()) &&
!$response->headers->hasCacheControlDirective('no-cache')) {
$response->setPrivate();
}
// Negotiate whether to use compression.
if ($response->headers->get('Content-Encoding') == 'gzip' && extension_loaded('zlib')) {
if (strpos($request->headers->get('Accept-Encoding'), 'gzip') !== FALSE) {
// The response content is already gzip'ed, so make sure
// zlib.output_compression does not compress it once more.
ini_set('zlib.output_compression', '0');
}
else {
// The client does not support compression. Decompress the content and
// remove the Content-Encoding header.
$content = $response->getContent();
$content = gzinflate(substr(substr($content, 10), 0, -8));
$response->setContent($content);
$response->headers->remove('Content-Encoding');
}
}
// Perform HTTP revalidation.
// @todo Use Response::isNotModified() as per https://drupal.org/node/2259489
$last_modified = $response->getLastModified();
if ($last_modified) {
// See if the client has provided the required HTTP headers.
$if_modified_since = $request->server->has('HTTP_IF_MODIFIED_SINCE') ? strtotime($request->server->get('HTTP_IF_MODIFIED_SINCE')) : FALSE;
$if_none_match = $request->server->has('HTTP_IF_NONE_MATCH') ? stripslashes($request->server->get('HTTP_IF_NONE_MATCH')) : FALSE;
if ($if_modified_since && $if_none_match
&& $if_none_match == $response->getEtag() // etag must match
&& $if_modified_since == $last_modified->getTimestamp()) { // if-modified-since must match
$response->setStatusCode(304);
$response->setContent(NULL);
// In the case of a 304 response, certain headers must be sent, and the
// remaining may not (see RFC 2616, section 10.3.5).
foreach (array_keys($response->headers->all()) as $name) {
if (!in_array($name, array('content-location', 'expires', 'cache-control', 'vary'))) {
$response->headers->remove($name);
}
}
}
}
}
/**
* Translates a string to the current language or to a given language.
*
......@@ -830,10 +724,6 @@ function drupal_bootstrap($phase = NULL) {
$kernel->boot();
break;
case DRUPAL_BOOTSTRAP_PAGE_CACHE:
$kernel->handlePageCache($request);
break;
case DRUPAL_BOOTSTRAP_CODE:
case DRUPAL_BOOTSTRAP_FULL:
$kernel->prepareLegacyRequest($request);
......
......@@ -1267,48 +1267,6 @@ function drupal_clear_js_cache() {
\Drupal::service('asset.js.collection_optimizer')->deleteAll();
}
/**
* Stores the current page in the cache.
*
* If page_compression is enabled, a gzipped version of the page is stored in
* the cache to avoid compressing the output on each request. The cache entry
* is unzipped in the relatively rare event that the page is requested by a
* client without gzip support.
*
* Page compression requires the PHP zlib extension
* (http://php.net/manual/ref.zlib.php).
*
* @param \Symfony\Component\HttpFoundation\Response $response
* The fully populated response.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request for this page.
*/
function drupal_page_set_cache(Response $response, Request $request) {
// Check if the current page may be compressed.
if (extension_loaded('zlib') && !$response->headers->get('Content-Encoding') &&
\Drupal::config('system.performance')->get('response.gzip')) {
$content = $response->getContent();
if ($content) {
$response->setContent(gzencode($content, 9, FORCE_GZIP));
$response->headers->set('Content-Encoding', 'gzip');
}
// When page compression is enabled, ensure that proxy caches will record
// and deliver different versions of a page depending on whether the
// client supports gzip or not.
$response->setVary('Accept-Encoding', FALSE);
}
// Use the actual timestamp from an Expires header, if available.
$date = $response->getExpires();
$expire = ($date > (new DateTime())) ? $date->getTimestamp() : Cache::PERMANENT;
$cid = drupal_page_cache_get_cid($request);
$tags = explode(' ', $response->headers->get('X-Drupal-Cache-Tags'));
\Drupal::cache('render')->set($cid, $response, $expire, $tags);
}
/**
* Pre-render callback: Renders a link into #markup.
*
......
......@@ -76,6 +76,13 @@ class DrupalKernel implements DrupalKernelInterface, TerminableInterface {
*/
protected $booted = FALSE;
/**
* Whether essential services have been set up properly by preHandle().
*
* @var bool
*/
protected $prepared = FALSE;
/**
* Holds the list of enabled modules.
*
......@@ -474,42 +481,8 @@ public function preHandle(Request $request) {
// Override of Symfony's mime type guesser singleton.
MimeTypeGuesser::registerWithSymfonyGuesser($this->container);
}
/**
* {@inheritdoc}
*
* @todo Invoke proper request/response/terminate events.
*/
public function handlePageCache(Request $request) {
$this->boot();
// Check for a cache mode force from settings.php.
if (Settings::get('page_cache_without_database')) {
$cache_enabled = TRUE;
}
else {
$config = $this->getContainer()->get('config.factory')->get('system.performance');
$cache_enabled = $config->get('cache.page.use_internal');
}
$request_policy = \Drupal::service('page_cache_request_policy');
if ($cache_enabled && $request_policy->check($request) === RequestPolicyInterface::ALLOW) {
// Get the page from the cache.
$response = drupal_page_get_cache($request);
// If there is a cached page, display it.
if ($response) {
$response->headers->set('X-Drupal-Cache', 'HIT');
drupal_serve_page_from_cache($response, $request);
// We are done.
$response->prepare($request);
$response->send();
exit;
}
}
return $this;
$this->prepared = TRUE;
}
/**
......@@ -577,7 +550,9 @@ public function getServiceProviders($origin) {
* {@inheritdoc}
*/
public function terminate(Request $request, Response $response) {
if (FALSE === $this->booted) {
// Only run terminate() when essential services have been set up properly
// by preHandle() before.
if (FALSE === $this->prepared) {
return;
}
......
......@@ -93,16 +93,6 @@ public function getAppRoot();
*/
public function updateModules(array $module_list, array $module_filenames = array());
/**
* Attempts to serve a page from the cache.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return $this
*/
public function handlePageCache(Request $request);
/**
* Prepare the kernel for handling a request without handling the request.
*
......
......@@ -136,17 +136,6 @@ public function onRespond(FilterResponseEvent $event) {
// header declaring the response as not cacheable.
$this->setResponseNotCacheable($response, $request);
}
// 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
if ($is_cacheable && $this->config->get('cache.page.use_internal') && !($response instanceof BinaryFileResponse) && !($response instanceof StreamedResponse)) {
// Store the response in the internal page cache.
drupal_page_set_cache($response, $request);
$response->headers->set('X-Drupal-Cache', 'MISS');
drupal_serve_page_from_cache($response, $request);
}
}
/**
......
......@@ -7,8 +7,16 @@
namespace Drupal\Core\StackMiddleware;
use Drupal\Core\DrupalKernelInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\ContentNegotiation;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
......@@ -24,32 +32,321 @@ class PageCache implements HttpKernelInterface {
protected $httpKernel;
/**
* The main Drupal kernel.
* The cache bin.
*
* @var \Drupal\Core\DrupalKernelInterface
* @var \Drupal\Core\Cache\CacheBackendInterface.
*/
protected $drupalKernel;
protected $cache;
/**
* A policy rule determining the cacheability of a request.
*
* @var \Drupal\Core\PageCache\RequestPolicyInterface
*/
protected $requestPolicy;
/**
* A policy rule determining the cacheability of the response.
*
* @var \Drupal\Core\PageCache\ResponsePolicyInterface
*/
protected $responsePolicy;
/**
* The content negotiation library.
*
* @var \Drupal\Core\ContentNegotiation
*/
protected $contentNegotiation;
/**
* Constructs a ReverseProxyMiddleware object.
*
* @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
* The decorated kernel.
* @param \Drupal\Core\DrupalKernelInterface $drupal_kernel
* The main Drupal kernel.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache bin.
* @param \Drupal\Core\PageCache\RequestPolicyInterface $request_policy
* A policy rule determining the cacheability of a request.
* @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy
* A policy rule determining the cacheability of the response.
* @param \Drupal\Core\ContentNegotiation $content_negotiation
* The content negotiation library.
*/
public function __construct(HttpKernelInterface $http_kernel, DrupalKernelInterface $drupal_kernel) {
public function __construct(HttpKernelInterface $http_kernel, CacheBackendInterface $cache, RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, ContentNegotiation $content_negotiation) {
$this->httpKernel = $http_kernel;
$this->drupalKernel = $drupal_kernel;
$this->cache = $cache;
$this->requestPolicy = $request_policy;
$this->responsePolicy = $response_policy;
$this->contentNegotiation = $content_negotiation;
}
/**
* {@inheritdoc}
*/
public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
$this->drupalKernel->handlePageCache($request);
if ($type !== static::MASTER_REQUEST) {
// Only allow page caching on master request.
$cache_enabled = FALSE;
}
elseif (Settings::get('page_cache_without_database')) {
// Check for a cache mode force from settings.php.
$cache_enabled = TRUE;
}
else {
$config = $this->config('system.performance');
$cache_enabled = $config->get('cache.page.use_internal');
}
if ($cache_enabled && $this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) {
$response = $this->lookup($request, $type, $catch);
}
else {
$response = $this->pass($request, $type, $catch);
}
return $response;
}
/**
* Sidesteps the page cache and directly forwards a request to the backend.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
* @param int $type
* The type of the request (one of HttpKernelInterface::MASTER_REQUEST or
* HttpKernelInterface::SUB_REQUEST)
* @param bool $catch
* Whether to catch exceptions or not
*
* @returns \Symfony\Component\HttpFoundation\Response $response
* A response object.
*/
protected function pass(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
return $this->httpKernel->handle($request, $type, $catch);
}
/**
* Retrieves a response from the cache or fetches it from the backend.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
* @param int $type
* The type of the request (one of HttpKernelInterface::MASTER_REQUEST or
* HttpKernelInterface::SUB_REQUEST)
* @param bool $catch
* Whether to catch exceptions or not
*
* @returns \Symfony\Component\HttpFoundation\Response $response
* A response object.
*/
protected function lookup(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
if ($response = $this->get($request)) {
$response->headers->set('X-Drupal-Cache', 'HIT');
}
else {
$response = $this->fetch($request, $type, $catch);
}
// Only allow caching in the browser and prevent that the response is stored
// by an external proxy server when the following conditions apply:
// 1. There is a session cookie on the request.
// 2. The Vary: Cookie header is on the response.
// 3. The Cache-Control header does not contain the no-cache directive.
if ($request->cookies->has(session_name()) &&
in_array('Cookie', $response->getVary()) &&
!$response->headers->hasCacheControlDirective('no-cache')) {
$response->setPrivate();
}
// Negotiate whether to use compression.
if (extension_loaded('zlib') && $response->headers->get('Content-Encoding') === 'gzip') {
if (strpos($request->headers->get('Accept-Encoding'), 'gzip') !== FALSE) {
// The response content is already gzip'ed, so make sure
// zlib.output_compression does not compress it once more.
ini_set('zlib.output_compression', '0');
}
else {
// The client does not support compression. Decompress the content and
// remove the Content-Encoding header.
$content = $response->getContent();
$content = gzinflate(substr(substr($content, 10), 0, -8));
$response->setContent($content);
$response->headers->remove('Content-Encoding');
}
}
// Perform HTTP revalidation.
// @todo Use Response::isNotModified() as per https://drupal.org/node/2259489
$last_modified = $response->getLastModified();
if ($last_modified) {
// See if the client has provided the required HTTP headers.
$if_modified_since = $request->server->has('HTTP_IF_MODIFIED_SINCE') ? strtotime($request->server->get('HTTP_IF_MODIFIED_SINCE')) : FALSE;
$if_none_match = $request->server->has('HTTP_IF_NONE_MATCH') ? stripslashes($request->server->get('HTTP_IF_NONE_MATCH')) : FALSE;
if ($if_modified_since && $if_none_match
&& $if_none_match == $response->getEtag() // etag must match
&& $if_modified_since == $last_modified->getTimestamp()) { // if-modified-since must match
$response->setStatusCode(304);
$response->setContent(NULL);
// In the case of a 304 response, certain headers must be sent, and the
// remaining may not (see RFC 2616, section 10.3.5).
foreach (array_keys($response->headers->all()) as $name) {
if (!in_array($name, array('content-location', 'expires', 'cache-control', 'vary'))) {
$response->headers->remove($name);
}
}
}
}
return $response;
}
/**
* Fetches a response from the backend and stores it in the cache.
*
* If page_compression is enabled, a gzipped version of the page is stored in
* the cache to avoid compressing the output on each request. The cache entry
* is unzipped in the relatively rare event that the page is requested by a
* client without gzip support.
*
* Page compression requires the PHP zlib extension
* (http://php.net/manual/ref.zlib.php).
*
* @see drupal_page_header()
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
* @param int $type
* The type of the request (one of HttpKernelInterface::MASTER_REQUEST or
* HttpKernelInterface::SUB_REQUEST)
* @param bool $catch
* Whether to catch exceptions or not
*
* @returns \Symfony\Component\HttpFoundation\Response $response
* A response object.
*/
protected function fetch(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
$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
if ($response instanceof BinaryFileResponse || $response instanceof StreamedResponse) {
return $response;
}
if ($this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
return $response;
}
// Check if the current page may be compressed.
if (extension_loaded('zlib') && !$response->headers->get('Content-Encoding') && $this->config('system.performance')->get('response.gzip')) {
$content = $response->getContent();
if ($content) {
$response->setContent(gzencode($content, 9, FORCE_GZIP));
$response->headers->set('Content-Encoding', 'gzip');
}
// When page compression is enabled, ensure that proxy caches will record
// and deliver different versions of a page depending on whether the
// client supports gzip or not.
$response->setVary('Accept-Encoding', FALSE);
}
// Use the actual timestamp from an Expires header, if available.
$date = $response->getExpires();
$expire = ($date > (new \DateTime())) ? $date->getTimestamp() : Cache::PERMANENT;
$tags = explode(' ', $response->headers->get('X-Drupal-Cache-Tags'));
$this->set($request, $response, $expire, $tags);
// Mark response as a cache miss.
$response->headers->set('X-Drupal-Cache', 'MISS');
return $response;
}
/**
* Returns a response object from the page cache.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
* @param bool $allow_invalid
* (optional) If TRUE, a cache item may be returned even if it is expired or
* has been invalidated. Such items may sometimes be preferred, if the
* alternative is recalculating the value stored in the cache, especially
* if another concurrent request is already recalculating the same value.
* The "valid" property of the returned object indicates whether the item is
* valid or not. Defaults to FALSE.
*
* @return \Symfony\Component\HttpFoundation\Response|false
* The cached response or FALSE on failure.
*/
protected function get(Request $request, $allow_invalid = FALSE) {
$cid = $this->getCacheId($request);
if ($cache = $this->cache->get($cid, $allow_invalid)) {
return $cache->data;
}
}
/**
* Stores a response object in the page cache.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
* @param \Symfony\Component\HttpFoundation\Response $response
* The response to store in the cache.
* @param int $expire
* One of the following values:
* - CacheBackendInterface::CACHE_PERMANENT: Indicates that the item should
* not be removed unless it is deleted explicitly.
* - A Unix timestamp: Indicates that the item will be considered invalid
* after this time, i.e. it will not be returned by get() unless
* $allow_invalid has been set to TRUE. When the item has expired, it may
* be permanently deleted by the garbage collector at any time.
* @param array $tags
* An array of tags to be stored with the cache item. These should normally
* identify objects used to build the cache item, which should trigger
* cache invalidation when updated. For example if a cached item represents
* a node, both the node ID and the author's user ID might be passed in as
* tags. For example array('node' => array(123), 'user' => array(92)).
*/
protected function set(Request $request, Response $response, $expire, array $tags) {
$cid = $this->getCacheId($request);
$this->cache->set($cid, $response, $expire, $tags);
}
/**
* Gets the page cache ID for this request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* A request object.
*
* @return string
* The cache ID for this request.
*/
protected function getCacheId(Request $request) {
$cid_parts = array(
$request->getUri(),
$this->contentNegotiation->getContentType($request),
);
return implode(':', $cid_parts);
}
/**
* Wraps Drupal::config().
*
* Config factory is not injected into this class in order to prevent
* premature initialization of config storage (database).
*
* @see \Drupal::config()
*/
protected function config($name) {
return \Drupal::config($name);
}
}
......@@ -25,13 +25,18 @@ public function testDestructionUsed() {
// Enable the test module to add it to the container.
$this->enableModules(array('service_provider_test'));
$request = $this->container->get('request_stack')->getCurrentRequest();
$kernel = $this->container->get('kernel');
$kernel->preHandle($request);
// The service has not been destructed yet.
$this->assertNull(\Drupal::state()->get('service_provider_test.destructed'));
// Call the class and then terminate the kernel
$this->container->get('service_provider_test_class');
$response = new Response();
$this->container->get('kernel')->terminate($this->container->get('request_stack')->getCurrentRequest(), $response);
$kernel->terminate($request, $response);
$this->assertTrue(\Drupal::state()->get('service_provider_test.destructed'));
}
......@@ -42,13 +47,17 @@ public function testDestructionUnused() {
// Enable the test module to add it to the container.
$this->enableModules(array('service_provider_test'));
$request = $this->container->get('request_stack')->getCurrentRequest();
$kernel = $this->container->get('kernel');
$kernel->preHandle($request);
// The service has not been destructed yet.
$this->assertNull(\Drupal::state()->get('service_provider_test.destructed'));
// Terminate the kernel. The test class has not been called, so it should not
// be destructed.
$response = new Response();
$this->container->get('kernel')->terminate($this->container->get('request_stack')->getCurrentRequest(), $response);
$kernel->terminate($request, $response);
$this->assertNull(\Drupal::state()->get('service_provider_test.destructed'));
}
}
......@@ -26,7 +26,6 @@
$request = Request::createFromGlobals();
$kernel = TestKernel::createFromRequest($request, $autoloader, 'testing', TRUE);
$response = $kernel
->handlePageCache($request)
->handle($request)
// Handle the response object.
->prepare($request)->send();
......