Особенности max-age в Друпал 8

Drupal 8

Вольный перевод статьи Common max-age Pitfalls When Working with Drupal's Page Cache

В Друпал 8 очень гибкая система кэширования. Прочитав множество хвалебных статей вы можете подумать, что использование max-age в рендер элементах (блоки, меню, ссылки, формы и т.д.) это нормально. Давайте посмотрим и попытаемся понять, что же происходит на самом деле.

В прошлом проекте у нас была задача выводить определенную информацию из внешнего API. Наш сайт Друпал должен был просто получить актуальную информацию из внешнего источника и отобразить информацию в блоке.

Речь идет о данном сайте этот сайт предназначен для правительственного агенства штата Джорджия, у которого есть несколько центров обслуживая клиентов. Когда пользователь заходит на страницу центра обслуживания наш блок должен отображать:

  1. Время ожидания в данном центре;
  2. Время ожидания в ближайшем центре обслуживания.
alpharetta_wait_times_example

Как вы можете видеть, выделенный блок динамичен и должен показывать актуальные данные. Максимальное время жизни кэша должно составлять не более 1-й минуты.

За время жизни кэша в Друпале отвечает свойство max-age.

(✋ Дальше я предполагаю, что у вас есть некоторый базовый уровень понимания системы кэширования Drupal и ее соответствующих частей. Если вы еще не знакомы с кэшированием то обязательно ознакомьтесь с оф. документацие или как минимум с отличной статьей от Niklan)

Самым простым способом создания подобного блока, это создать собственный плагин блок и задать ему максимальное время жизни кэша. Посколько базовый блок плагин реализует \Drupal\Core\Cache\CacheableDependencyInterface то нам достаточно переопределить метод getCacheMaxAge.

namespace Drupal\foo\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
* Provides a block with time-sensitive content.
*
* @Block(
* id = "foo",
* admin_label = @Translation("Foo block"),
* category = @Translation("Foo custom blocks"),
* context = {
* "node" = @ContextDefinition("entity:node", required = TRUE, label = @Translation("Node"))
* }
* )
*/
class FooBlock extends BlockBase {

/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
// Cache this for 1 minute.
return 60;
}

/**
* {@inheritdoc}
*/
public function build() {
$build = [];

// Retrieve our remote data, and populate the $build render array.
return $build;
}

}

После сброса кэша друпала и размещения нашего нового блока на странице, мы увидем подобный заголовок страницы.

locations_headers

Почему max-age = 31536000, если мы намеренно установили его на 60 в нашем блоке?

Это довольно известная проблема. На данный момент Друпал не может повлиять на внешний заголовок ответа страницы из рендер массива страницы. Поскольку данная проблема довольна большая, пройдемся по основным моментам ниже.

Для начало посмотрим на max-age=31536000.

Это значение берется из настроек на странице: Конфигурация > Разработка > Производительность:Максимальный возраст кэша (а если точнее то в значении конфигурации system.performance:cache.page.max_age). Друпал будет использовать это значение для всех анонимных посетителей.

Определив причину такого поведения давайте попытаемся ответить на вопрос. Как мы можем изменить заголовок ответа max-age?  

Если несколько вариантов. Тот который мы будет использовать похож на комментарий @Berdet, потому, что мы хотим изменять заголовок ответа для определенных страниц. Альтернативой является использование модуля  Cache-Control Override, но у данного модуля есть некоторые ограничения. И поэтому давайте подпишемся на события KernelEvents::RESPONSE.

namespace Drupal\foo\EventSubscriber;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Page response subscriber to set appropriate headers on anonymous requests.
*/
class FooCacheResponseSubscriber implements EventSubscriberInterface {

/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;

/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $user;

/**
* Class constructor.
*
* @param \Drupal\Component\Datetime\TimeInterface $time
* The Time service.
* @param \Drupal\Core\Session\AccountInterface $user
* Current user.
*/
public function __construct(TimeInterface $time, AccountInterface $user) {
$this->time = $time;
$this->user = $user;
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[KernelEvents::RESPONSE][] = ['onResponse'];
return $events;
}

/**
* Sets expires and max-age for bubbled-up max-age values that are > 0.
*
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
* The response event.
*
* @throws \Exception
* Thrown when \DateTime() cannot create a new date object from the
* arguments passed in.
*/
public function onResponse(FilterResponseEvent $event) {
// Don't bother proceeding on sub-requests.
if (!$event->isMasterRequest()) {
return;
}
$response = $event->getResponse();

// Nothing to do here if there isn't cacheable metadata available.
if (!($response instanceof CacheableResponseInterface)) {
return;
}

// Bail out early if this isn't an anonymous request.
if ($this->user->isAuthenticated()) {
return;
}

// Do some other crazy business logic, if necessary.

$max_age = (int) $response->getCacheableMetadata()->getCacheMaxAge();
if ($max_age !== Cache::PERMANENT) {
// Here we do 2 things: 1) we forward the bubbled max-age to the response
// Cache-Control "max-age" directive (which would otherwise take the
// site-wide `system.performance:cache.page.max_age` value; and 2) we
// replicate that into the "Expires" header, which is unfortunately what
// Drupal's internal page cache will respect. The former is for the outer
// world (proxies, CDNs, etc), and the latter for our own page cache.
$response->setMaxAge($max_age);
$date = new \DateTime('@' . ($this->time->getRequestTime() + $max_age));
$response->setExpires($date);
}
}

}

И определим нашу подписку в foo.services.yml:

  foo.foo_cache_response_subscriber:
class: Drupal\foo\EventSubscriber\FooResponseSubscriber
arguments: ['@datetime.time', '@current_user']
tags:
- { name: event_subscriber }

Все что делает наш код это берет все не постоянные значения (-1 или \Drupal\Core\Cache\Cache::PERMANENT) из системы рендеринга и устанавливает его в заголовок ответа. Преимуществом использования пользовательского сервиса состоит в то, что мы имеем очень гибкий контроль.Так же стоит отметить, что мы устанавливаем здесь такие значения как max-age Cache-Control и Expires. Модуль Cache-Control Override на текущий момент позволяет устанавливать только max-age Cache-Control. Данный вопрос обсуждается тут.

Итак, если наш код работает, значит мы должны увидеть в заголовках на странице где выведен наш блок время кэша 60 секунд. Верно?

К сожалению нет. Если мы посмотрим на значение $max_age в строке:

$max_age = (int) $response->getCacheableMetadata()->getCacheMaxAge();

Мы увидем что $max-age равен 0, для страницы где выводится наш блок.

Если мы начнем разбираться, то мы доберемся до \Drupal\Core\Cache\Cache::mergeMaxAges(), где мы увидем что возвращается минимальное значение $max_age из всего рендер массива страницы.

/**
* Merges max-age values (expressed in seconds), finds the lowest max-age.
*
* Ensures infinite max-age (Cache::PERMANENT) is taken into account.
*
* @param int $a
* Max age value to merge.
* @param int $b
* Max age value to merge.
*
* @return int
* The minimum max-age value.
*/
public static function mergeMaxAges($a = Cache::PERMANENT, $b = Cache::PERMANENT) {
// If one of the values is Cache::PERMANENT, return the other value.
if ($a === Cache::PERMANENT) {
return $b;
}
if ($b === Cache::PERMANENT) {
return $a;
}

// If none or the values are Cache::PERMANENT, return the minimum value.
return min($a, $b);
}

Из всего рендер массива страницы - это означает, что при "слиянии" рендер массивов, определенных на странице (к ним относятся все элементы на странице: блоки, меню, Views, ViewMode сущностей и т.д.) будет возвращен самый минимальный из них. То что наш $max_age всегда равен 0, говорит нам о том, что у нас есть какой то элемент на странице с указанным значение 'max-age' равным 0 ('#cache' => [ 'max-age' => 0, ]).

Довольно часто разработчики, не подозревая о таком поведении, часто используют max_age = 0. Что бы отключить динамический кеш для своего блока, не подозревая о том, что он может повлиять на заголовок ответа всей страницы.

  /**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
// Возвращаем "0" что бы сказать "не кешируй этот блок".
return 0;
}

Когда вы столкнетесь с тем что max-age в заголовке вашей страницы не такой как вы ожидаете, то в первую очередь вы должны найти тот элемент, где используется max_age = 0. К сожалению нет простого механизма отладки. Но есть некоторые подходы к отладке, которые возможно вам помогут. 

  1. Получить список всех тегов и контекстов из $response->getCacheableMetadata(). Возможно вы сможете определить "бракованый" блок;
  2. Использование Xdebug с брекпоинтов в методе Cache::mergeMaxAges().
  3. Попробуйте модуль Renderviz

Итоги:

  • Если у вас на странице используется блок зависящий от времени max-age, проверьте/посмотрите на данную проблему;
  • При необходимости создайте подписчика событий для управления заголовками ответа;
  • Избегайте использования max-age=0