summaryrefslogtreecommitdiffstats
path: root/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
blob: 63689a16bdae53202d102af41efcba1dfa8c4fe9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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
192
193
194
195
196
197
198
199
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
225
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
317
318
319
320
321
322
323
324
325
326
327
328
329
<?php

/**
 * @file
 * Contains \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber.
 */

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.
 *
 * Dynamic Page Cache is able to cache so much because it utilizes cache
 * 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',
    ],
  ];

  /**
   * Internal cache of request policy results.
   *
   * @var \SplObjectStorage
   */
  protected $requestPolicyResults;

  /**
   * 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;
    $this->requestPolicyResults = new \SplObjectStorage();
  }

  /**
   * 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);
    $this->requestPolicyResults[$request] = $request_policy_result;
    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
    // access and modify the cacheability metadata associated with the
    // response.)
    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();
    if (!isset($this->requestPolicyResults[$request])) {
      return;
    }

    // Don't cache the response if the Dynamic Page Cache request & response
    // policies are not met.
    // @see onRouteMatch()
    if ($this->requestPolicyResults[$request] === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
      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
   * of the placeholdered content does not bubble up to the response level.
   *
   * @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
    // 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).
    $events[KernelEvents::REQUEST][] = ['onRouteMatch', 27];

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

    return $events;
  }

}