RenderCache.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <?php
  2. namespace Drupal\Core\Render;
  3. use Drupal\Core\Cache\Cache;
  4. use Drupal\Core\Cache\CacheableMetadata;
  5. use Drupal\Core\Cache\Context\CacheContextsManager;
  6. use Drupal\Core\Cache\CacheFactoryInterface;
  7. use Symfony\Component\HttpFoundation\RequestStack;
  8. /**
  9. * Wraps the caching logic for the render caching system.
  10. *
  11. * @internal
  12. *
  13. * @todo Refactor this out into a generic service capable of cache redirects,
  14. * and let RenderCache use that. https://www.drupal.org/node/2551419
  15. */
  16. class RenderCache implements RenderCacheInterface {
  17. /**
  18. * The request stack.
  19. *
  20. * @var \Symfony\Component\HttpFoundation\RequestStack
  21. */
  22. protected $requestStack;
  23. /**
  24. * The cache factory.
  25. *
  26. * @var \Drupal\Core\Cache\CacheFactoryInterface
  27. */
  28. protected $cacheFactory;
  29. /**
  30. * The cache contexts manager.
  31. *
  32. * @var \Drupal\Core\Cache\Context\CacheContextsManager
  33. */
  34. protected $cacheContextsManager;
  35. /**
  36. * Constructs a new RenderCache object.
  37. *
  38. * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
  39. * The request stack.
  40. * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory
  41. * The cache factory.
  42. * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
  43. * The cache contexts manager.
  44. */
  45. public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager) {
  46. $this->requestStack = $request_stack;
  47. $this->cacheFactory = $cache_factory;
  48. $this->cacheContextsManager = $cache_contexts_manager;
  49. }
  50. /**
  51. * {@inheritdoc}
  52. */
  53. public function get(array $elements) {
  54. // Form submissions rely on the form being built during the POST request,
  55. // and render caching of forms prevents this from happening.
  56. // @todo remove the isMethodCacheable() check when
  57. // https://www.drupal.org/node/2367555 lands.
  58. if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) {
  59. return FALSE;
  60. }
  61. $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
  62. if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) {
  63. $cached_element = $cache->data;
  64. // Two-tier caching: redirect to actual (post-bubbling) cache item.
  65. // @see \Drupal\Core\Render\RendererInterface::render()
  66. // @see ::set()
  67. if (isset($cached_element['#cache_redirect'])) {
  68. return $this->get($cached_element);
  69. }
  70. // Return the cached element.
  71. return $cached_element;
  72. }
  73. return FALSE;
  74. }
  75. /**
  76. * {@inheritdoc}
  77. */
  78. public function set(array &$elements, array $pre_bubbling_elements) {
  79. // Form submissions rely on the form being built during the POST request,
  80. // and render caching of forms prevents this from happening.
  81. // @todo remove the isMethodCacheable() check when
  82. // https://www.drupal.org/node/2367555 lands.
  83. if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) {
  84. return FALSE;
  85. }
  86. $data = $this->getCacheableRenderArray($elements);
  87. $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
  88. $cache = $this->cacheFactory->get($bin);
  89. // Calculate the pre-bubbling CID.
  90. $pre_bubbling_cid = $this->createCacheID($pre_bubbling_elements);
  91. // Two-tier caching: detect different CID post-bubbling, create redirect,
  92. // update redirect if different set of cache contexts.
  93. // @see \Drupal\Core\Render\RendererInterface::render()
  94. // @see ::get()
  95. if ($pre_bubbling_cid && $pre_bubbling_cid !== $cid) {
  96. // The cache redirection strategy we're implementing here is pretty
  97. // simple in concept. Suppose we have the following render structure:
  98. // - A (pre-bubbling, specifies #cache['keys'] = ['foo'])
  99. // -- B (specifies #cache['contexts'] = ['b'])
  100. //
  101. // At the time that we're evaluating whether A's rendering can be
  102. // retrieved from cache, we won't know the contexts required by its
  103. // children (the children might not even be built yet), so cacheGet()
  104. // will only be able to get what is cached for a $cid of 'foo'. But at
  105. // the time we're writing to that cache, we do know all the contexts that
  106. // were specified by all children, so what we need is a way to
  107. // persist that information between the cache write and the next cache
  108. // read. So, what we can do is store the following into 'foo':
  109. // [
  110. // '#cache_redirect' => TRUE,
  111. // '#cache' => [
  112. // ...
  113. // 'contexts' => ['b'],
  114. // ],
  115. // ]
  116. //
  117. // This efficiently lets cacheGet() redirect to a $cid that includes all
  118. // of the required contexts. The strategy is on-demand: in the case where
  119. // there aren't any additional contexts required by children that aren't
  120. // already included in the parent's pre-bubbled #cache information, no
  121. // cache redirection is needed.
  122. //
  123. // When implementing this redirection strategy, special care is needed to
  124. // resolve potential cache ping-pong problems. For example, consider the
  125. // following render structure:
  126. // - A (pre-bubbling, specifies #cache['keys'] = ['foo'])
  127. // -- B (pre-bubbling, specifies #cache['contexts'] = ['b'])
  128. // --- C (pre-bubbling, specifies #cache['contexts'] = ['c'])
  129. // --- D (pre-bubbling, specifies #cache['contexts'] = ['d'])
  130. //
  131. // Additionally, suppose that:
  132. // - C only exists for a 'b' context value of 'b1'
  133. // - D only exists for a 'b' context value of 'b2'
  134. // This is an acceptable variation, since B specifies that its contents
  135. // vary on context 'b'.
  136. //
  137. // A naive implementation of cache redirection would result in the
  138. // following:
  139. // - When a request is processed where context 'b' = 'b1', what would be
  140. // cached for a $pre_bubbling_cid of 'foo' is:
  141. // [
  142. // '#cache_redirect' => TRUE,
  143. // '#cache' => [
  144. // ...
  145. // 'contexts' => ['b', 'c'],
  146. // ],
  147. // ]
  148. // - When a request is processed where context 'b' = 'b2', we would
  149. // retrieve the above from cache, but when following that redirection,
  150. // get a cache miss, since we're processing a 'b' context value that
  151. // has not yet been cached. Given the cache miss, we would continue
  152. // with rendering the structure, perform the required context bubbling
  153. // and then overwrite the above item with:
  154. // [
  155. // '#cache_redirect' => TRUE,
  156. // '#cache' => [
  157. // ...
  158. // 'contexts' => ['b', 'd'],
  159. // ],
  160. // ]
  161. // - Now, if a request comes in where context 'b' = 'b1' again, the above
  162. // would redirect to a cache key that doesn't exist, since we have not
  163. // yet cached an item that includes 'b'='b1' and something for 'd'. So
  164. // we would process this request as a cache miss, at the end of which,
  165. // we would overwrite the above item back to:
  166. // [
  167. // '#cache_redirect' => TRUE,
  168. // '#cache' => [
  169. // ...
  170. // 'contexts' => ['b', 'c'],
  171. // ],
  172. // ]
  173. // - The above would always result in accurate renderings, but would
  174. // result in poor performance as we keep processing requests as cache
  175. // misses even though the target of the redirection is cached, and
  176. // it's only the redirection element itself that is creating the
  177. // ping-pong problem.
  178. //
  179. // A way to resolve the ping-pong problem is to eventually reach a cache
  180. // state where the redirection element includes all of the contexts used
  181. // throughout all requests:
  182. // [
  183. // '#cache_redirect' => TRUE,
  184. // '#cache' => [
  185. // ...
  186. // 'contexts' => ['b', 'c', 'd'],
  187. // ],
  188. // ]
  189. //
  190. // We can't reach that state right away, since we don't know what the
  191. // result of future requests will be, but we can incrementally move
  192. // towards that state by progressively merging the 'contexts' value
  193. // across requests. That's the strategy employed below and tested in
  194. // \Drupal\Tests\Core\Render\RendererBubblingTest::testConditionalCacheContextBubblingSelfHealing().
  195. // Get the cacheability of this element according to the current (stored)
  196. // redirecting cache item, if any.
  197. $redirect_cacheability = new CacheableMetadata();
  198. if ($stored_cache_redirect = $cache->get($pre_bubbling_cid)) {
  199. $redirect_cacheability = CacheableMetadata::createFromRenderArray($stored_cache_redirect->data);
  200. }
  201. // Calculate the union of the cacheability for this request and the
  202. // current (stored) redirecting cache item. We need:
  203. // - the union of cache contexts, because that is how we know which cache
  204. // item to redirect to;
  205. // - the union of cache tags, because that is how we know when the cache
  206. // redirect cache item itself is invalidated;
  207. // - the union of max ages, because that is how we know when the cache
  208. // redirect cache item itself becomes stale. (Without this, we might end
  209. // up toggling between a permanently and a briefly cacheable cache
  210. // redirect, because the last update's max-age would always "win".)
  211. $redirect_cacheability_updated = CacheableMetadata::createFromRenderArray($data)->merge($redirect_cacheability);
  212. // Stored cache contexts incomplete: this request causes cache contexts to
  213. // be added to the redirecting cache item.
  214. if (array_diff($redirect_cacheability_updated->getCacheContexts(), $redirect_cacheability->getCacheContexts())) {
  215. $redirect_data = [
  216. '#cache_redirect' => TRUE,
  217. '#cache' => [
  218. // The cache keys of the current element; this remains the same
  219. // across requests.
  220. 'keys' => $elements['#cache']['keys'],
  221. // The union of the current element's and stored cache contexts.
  222. 'contexts' => $redirect_cacheability_updated->getCacheContexts(),
  223. // The union of the current element's and stored cache tags.
  224. 'tags' => $redirect_cacheability_updated->getCacheTags(),
  225. // The union of the current element's and stored cache max-ages.
  226. 'max-age' => $redirect_cacheability_updated->getCacheMaxAge(),
  227. // The same cache bin as the one for the actual render cache items.
  228. 'bin' => $bin,
  229. ],
  230. ];
  231. $cache->set($pre_bubbling_cid, $redirect_data, $this->maxAgeToExpire($redirect_cacheability_updated->getCacheMaxAge()), Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
  232. }
  233. // Current cache contexts incomplete: this request only uses a subset of
  234. // the cache contexts stored in the redirecting cache item. Vary by these
  235. // additional (conditional) cache contexts as well, otherwise the
  236. // redirecting cache item would be pointing to a cache item that can never
  237. // exist.
  238. if (array_diff($redirect_cacheability_updated->getCacheContexts(), $data['#cache']['contexts'])) {
  239. // Recalculate the cache ID.
  240. $recalculated_cid_pseudo_element = [
  241. '#cache' => [
  242. 'keys' => $elements['#cache']['keys'],
  243. 'contexts' => $redirect_cacheability_updated->getCacheContexts(),
  244. ],
  245. ];
  246. $cid = $this->createCacheID($recalculated_cid_pseudo_element);
  247. // Ensure the about-to-be-cached data uses the merged cache contexts.
  248. $data['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts();
  249. }
  250. }
  251. $cache->set($cid, $data, $this->maxAgeToExpire($elements['#cache']['max-age']), Cache::mergeTags($data['#cache']['tags'], ['rendered']));
  252. }
  253. /**
  254. * Maps a #cache[max-age] value to an "expire" value for the Cache API.
  255. *
  256. * @param int $max_age
  257. * A #cache[max-age] value.
  258. *
  259. * @return int
  260. * A corresponding "expire" value.
  261. *
  262. * @see \Drupal\Core\Cache\CacheBackendInterface::set()
  263. */
  264. protected function maxAgeToExpire($max_age) {
  265. return ($max_age === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME') + $max_age;
  266. }
  267. /**
  268. * Creates the cache ID for a renderable element.
  269. *
  270. * Creates the cache ID string based on #cache['keys'] + #cache['contexts'].
  271. *
  272. * @param array &$elements
  273. * A renderable array.
  274. *
  275. * @return string
  276. * The cache ID string, or FALSE if the element may not be cached.
  277. */
  278. protected function createCacheID(array &$elements) {
  279. // If the maximum age is zero, then caching is effectively prohibited.
  280. if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) {
  281. return FALSE;
  282. }
  283. if (isset($elements['#cache']['keys'])) {
  284. $cid_parts = $elements['#cache']['keys'];
  285. if (!empty($elements['#cache']['contexts'])) {
  286. $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($elements['#cache']['contexts']);
  287. $cid_parts = array_merge($cid_parts, $context_cache_keys->getKeys());
  288. CacheableMetadata::createFromRenderArray($elements)
  289. ->merge($context_cache_keys)
  290. ->applyTo($elements);
  291. }
  292. return implode(':', $cid_parts);
  293. }
  294. return FALSE;
  295. }
  296. /**
  297. * {@inheritdoc}
  298. */
  299. public function getCacheableRenderArray(array $elements) {
  300. $data = [
  301. '#markup' => $elements['#markup'],
  302. '#attached' => $elements['#attached'],
  303. '#cache' => [
  304. 'contexts' => $elements['#cache']['contexts'],
  305. 'tags' => $elements['#cache']['tags'],
  306. 'max-age' => $elements['#cache']['max-age'],
  307. ],
  308. ];
  309. // Preserve cacheable items if specified. If we are preserving any cacheable
  310. // children of the element, we assume we are only interested in their
  311. // individual markup and not the parent's one, thus we empty it to minimize
  312. // the cache entry size.
  313. if (!empty($elements['#cache_properties']) && is_array($elements['#cache_properties'])) {
  314. $data['#cache_properties'] = $elements['#cache_properties'];
  315. // Extract all the cacheable items from the element using cache
  316. // properties.
  317. $cacheable_items = array_intersect_key($elements, array_flip($elements['#cache_properties']));
  318. $cacheable_children = Element::children($cacheable_items);
  319. if ($cacheable_children) {
  320. $data['#markup'] = '';
  321. // Cache only cacheable children's markup.
  322. foreach ($cacheable_children as $key) {
  323. // We can assume that #markup is safe at this point.
  324. $cacheable_items[$key] = ['#markup' => Markup::create($cacheable_items[$key]['#markup'])];
  325. }
  326. }
  327. $data += $cacheable_items;
  328. }
  329. $data['#markup'] = Markup::create($data['#markup']);
  330. return $data;
  331. }
  332. }