DynamicPageCacheSubscriber.php 12.2 KB
Newer Older
1 2 3 4
<?php

/**
 * @file
5
 * Contains \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber.
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
 */

namespace Drupal\dynamic_page_cache\EventSubscriber;

use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\RenderCacheInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Returns cached responses as early and avoiding as much work as possible.
 *
25
 * Dynamic Page Cache is able to cache so much because it utilizes cache
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
 * contexts: the cache contexts that are present capture the variations of every
 * component of the page. That, combined with the fact that cacheability
 * metadata is bubbled, means that the cache contexts at the page level
 * represent the complete set of contexts that the page varies by.
 *
 * The reason Dynamic Page Cache is implemented as two event subscribers (a late
 * REQUEST subscriber immediately after routing for cache hits, and an early
 * RESPONSE subscriber for cache misses) is because many cache contexts can only
 * be evaluated after routing. (Examples: 'user', 'user.permissions', 'route' …)
 * Consequently, it is impossible to implement Dynamic Page Cache as a kernel
 * middleware that simply caches per URL.
 *
 * @see \Drupal\Core\Render\MainContent\HtmlRenderer
 * @see \Drupal\Core\Cache\CacheableResponseInterface
 */
class DynamicPageCacheSubscriber implements EventSubscriberInterface {

  /**
   * Name of Dynamic Page Cache's response header.
   */
  const HEADER = 'X-Drupal-Dynamic-Cache';

  /**
   * A request policy rule determining the cacheability of a response.
   *
   * @var \Drupal\Core\PageCache\RequestPolicyInterface
   */
  protected $requestPolicy;

  /**
   * A response policy rule determining the cacheability of the response.
   *
   * @var \Drupal\Core\PageCache\ResponsePolicyInterface
   */
  protected $responsePolicy;

  /**
   * The render cache.
   *
   * @var \Drupal\Core\Render\RenderCacheInterface
   */
  protected $renderCache;

  /**
   * The renderer configuration array.
   *
   * @var array
   */
  protected $rendererConfig;

  /**
   * Dynamic Page Cache's redirect render array.
   *
   * @var array
   */
  protected $dynamicPageCacheRedirectRenderArray = [
    '#cache' => [
      'keys' => ['response'],
      'contexts' => [
        'route',
        // Some routes' controllers rely on the request format (they don't have
        // a separate route for each request format). Additionally, a controller
        // may be returning a domain object that a KernelEvents::VIEW subscriber
        // must turn into an actual response, but perhaps a format is being
        // requested that the subscriber does not support.
        // @see \Drupal\Core\EventSubscriber\AcceptNegotiation406::onViewDetect406()
        'request_format',
      ],
      'bin' => 'dynamic_page_cache',
    ],
  ];

98 99 100 101 102 103 104
  /**
   * Internal cache of request policy results.
   *
   * @var \SplObjectStorage
   */
  protected $requestPolicyResults;

105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
  /**
   * Constructs a new DynamicPageCacheSubscriber object.
   *
   * @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\Render\RenderCacheInterface $render_cache
   *   The render cache.
   * @param array $renderer_config
   *   The renderer configuration array.
   */
  public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, RenderCacheInterface $render_cache, array $renderer_config) {
    $this->requestPolicy = $request_policy;
    $this->responsePolicy = $response_policy;
    $this->renderCache = $render_cache;
    $this->rendererConfig = $renderer_config;
122
    $this->requestPolicyResults = new \SplObjectStorage();
123 124 125 126 127 128 129 130 131 132 133 134 135 136
  }

  /**
   * Sets a response in case of a Dynamic Page Cache hit.
   *
   * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
   *   The event to process.
   */
  public function onRouteMatch(GetResponseEvent $event) {
    // Don't cache the response if the Dynamic Page Cache request policies are
    // not met. Store the result in a request attribute, so that onResponse()
    // does not have to redo the request policy check.
    $request = $event->getRequest();
    $request_policy_result = $this->requestPolicy->check($request);
137
    $this->requestPolicyResults[$request] = $request_policy_result;
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
    if ($request_policy_result === RequestPolicyInterface::DENY) {
      return;
    }

    // Sets the response for the current route, if cached.
    $cached = $this->renderCache->get($this->dynamicPageCacheRedirectRenderArray);
    if ($cached) {
      $response = $this->renderArrayToResponse($cached);
      $response->headers->set(self::HEADER, 'HIT');
      $event->setResponse($response);
    }
  }

  /**
   * Stores a response in case of a Dynamic Page Cache miss, if cacheable.
   *
   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
   *   The event to process.
   */
  public function onResponse(FilterResponseEvent $event) {
    $response = $event->getResponse();

    // Dynamic Page Cache only works with cacheable responses. It does not work
    // with plain Response objects. (Dynamic Page Cache needs to be able to
162 163
    // access and modify the cacheability metadata associated with the
    // response.)
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
    if (!$response instanceof CacheableResponseInterface) {
      return;
    }

    // There's no work left to be done if this is a Dynamic Page Cache hit.
    if ($response->headers->get(self::HEADER) === 'HIT') {
      return;
    }

    // There's no work left to be done if this is an uncacheable response.
    if (!$this->shouldCacheResponse($response)) {
      // The response is uncacheable, mark it as such.
      $response->headers->set(self::HEADER, 'UNCACHEABLE');
      return;
    }

    // Don't cache the response if Dynamic Page Cache's request subscriber did
    // not fire, because that means it is impossible to have a Dynamic Page
    // Cache hit. (This can happen when the master request is for example a 403
    // or 404, in which case a subrequest is performed by the router. In that
    // case, it is the subrequest's response that is cached by Dynamic Page
    // Cache, because the routing happens in a request subscriber earlier than
    // Dynamic Page Cache's and immediately sets a response, i.e. the one
    // returned by the subrequest, and thus causes Dynamic Page Cache's request
    // subscriber to not fire for the master request.)
    // @see \Drupal\Core\Routing\AccessAwareRouter::checkAccess()
    // @see \Drupal\Core\EventSubscriber\DefaultExceptionHtmlSubscriber::on403()
    $request = $event->getRequest();
192
    if (!isset($this->requestPolicyResults[$request])) {
193 194 195 196 197 198
      return;
    }

    // Don't cache the response if the Dynamic Page Cache request & response
    // policies are not met.
    // @see onRouteMatch()
199
    if ($this->requestPolicyResults[$request] === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
      return;
    }

    // Embed the response object in a render array so that RenderCache is able
    // to cache it, handling cache redirection for us.
    $response_as_render_array = $this->responseToRenderArray($response);
    $this->renderCache->set($response_as_render_array, $this->dynamicPageCacheRedirectRenderArray);

    // The response was generated, mark the response as a cache miss. The next
    // time, it will be a cache hit.
    $response->headers->set(self::HEADER, 'MISS');
  }

  /**
   * Whether the given response should be cached by Dynamic Page Cache.
   *
   * We consider any response that has cacheability metadata meeting the auto-
   * placeholdering conditions to be uncacheable. Because those conditions
   * indicate poor cacheability, and if it doesn't make sense to cache parts of
   * a page, then neither does it make sense to cache an entire page.
   *
   * But note that auto-placeholdering avoids such cacheability metadata ever
   * bubbling to the response level: while rendering, the Renderer checks every
   * subtree to see if meets the auto-placeholdering conditions. If it does, it
   * is automatically placeholdered, and consequently the cacheability metadata
225
   * of the placeholdered content does not bubble up to the response level.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
   *
   * @param \Drupal\Core\Cache\CacheableResponseInterface
   *   The response whose cacheability to analyze.
   *
   * @return bool
   *   Whether the given response should be cached.
   *
   * @see \Drupal\Core\Render\Renderer::shouldAutomaticallyPlaceholder()
   */
  protected function shouldCacheResponse(CacheableResponseInterface $response) {
    $conditions = $this->rendererConfig['auto_placeholder_conditions'];

    $cacheability = $response->getCacheableMetadata();

    // Response's max-age is at or below the configured threshold.
    if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) {
      return FALSE;
    }

    // Response has a high-cardinality cache context.
    if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) {
      return FALSE;
    }

    // Response has a high-invalidation frequency cache tag.
    if (array_intersect($cacheability->getCacheTags(), $conditions['tags'])) {
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Embeds a Response object in a render array so that RenderCache can cache it.
   *
   * @param \Drupal\Core\Cache\CacheableResponseInterface $response
   *   A cacheable response.
   *
   * @return array
   *   A render array that embeds the given cacheable response object, with the
   *   cacheability metadata of the response object present in the #cache
   *   property of the render array.
   *
   * @see renderArrayToResponse()
   *
   * @todo Refactor/remove once https://www.drupal.org/node/2551419 lands.
   */
  protected function responseToRenderArray(CacheableResponseInterface $response) {
    $response_as_render_array = $this->dynamicPageCacheRedirectRenderArray + [
      // The data we actually care about.
      '#response' => $response,
      // Tell RenderCache to cache the #response property: the data we actually
      // care about.
      '#cache_properties' => ['#response'],
      // These exist only to fulfill the requirements of the RenderCache, which
      // is designed to work with render arrays only. We don't care about these.
      '#markup' => '',
      '#attached' => '',
    ];

    // Merge the response's cacheability metadata, so that RenderCache can take
    // care of cache redirects for us.
    CacheableMetadata::createFromObject($response->getCacheableMetadata())
      ->merge(CacheableMetadata::createFromRenderArray($response_as_render_array))
      ->applyTo($response_as_render_array);

    return $response_as_render_array;
  }

  /**
   * Gets the embedded Response object in a render array.
   *
   * @param array $render_array
   *   A render array with a #response property.
   *
   * @return \Drupal\Core\Cache\CacheableResponseInterface
   *   The cacheable response object.
   *
   * @see responseToRenderArray()
   */
  protected function renderArrayToResponse(array $render_array) {
    return $render_array['#response'];
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events = [];

    // Run after AuthenticationSubscriber (necessary for the 'user' cache
317 318 319 320
    // context; priority 300) and MaintenanceModeSubscriber (Dynamic Page Cache
    // should not be polluted by maintenance mode-specific behavior; priority
    // 30), but before ContentControllerSubscriber (updates _controller, but
    // that is a no-op when Dynamic Page Cache runs; priority 25).
321 322 323 324 325 326 327 328 329
    $events[KernelEvents::REQUEST][] = ['onRouteMatch', 27];

    // Run before HtmlResponseSubscriber::onRespond(), which has priority 0.
    $events[KernelEvents::RESPONSE][] = ['onResponse', 100];

    return $events;
  }

}