Drupal 8: #cache — cache tags, context и max-age

Всё что нужно знать об основном кэшировании в восьмерке.

15.07.2017
29 комментариев
23 мин.

Продолжая статью про #lazy_builder мы переходим к самому кэшированию в Drupal 8.

Так как в 8-ке всё перевели на Render API, то кэширование теперь внедряется прямо на уровне render array при помощи ключа #cache и делится на три самостоятельных типа:

  • tags: Прямо так и понимайте — это теги кэша, на основе которых Drupal принимает решение, обновить какой-то кэш или нет. Это такие "зависимости", и если одна из них поменяется — кэш содержащий данный тег(и) автоматически инвалидируется.
  • contexts: Данный тип кэша позволяет делать кэш вариативным, например для разных ролей. Это уже деление кэша на разные "варианты" в зависимости от контекста вызова содержимого. Очень часто основываются на HTTP запросе.
  • max-age: Самый простой тип кэша, который хранит в секундах, как долго данный кэш будет жить. Как только время пройдет — он инвалидируется. При помощи данного свойства можно отключать кэширование вообще, так и делать его вечным.

То же самое в картинке:

Схема выбора кэширования

Все три вышеперечисленные типы кэширования могут работать вместе, они лишь дополняют друг-друга, также они могут быть во вложенных элементах.

Если сократить до одного предложения, то: contexts отвечает за вариативность кэша в зависимости от условий запроса содержимого, tags за связи с какими-то элементами, сущностями, содержимым и т.п. для которых данныйэ кэш актуален и изменения данных связей повелечет инвалидацию кэша и самый простой max-age отвечает за время жизни самого кэша.

Должно быть всё просто, так как ничего сложного, на самом деле, нет. Что касается наследования, то все #cache элементы наследуются от дочерних элементов к родительскому. Они обьединяются в одну конфигурацию. Если, например, главная страница сделана при помощи Views, который выводит материалы, и там, допустим, есть node с id 10. То теги получатся: node:10, config:views.view.VIEW_NAME и остальные ноды попавшие на страницу. Таким образом, изменение одной из нод или настроек вьюса, повлечет за собой инвалидацию данного кэша.

Разбирать каждый из вариантов кэширования мы будем по очереди, чтобы не было каши. Поняв за что отвечает каждый из них, и как он влияет на жизнь и поведение кэша, у вас не будет сложности объединять их между собой.

Cache tags

Теги кэша — это такие метки для кэша, на основе которых он определяет какой кэш нужно инвалидировать для последующей генерации нового содержимого.

Ядро предоставляет два встроенных формата записи кэш тегов, которые автоматически будут контролироваться ядром:

  • <entity type ID>:<entity ID>: тег связанный с определенной сущностью по её названию и id. Когда данная конкретная сущность:id будет обновлена, данный кэш автоматически будет инвалидирован. Пример: node:7, user:17.
  • config:<configuration name>: тег связаный с определенной конфигурацией. Аналогично как и выше, инвалидируется при изменении определенной конфигурации. Пример: config:system.performance, config:system_site.

Также вы можете добавлять свои теги, и в дальнейшем самостоятельно, при определенных условиях, инвалидировать. Их формат записи должен быть my_cache_tag — т.е. все через нижнее подчеркивание. Среди предоставляемых по умолчанию имеются такие теги используемые самой системой:

  • node_list (<entity_type>_list): инвалидирует когда любой материал сущности Node создан.
  • library_info: инвалидирует когда информация о библиотеках на сайте обновлена.

Пример использования cache tags

$build = [
  '#theme' => 'my_awesome_theme_hook',
  '#cache' => [
    'tags' => ['user:1', 'node:2', 'my_custom_tag'],
  ],
];

Результат примера выше будет закэширован пока хотябы один тег не будут инвалидирован. А это произойдет если будет изменен профиль (сущность) пользователя с UID 1, либо материал node с id 2, либо инвалидирован кастомный тег my_custom_tag. И тут мы подходим к вопросу инвалидации.

Программная инвалидация тэгов

Тэги инвалидируются программно, сущности это делают сами в своих методах и логике, а вот кастомные теги вы должны инвалидировать самостоятельно, хотя вы тем же способом можете инвалидировать и теги сущностей, мало ли потребуется такое.

Инвалидируются они следующим образом:

Инвалидация cache tags
// Пример 1 — Правильный
\Drupal::service('cache_tags.invalidator')
  ->invalidateTags(['my_custom_tag']);

// Пример 2 — Запасной
use Drupal\Core\Cache;
Cache::invalidateTags(['my_custom_tag']);

Если по каким-то причинам не работает вызов сервиса, то используется второй пример, во всех остальных случаях первый (вызов через сервис).

Вы можете инвалидировать сколько и какие угодно теги, передавая их в массив друг за другом. Вот так всё просто!

Cache contexts

Контексты позволяют добавить варианты кэша. Их может быть указано сколько угодно и какие угодно (из доступных). Каждый контекст это определенный сервис (всегда) созданный с определенными методами и именованием, о том как они создаются будет чуть ниже. В момент генерации кэша, он запрашивает все контексты, они возвращают строку или целое число, затем он на основе этих данных смотрит, есть ли для такой комбинации кэш, и если есть, то отдает его, а если нет, генерирует новый вариант.

Надо понимать что контексты нельзя напрямую инвалидировать как в случае с тегами. По дефолту, если вы не укажите попутно max-age или tags он будет закэширован навсегда в пределах контекста, и только сброс общего кэша, или же если есть родитель, то его условия очистят их значения. Конечно, можно по CID их чистить по одному, но это безумно и куда сложнее. Если проще: в ядре нет механизма прямой очистки конкретного кэша по контексту.

Контексты указываются примерно как теги. Также они могут иметь иерархию, дочерние контексты отделяются от родительских через ., но при этом могут иметь динамический параметр, который отделяется :, который может указать пользователь.

Ядро предоставляет следующие контексты:

cache contexts из ядра
cookies
  :name
headers
  :name
ip
languages
  :type
request_format
route
  .book_navigation
  .menu_active_trails
    :menu_name
  .name
session
  .exists
theme
timezone
url
  .path
    .is_front // Available in 8.3.x or higher.
    .parent
  .query_args
    :key
    .pagers
      :pager_id
  .site
user
  .is_super_user
  .node_grants
    :operation
  .permissions
  .roles
    :role

Давайте посмотрим на самый первый контекст: cookies:name. Вы можете игнорировать указание динамического значения, в данном случае :name, то в таком случае будет автоматически поставлено all (на самом деле, там не в буквальном смысле передается all, это подразумевается что вам не важно на чем именно оно будет основываться, все что есть, все сгодится). Например, если укажите cookies:my_cookie, то кэш будет иметь разные варианты в зависимости от Cookies в браузере пользователя с именем my_cookie. Если оно не имеет значения — один вариант, если имеет — то каждый вариант значения будет иметь свой кэш. Например значение my_cookie 1 и 2, это указывает Drupal что для 1 надо сгенерировать один результат, а для 2, другой.

Теперь рассмотрим цепочку url:

  • url: Просто указав данный путь, кэш будет ориентироваться на обычный url сайта, например: http://example.com/. Учитывает $_GET, порт (если указан), а также протокол.
    • url.path: Будет отдавать полный путь до текущей страницы включая домен. Например: http://example.com/blog/title.
      • url.path.is_front: Возвращает 1 если главная и 0 если нет.
      • url.path.parent: Данный контекст возвращает только путь без начального слеша и последнего слага. Если опираться на пример выше, то результат будет: blog, а для http://example.com/blog/title/and-another-one результат будет blog/title.
    • url.query_args: Будет генерировать строку на основе $_GET запроса. Например: http://example.com/?foo=bar&baz=boom превратится в foo=bar&baz=boom и кэшироваться будует под каждый вариант такого запроса.
    • url.query_args:key: Позволяет сделать только на основе определенного аргумента: url.query_args:foo — будет учитывать только данный аргумент.
    • url.query_args.pagers: Позволяет делать варианты на основе значения пейджера на странице и его ID. У разных pager id будут разные кэши! * url.query_args.pagers:pager_id: Позволяет сократить контекст до определенного пейджера.
    • url.site: Этот контекст аналогичен основному, учитывает протокол, а также поддомен и порт (если указан), но не учитывает $_GET и дальнейший путь. Например: http://example.com, http://www.example.com (обратите внимание что тут нет на конце /).

Я думаю тут ничего сложного нет, главное уловить разницу между тегами, которая уже должна быть очевидной. Также, надо понимать, что контексты нельзя просто взять и написать, они должны быть обязательно подкреплены сервисами. Каждая часть разделенная точкой из примеров выше имеет свой собственный сервис. Только :arg передается родительскому в качестве аргумента.

Cache max-age

С этим вообще всё настолько просто, что даже нет особо тормозить и разьяснять. Данное значение содержит число секунд, спустя которое кэш будет инвалидирован.

  • 60: Спустя минуту кэш будет создан заново.
  • 0: Полностью отключает кэш. Помеченные элементы данным числом не будут кэшироваться вообще.
  • (по умолчанию) \Drupal\Core\Cache\Cache::PERMANENT: Данный кэш не будет инвалидирован сроком жизни. Он будет существовать до тех пор, пока кэш в системе не будет сброшен или же не инвалидируется один из тегов, если они там есть.

Это все что следует о них знать. Самый сложный из понимания contexts, так как он самый гибкий, поэтому ему я уделю особое внимание и ниже будет пример как создавать свои контексты. Поняв три данных параметра, считайте вы поняли кэширование в Drupal.

Если вас не интересует создание собственного контекста, статья на этом завершена ;)

Создание собственного контекста

Создание контекста для кэша состоит из следующих этапов:

  1. Создание объекта который реализовывает один из двух интерфейсов:
  • CacheContextInterface: стандартный интерфейс который требует объявить три метода.
  • CalculatedCacheContextInterface: абсолютный аналог предыдущего с единственным отличием, что данный интерфейс обязан принимать аргумент в двух методах. Данный интерфейс используется для контекстов где требуется возможность ввода аргумента. Например my_context:arg.
  1. Объявление данного объекта в качестве сервиса. Данный сервис должен удовлетворять следующим требованиям, нарушение хотябы одного из них повлечет за собой то что контекст не будет объявлен:
  • Он должен иметь тег cache.context для того чтобы Cache API смог его обнаружить.
  • Он обязательно должен начинаться с cache_context., а уже после точки пишите название своего контекста. Если контекст вложенный, как, например url.path.is_front, то нужно объявить: cache_context.url.path.is_front. Учтите, что удаляя часть is_front оставшееся часть должна продолжать работать. На самом деле это не обязательно, но так все устроено в ядре, и это логично, ваши контексты, если они вложенные, должны идти от общего к более узким, таким образов разработчики смогут сужать пул контекста или же расширять его.
  • Он всегда, не зависимот от задач и ситуации принимает аргумент @request_stack, который является сервисом содержащий RequestStack, в котором вся необходимая информация о текущем запросе. Будете ли вы его использовать или нет, дело ваше, но в качестве аргумента сервису указать вы его обязаны, но вот в __construct() уже можно не принимать, если не нужно.

Теперь пройдемся по методам которые необходимо объявить для интерфейсов. Каждый из них обязательнный.

  • getLabel(): должен возвращать человечское название контекста кэширование. Используется в администритивном интерфейсе.
  • getContext(): возвращает строку, которая является результатом контекста. Постарайтесь не возвращать тут огромные строки, если уж прямо очень много данных, лучше всего прогоняйте через md5() и подобные функции. Данный метод может возвращать только: string, int, float и прочие базовые типы, которые можно интерпритировать как строку, никаких массивов и уж темболее объектов там возвращать нельзя. Если ваш контекст реализует CalculatedCacheContextInterface то он обязательно должен принимать аргумент. Если вы хотите вернуть результат подразумевающий что контекст не подготовлен, общий вариант или не важно что будет, то просто возвращайте пустую строку, никаких TRUE, FALSE и NULL.
  • getCachebaleMetadata(): возвращает информацию о "кэшируемости" данного контекста. Так сказать базовые значения, которые будут также добавлены к элементу который его вызывает. Если ваш контекст реализует CalculatedCacheContextInterface то он обязательно должен принимать аргумент. Вот такие варианты данный метод может вернуть:
    • Пустой объект CacheableMetadata — в таком случае это означает, что данный контекст можно кэшировать без проблем, кэш будет зависить от других элементов и не контролироваться нами. То есть нас не волнует как будут кэшировать данные где используется данный контекст, мы его используем для добавления вариативности.
    • Массив содержащий max-age = 0. Это означает что данный контекст вообще не кэшируемый. Т.е. везде где указан данный контекст в кэш массиве, будет генерироваться при каждом запросе. И если используется #lazy_builder, он будет задействован для генериации содержимого с данным контекстом.
    • Массив содержащий max-age > 0 и cache tags. Он будет кэшироваться с данными параметрами и может быть инвалидирован через тег, либо будет очищаться по истечению указаного времени в max-age. Как я писал выше, если содержимое содержащее данный контекст или выше по дереву массива будет содержать cache tags и contexts, то они будут обьеденины.

Также в ядре существует базовый класс который также можно зайдествовать в своём контексте: RequestStackCacheContextBase. Он просто добавляет RequestStack в свойство requestStack, и принимает его в конструкторе. Используйте его если хотите что-то вытащить из запроса, так как это хоть и немного, но сократит код.

Пример №1. Свой собственный контекст с примером

В данном примере мы объявим свой контекст: dummy_request_header:parameter. Он будет основываться на header запроса. Вот пример что там может быть:

Пример Header из запроса

Так как мы принимает аргумент :parameter то наш контекст будет доступен как dummy_request_header, так и dummy_request_header:parameter. Вы должны предусмотреть в таком случае, что при обращении без аргумента контекст тоже что-то вернул. Наличие аргумента у контекста не делает его обязательным, если выхотите чтобы при обращении к контексту без аргумента как бы "ничего не было", просто возвращайте всегда пустую строку, в таком случае у всех таких контекстов будет одно содержимое.

Исходя из задачи, нам нужно реализовывать CalculatedCacheContextInterface так как будем принимать аргументы, а также расширять RequestStackCacheContextBase, чтобы не перегружать код.

Так как dummy_request_header массив, и мы не можем его просто так вернуть, мы будем соединять массив в строку, а затем получать его hash при помощи md5(). Таким образом мы значительно сократим значение, которое в сыром виде хранится в БД, а также будет достаточно отличная уникальность и вероятность совпадения md5() при разных значениях стремится к 0.

Первым делом делаем объект для нашего будущего контекста. В ядре нет четкого указания где держать подобные объекты, можете в src, можете где-то ещё, им не важна структура, так как это не плагины. Но я последую тому как это сделано в ядре и создам его в src/Cache/Context. Назову я его соответствующе названию контекста DummyRequestHeaderCacheContext.

src/Cache/Context/DummyRequestHeaderCacheContext.php
<?php

namespace Drupal\dummy\Cache\Context;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CalculatedCacheContextInterface;
use Drupal\Core\Cache\Context\RequestStackCacheContextBase;

/**
 * Cache context ID: 'dummy_request_header'.
 */
class DummyRequestHeaderCacheContext extends RequestStackCacheContextBase implements CalculatedCacheContextInterface {

  /**
   * {@inheritdoc}
   */
  public static function getLabel() {
    return t('Dummy request header');
  }

  /**
   * {@inheritdoc}
   */
  public function getContext($parameter = NULL) {
    $request_headers = $this->requestStack->getCurrentRequest()->headers;
    if ($parameter) {
      if ($request_headers->has($parameter)) {
        return (string) $request_headers->get($parameter);
      }
      else {
        return '';
      }
    }
    else {
      // If none parameter is passed, we get all available during request and
      // merges them into single string, after that we hash it with md5 and
      // return result.
      $headers_string = implode(';', array_map(function ($entry) {
        return $entry[0];
      }, $request_headers->all()));
      return md5($headers_string);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheableMetadata($parameter = NULL) {
    return new CacheableMetadata();
  }
}

И объявляем наш объект в качестве сервиса cache context.

dummy.services.yml
services:
  cache_context.dummy_request_header:
    class: Drupal\dummy\Cache\Context\DummyRequestHeaderCacheContext
    arguments: ['@request_stack']
    tags:
      - { name: cache.context }

Немного вернусь к объекту. Вы можете заметить комментарий у класса: Cache context ID: 'dummy_request_header'. Не путайте его с аннотациями. Это не обязательно. Такой комментарий содержит каждый контекст в ядре, это для удобства поиска в коде, поэтому я последовал их примеру.

Далее нам нужно, например, создать блок для тестирования нашего объекта. В нем все будет крайне просто. Мы будем брать user-agent из заголовка, пытаться определить операционную систему, и выводить сыслку, якобы на загрузку программы для данной платформы. Но вот проблема, user-agent содержит не только операционную систему, а также архитектуру, движок браузера, версию движка, браузер, его версию. Получается что кэшей будет просто навалом и они будут очень часто повторяться, так как строка, которую будет возвращать контекст (user-agent) будет большая и очень разная.

Что мы можем сделать в данном случае?

  • Первый вариант, чутка доработать текущий контекст и добавить ключ для несуществующего параметра из шапки запроса, например os, который будет возвращать название операционной системы в качестве результата контекста.
  • Второй, объявить свой контекст. Если хотите попробовать, добавьте свой контекст, будет такой челенджн на проверку того что выше. Сделайте ему название dummy_request_header.os. Учтите, что реализовывать уже потребуется CacheContextInterface, всё остальное вообще не изменится, а код, который потребуется для getContext() сможете взять ниже.

Я же пойду по первому пути, ибо это быстрее и проще, хотя будет не логично для разработчиков если сделать подобное на реальном проекте, так как не залезая в код контекста будет неизвестно о такой особенности. Для того чтобы сделать поддержку ключа os в нашем текущем контексте и возвращать название операционной системы, достаточно поправить метод getContext() следующим образом:

public function getContext($parameter = NULL) {
    $request_headers = $this->requestStack->getCurrentRequest()->headers;
    if ($parameter) {
      if ($request_headers->has($parameter)) {
        return (string) $request_headers->get($parameter);
      }
      elseif ($parameter == 'os') {
        $user_agent = $request_headers->get('user-agent');
        if (preg_match('/linux/i', $user_agent)) {
          return 'linux';
        }
        elseif (preg_match('/macintosh|mac os x/i', $user_agent)) {
          return 'mac';
        }
        elseif (preg_match('/windows|win32/i', $user_agent)) {
          return 'windows';
        }
        else {
          return 'other';
        }
      }
      else {
        return '';
      }
    }
    else {
      // If none parameter is passed, we get all available during request and
      // merges them into single string, after that we hash it with md5 and
      // return result.
      $headers_string = implode(';', array_map(function ($entry) {
        return $entry[0];
      }, $request_headers->all()));
      return md5($headers_string);
    }
  }

Таким образом, указав контекст dummy_request_header:os кэш будет иметь вариант под каждую ОС из перечисленных, и если не определена, то выводить стандартный текст. Теперь на основе этого сделаем свой блок.

Для того чтобы сделать такой блок, нам также потребуется RequestStack для получения и парсинга аналогичных данных, аналогичными регулярками будем получать ОС и генерировать содержимое блока, указав наш контекст для кэширования.

src/Plugin/Block/CacheContextRequestHeaderTest.php
<?php

namespace Drupal\dummy\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Url;

/**
 * Provides a 'CacheContextRequestHeaderTest' block.
 *
 * @Block(
 *  id = "dummy_cache_context_request_header_test",
 *  admin_label = @Translation("Cache context OS test"),
 * ) Как
 */
class CacheContextRequestHeaderTest extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function getOs() {
    $request_headers = \Drupal::service('request_stack')->getCurrentRequest()->headers;
    $user_agent = $request_headers->get('user-agent');
    if (preg_match('/linux/i', $user_agent)) {
      return 'Linux';
    }
    elseif (preg_match('/macintosh|mac os x/i', $user_agent)) {
      return 'Mac';
    }
    elseif (preg_match('/windows|win32/i', $user_agent)) {
      return 'Windows';
    }
    else {
      return 'other';
    }
  }

  /**
   * {@inheritdoc}
   */
  public function build() {
    $platform = $this->getOs();
    if ($platform == 'other') {
      return [
        '#markup' => t('Sorry, we have not already created software for you OS.'),
      ];
    }
    else {
      $string = t('Download for @platform', [
        '@platform' => $platform,
      ]);
      $url = Url::fromUri('http://www.example.com/');
      $external_link = \Drupal::l($string, $url);

      return [
        '#markup' => $external_link,
      ];
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheContexts() {
    return ['dummy_request_header:os'];
  }

}

Думаю объяснять по поводу генерации содержимого нет никакого смысла, все итак ясно. Но я не просто так добавил getCacheContexts() метод.

У меня есть материал про Block Plugin API и там нет такого метода. Все потому что он не является частью этого API, он является частью Cache API и в данной статье его самое время упомянуть. Если посмотреть в BlockBase, то он также не содержит данных методов, но он расширяет ContextAwarePluginBase, который и содержит данные методы.

Они позволяют задать contexts, max-age и tags для всего блока независимо от того что он будет возвращать и как. То есть вы можете возвращать контексты, теги и время жизни прямо в render array, а можете соответствующими методами. Соответственно для этого существует три метода:

  • getCacheContexts(): возвращает массив из контекстов.
  • getCacheTags(): возвращает массив тегов.
  • getCacheMaxAge(): возвращает время жизни кэша.

Просто знайте что они есть, и есть такой базовый плагин который реализуют другие плагины, и могут реализовывать ваши, для добавления соотстветующих методов работы с кэшем. Я не могу вам привести примеров или за и против, они просто есть, работают оба варианта идентично, разницу между ними я не заметил. Смею предположить, что данные методы имеют выше приоритет, так сказать являются родителями результата блока и будут также соединены с теми, что указаны в #cache элементов render array, который возвращает блок.

Теперь, возвращаясь к блоку, можете добавить его на страницу и посмотреть результат:

Пример блока для теста User Agent

Как проверить всё ли кэшируется правильно? Если вы добавили данный блок на страницу и включили кэширование у сайта. Вы можете зайти в БД сайта и выполнить запрос:

SELECT * FROM `cache_render` WHERE `cid` LIKE '%dummy_request_header:os%'

Вы увидите примерно следующие строки(у): entity_view:block:cachecontextostest:[dummy_request_header:os]=linux:[languages:language_interface]=en:[theme]=bartik:[user.permissions]=a30137c0ac401cc4ad2826f92eaa0e633b4b90f869166a1515aac2bbbc04d421

Как вы можете заметить, это ID кэша для соответствующего варианта кэша. Например, контексты языка и темы унаследовались от ядяра, а также там есть и наш. Как видите, там linux — это результат работы getContext(). Также в строке есть дополнительно теги и max-age если вы их также указывали, или где-то они были добавлены. Если человек зайдет с Windows, появится ещё одна записать и т.д.

Я очень надеюсь я понятно объяснил как работают контексты, так как если понять их, всё остальное просто ерунда.

Пример №2

Данный пример будет основан на стандартных контекстах, но мы сделаем достаточно любопытный пример. Я не знаю как он влияет на SEO, но это отлично демонстрирует как можно использовать контексты в повседневных задачах, а не только когда что-то лагает.

Сеошники, не бейте ссаными тряпками, я в этом не шарю, но мы сделаем блок, который меняет свое содержимое в зависимости от UTM меток.

Если вы не знакомы с UTM метками, то это такие параметры передаваемые с URL в GET. Они передаются для обозначения что переход был с рекламной компании. Там содержится название рекламной площадки (utm_source), тип рекламного объявления (utm_medium) и название рекламной компании (utm_campaign). Наверное, уже просто не существует таких рекламных сетей которые бы не использовали UTM метки. Они позволяют определить откуда пришли пользователи, если они пришли с рекламы. От какой площадки, с какого типа рекламы и какой рекламной компании. Они используются для анализа трафика и понимания, какая реклама приносит пользователей наиболее эффективно. Многие делают такие ссылки и руками, когда что-то где-то размещают, чтобы также, отследить эффкетивность данной ссылки. Мы же, будем менять содержимое блока на основе source и campaign.

Всё что нам нужно сделать, это блок, так как всё что требуется для соответствующего кэширования есть в ядре: url.query_args:key.

src/Plugin/Block/CacheContextUtmTest.php
<?php

namespace Drupal\dummy\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Link;

/**
 * Provides a 'CacheContextUtmTest' block.
 *
 * @Block(
 *  id = "dummy_cache_context_utm_test",
 *  admin_label = @Translation("Cache context UTM test"),
 * )
 */
class CacheContextUtmTest extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {
    $build = [];
    // Static links for testing block caching.
    $build[] = [
      '#markup' => Link::createFromRoute('Yandex, SEO', '<front>', [], [
        'query' => [
          'utm_source' => 'yandex.com',
          'utm_campaign' => 'SEO',
        ],
        'target' => '_blank',
      ])->toString(),
      '#prefix' => '<div>',
      '#suffix' => '</div>',
    ];
    $build[] = [
      '#markup' => Link::createFromRoute('Yandex, website', '<front>', [], [
        'query' => [
          'utm_source' => 'yandex.com',
          'utm_campaign' => 'website',
        ],
        'target' => '_blank',
      ])->toString(),
      '#prefix' => '<div>',
      '#suffix' => '</div>',
    ];
    $build[] = [
      '#markup' => Link::createFromRoute('Google, SEO', '<front>', [], [
        'query' => [
          'utm_source' => 'google.com',
          'utm_campaign' => 'SEO',
        ],
        'target' => '_blank',
      ])->toString(),
      '#prefix' => '<div>',
      '#suffix' => '</div>',
    ];

    // Get current values.
    $result = 'Мы делаем крутые сайты и занимаемся продвижением, звоните нам!';
    $this->textModifier($result);
    $build[] = [
      '#markup' => $result,
    ];

    return $build;
  }

  /**
   * {@inheritdoc}
   */
  public function textModifier(&$result) {
    $utm_source = $this->getUtmSource();
    $utm_campaign = $this->getUtmCampaign();
    if ($utm_source == 'yandex.com') {
      switch ($utm_campaign) {
        case 'SEO':
          $result = 'Поможем продвинуть сайт в ТОП Яндекса!';
          break;

        case 'website':
          $result = 'Создадим крутой сайт, который без проблем займет ТОП Яндекса';
          break;
      }
    }

    if ($utm_source == 'google.com') {
      switch ($utm_campaign) {
        case 'SEO':
          $result = 'Поможем продвинуть сайт в ТОП Google!';
          break;
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  private function getUtmValue($parameter) {
    $request_query = \Drupal::service('request_stack')->getCurrentRequest()->query;
    if ($request_query->has($parameter)) {
      return $request_query->get($parameter);
    }
    return '';
  }

  /**
   * {@inheritdoc}
   */
  public function getUtmSource() {
    return $this->getUtmValue('utm_source');
  }

  /**
   * {@inheritdoc}
   */
  public function getUtmCampaign() {
    return $this->getUtmValue('utm_campaign');
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheContexts() {
    return [
      'url.query_args:utm_source',
      'url.query_args:utm_campaign',
    ];
  }

}

Вот результат работы блока:

Разное содержимое в зависимости от UTM меток

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

Прикрепленные файлы
Исходник схемы — scmeme.svg, 18.82 КБ
Готовый модуль со всеми примерами — dummy.tar.gz, 2.24 КБ
Drupal
Drupal 8
Cache API

Комментарии

Александр   ср, 19/07/2017 - 10:05

Здравствуйте, а когда будут видео по написанию кастомных модулей на Drupal 8?

Niklan   ср, 19/07/2017 - 11:45

Ой, этого я не знаю :) Есть куча планов про что ещё написать, потом подумаю на счет видеогайдов.

Nikita   пт, 25/08/2017 - 17:17

привет. Можешь подсказать как добавить контекст для домашней странцицы (нода) по куки? Если есть куки customCookie, то один вариант кеша страницы, если нет - то другой?

Niklan   пт, 25/08/2017 - 18:30

Привет. Примерно так:

$build['#cache']['context'][] = 'cookies:customCookie';
Nikita   пт, 25/08/2017 - 20:58

проблема в том, что я не знаю где именно этот $build взять для уже существующей страницы (в моем случае нода, она же домашняя страница). Это какой-то альтер должен быть? Да и когда пробую просто на кастомном блоке, то контекст url.path работает хорошо - сохраняются отдельные кешы для каждого пути. Но cookies:customCookie ни в какую работать не хочет.

Niklan   сб, 26/08/2017 - 10:05

hook_ENTITY_TYPE_view()

function hook_node_view(array &$build, \Drupal\Core\Entity\EntityInterface $entity, \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display, $view_mode) {
  $build['#cache']['context'][] = 'cookies:customCookie';
}

Только условием обернуть чтобы не везде тянулось. $entity->id() == NID или по bundle(), в общем там уже сами разберетесь.

Но cookies:customCookie ни в какую работать не хочет

А кастомная кука 100% имеет название customCookie? В хром дев-тулс или в firefox посмотрите в куках её реальное название.

Nikita   сб, 26/08/2017 - 18:01

Спасибо. Кука точно с таким названием. Кстати заметил, что с залогиненным юзером работает, но для анонима сохраняет кеш только раз (а вот с контекстом url.path работает как нужно, уже писал выше). В любом случае спасибо большое. В D8 с кешом намудрили, но мне это нравится, буду дальше разбираться.

Niklan   пн, 28/08/2017 - 07:02

Ну вообще должно работать корректно. Но можно также добавит ещё контекст user:permissions. В первую очередь надо проверять что происходит с конкретной кукой под анонимом. Скорее проблема в ней, а не в кэшировании. И ваше упоминание что блок с url:path кэширует корректно, лишь ещё раз говорит о том что кэш работает корректно а косячат именно куки.

c0rzair   чт, 12/04/2018 - 16:23

Приветствую! Можете подвердить (или опровергнуть) наличие такой проблемы: В Drupal 8.4+ (у меня эта проблема наблюдается в 8.5.1) Вывод build() кастомного блока кэшируется - она запускается только один раз и установка опций кэширования на это не оказывает никакого действия. Это ошибка кода или это изменение логики поведения механизма кэширования в Drupal 8.4+?

Cейчас для отмены кэширования вывода блока приходится build() вызывать \Drupal::service('page_cache_kill_switch')->trigger().

Вот пример, иллюстрирующий проблему:

... class MySuperBlock extends BlockBase { use UncacheableDependencyTrait; // не оказывает никакого эффекта

public function build()
{
    // НЕ кэшировать страницу с этим блоком - Это реально отключает кэширование.
    // но работает по принципу всё или ничего.
    \Drupal::service('page_cache_kill_switch')->trigger();

    // Initialise the block data.
    $build = [];

         ...

    //  Установка ['#cache'] тоже не оказывает никакого влияния на кэширование
    //  такое впечатление, что они переопределяются позже.
    $build['#cache']['max-age'] = 0;
    $build['#cache']['context'] = [];
    $build['#cache']['tags'] = ['my_custom_tag'];

    return $build;
}

// Добавление getCacheMaxAge также не имеет никакого эффекта.
public function getCacheMaxAge() {
    return 0;
}

}

И ещё пробовал в .theme файле такой код:

function mysupertheme_preprocess_block(&$vars) { $vars['#cache']['max-age'] = 0; }

... но он тоже не отключает кэширование.

Niklan   чт, 12/04/2018 - 17:42

ЕМНИП max-age не работает с включенным модулем Internal Page Cache. Его надо будет удалить или юзать варнишы и ему подобные инструменты, чтобы они учитывали это.

Если вы не хотите отказываться от данного модуля, то вызов \Drupal::service('page_cache_kill_switch')->trigger() единственное правильное решение, которое, по сути, для этого и существует.

Вот тут можете углубиться. Или вот ещё

c0rzair   пт, 13/04/2018 - 08:49

Спасибо за ваш ответ. Вы оказались совершенно правы. Установка $build['#cache']['max-age'] = 0; не оказывает никакого влияния, когда страница отображается для анонимного пользователя (я так понимаю в этом случае используется модуль Internal Page Cache. Однако, $build['#cache']['tags'] = ['my_custom_tag']; прекрасно работает с "анонимными" страницами.

Niklan   пт, 13/04/2018 - 09:26

Да, там не работает только max-age = 0. Контексты и теги и будут работать, так как они зависят не от времени, как правило, а от событий которые удалят этот кэш. Обычно у них max-age = -1. max-age, насколько мне известно, все же работает, но только по крону, с включенным модулем IPC. Так что, можно смело заявить что не работает :) Так как чистить кроном, ещё хуже скажется на производительности чем через сервис.

Oleh   пт, 18/02/2022 - 13:30

\Drupal\page_cache\StackMiddleware\request. // The response passes all of the above checks, so cache it. Page cache // entries default to Cache::PERMANENT since they will be expired via cache // tags locally. Because of this, page cache ignores max age. Если модуль Internal Page Cache включен, то max age просто игнорируется. Тут как минимум 2 варианта решения проблемы кеширования страниц для анонимов:

  1. Или руками инвалидировать, через сервис, как Вы и упоминаете.
  2. Использовать #lazy_builder и создавать плейсхолдеры, чтобы динамические части страницы рендерились в конце, не нагружая, даром те части, которые можно и нужно кешировать.
xandeadx   чт, 12/04/2018 - 19:19

Они позволяют задать contexts, max-age и tags для всего блока независимо от того что он будет возвращать и как. То есть вы можете возвращать контексты, теги и время жизни прямо в render array, а можете соответствующими методами.

Интересный и пока непонятный момент. Если блок есть в кэше, то BlockBase::build() не вызывается. Как тогда друпал получает контекст кэша если прописывать #cache['contexts'] в BlockBase::build(), а не в отдельном методе? Он ведь где-то сохраняет эту информацию.

xandeadx   чт, 12/04/2018 - 23:43

Выяснилось, что контекст кэша хранится в той же таблице отдельной записью. Таким образом, если возвращать контекст в BlockBase::build(), то во-первых, при кэшировании блока будет создаваться две записи в cache_render, а во-вторых при получении данных из кэша будет делаться два запроса. Поэтому вариант с BlockBase::getCacheContexts() самый правильный.

Андрей   ср, 25/04/2018 - 16:10

В cache_render записи не создаются почему-то. На странице Performance поставил Page cache maximum age = 1 hour, например. Этим кэш только и включается или еще что-то сделать надо? Кэши чистил, заходил под анонимом - все равно нет записей в таблице.

Владимир   чт, 05/07/2018 - 13:05

Спасибо большое за статью. Никак не могу понять как правильно поступить: у меня есть блок, в котором программно выводится форма, содержащая в своем select разные параметры, которые меняются один-два раза в день. Каким образом правильно кешировать этот блок, чтобы при изменении параметров в selecte формы, кеш сбрасывался? Думаю пойти по пути инвалидации кеш тегов, чтобы программно сбрасывать свой кеш тег при обновлении параметров формы. Но может есть ещё какие то способы? Также в некоторых других блоках иногда меняются выводимые переменные для twig, их таким же образом кешировать?

Niklan   чт, 05/07/2018 - 17:05

Насколько мне известно, формы не кэшируется, а следовательно блок, где форма, тоже не кэшируется. Они грузятся через #lazy_builder, в обход кэша.

Если селект долго генерируется. То тут лучше генерировать options один раз и кэшироавть их штатными средствами, а не тем что описано в статье. Например:

if ($cache = \Drupal::cache()->get('my_custom_options')) {
  $options = $cache->data;
}
else {
   $options = generate_options();
   // Закэшить на сутки.
   \Drupal::cache()->set('my_custom_options', $options, time() + 60 * 60 * 24);
}
Владимир   пт, 06/07/2018 - 00:53

Спасибо за ответ! Ещё момент - если у меня в блоке нет формы, но есть переменные для твига, которые меняются раз или два раза в сутки по крону. Как такие блоки кешировать? Единственное, что я пока сделал, это в самом кроне при обновлении выполняю инвалидацию кеш тега, к нему и привязываюсь в блоке. Но может это как то грамотнее можно сделать?

Niklan   пт, 06/07/2018 - 06:24

Если раз или два меняются по расписанию, например каждые 12 часов - max-age. Если же по факту когда поменяли cache tags. И смотря как эти значения получаются и где хранятся, подключиться к форме где они правятся\задаются и при успешном субмите этой формы инвалидировать этот тег. Так кэш будет сбрасываться не по крону, а когда данные фактически изменились, раз, два, 100 раз в сутки. Сразу после изменения будет сделан новый кэш и все продолжит работать.

Владимир   пн, 24/09/2018 - 18:41

Добрый день, пробую следующий вариант со своим тегом, все нормально работает. if ($cache = \Drupal::cache()->get('my_custom_options')) { $options = $cache->data; } else { $options = generate_options();
\Drupal::cache()->set('my_custom_options', $options, CacheBackendInterface::CACHE_PERMANENT, ['MY_TAG']); } Но когда я выполняю программную инвалидацию тега в хуке, то после уже невозможно снова задать кеш через \Drupal::cache()->set('my_custom_options', $options, CacheBackendInterface::CACHE_PERMANENT, ['MY_TAG']); т.к. $cache = \Drupal::cache()->get('my_custom_options') - даёт null после инвалидации того тега. Не могу понять, как его заново задать. Пробовал \Drupal::cache()->delete .. но по прежнему не получается задать заново кеш, также и соответсвующий cid в таблице entity_cache по прежнему присутствует, не удаляется через \Drupal::cache()->delete .. Как правильно инвалидировать тег, или выполнить \Drupal::cache()->delete, чтобы задать его заново с тем же тегом?

Владимир   пн, 24/09/2018 - 19:13

Решил проблему, возможно это было оттого, что я использовал несколько тегов, с одним тегом все заработало как надо.

xandeadx   чт, 31/10/2019 - 22:42

Ошибка в тексте - url.path.parent возвращает путь без последнего аргумента, т.е. для "http://example.com/blog/title" вернёт "blog"

Kostarh   вт, 01/12/2020 - 22:09

Если я для юзера, который с телефона хочу выводить один шаблон, а для юзера с пк другой. Я mytheme.theme в хуке

function pc_preprocess_html(&$vars) {
  if($mobile_detect->isMobile()) {
    $vars['is_mobile'] = TRUE;
  }
}

в html.html.twig делаю проверку {% if (is_mobile is defined) %} и делаю инклуд одних файлов иначе других. Это бы все работало, если бы не крутой кеш в Drupal 9? Можете подсказать что в моей логике не верно? Нужно добавить Cache contexts ? Спасибо!

Niklan   ср, 02/12/2020 - 06:31

Да, требуется создать кэш контекст и добавить его для данной страницы (если только тут и нужно):

function pc_preprocess_html(&$vars) {
  if($mobile_detect->isMobile()) {
    $vars['is_mobile'] = TRUE;
  }
  $vars['#cache']['contexts'] = 'device_type';
}

Ну или как вы его там назовёте. Иначе друпал не знает что страница может иметь разные варианты в зависимости от типа устройства и не будет создавать разные результаты.

Константин   пт, 11/06/2021 - 12:20

Всем привет. Сделал свой контекс для кеша. На главной странице меняется блок слайдера в зависимости от того с мобилки или ПК зашли на сайт. Если пользователь залогинен, то контекс отлично отрабатывает. Если аноним, то не работает. Как выяснилось, модуль "Internal Page Cache" не работает с контекстом. Он предполагает, что для анонимов страницы всегда отдаются одинаковые. Об этом написано вот тут в конце статьи https://www.drupal.org/docs/drupal-apis/cache-api/cache-contexts

Вопрос: может кто смог решить проблему кеширования для анонимов без отключения модуля "Internal Page Cache" и без использования \Drupal::service('page_cache_kill_switch')->trigger();

Niklan   вт, 15/06/2021 - 08:56

Привет. Да, Internal Page Cache не работает с контекстами. Это достаточно примитивный кеш, для примитивных и статичных сайтов. Деление сайта на две версии на уровне бэкенда, уже не звучит как-то примитивно.

Решения:

  1. Internal Page Cache — это мидлвара, а следовательно является сервисом. Создаём свой класс расширяющий оригинальный PageCache, и подменяем им class в сервисе мидлвары. Правим метод, отвечающий за формирование кеша так, чтобы учитывал нужные контексты. Думаю, в данном кейсе хватит слегка подкорректировать ::getCacheId() чтобы он учитывал в своём CID мобильники и ПК, а именно, добавить обозначение в $cid_parts.
  2. Написать свое решение подглядывая в Internal Page Cache.

Тут очень важно понимать, что даже дробление на мобильные \ ПК в контексте Internal Page Cache — это уже серьезная потенциальная проблема. И проблема тут в том, что данный кеш и так не лёгкий, а тут его станет х2 на ровном месте, а если есть еще деление на планшеты… На проекте с большим количеством страниц это приведёт к тому, что таблица с данным кешем будет настолько "толстой", что окажется, что без данного модуля, на одном лишь Internal Dyanamic Page Cache будет работать быстрее. Тут нужно серьёзно замерять и профайлить, предварительно разогрев весь сайт, чтобы таблица раздулась.

Как правило, если Internal Page Cache начинает не справляться и с ним что-то работает не так, то либо вы делаете что-то не так, либо ваш проект вышел за рамки данного подхода к кешированию. Как я уже написал выше, добавление к нему контекстов, может дать отрицательный эффект в сравнении с простым его отключением. Это далеко не единственный кеш в друпале.

Этот модуль не панацея, на определенных этапах и в определенных условиях, его нужно отключать.