Организация очередей в Drupal 8 (Queue API). Инъекция зависимости.

Как показала практика — лучший способ изучить Drupal — построить на его базе достаточно сложный проект. В принципе, открытие не ново, — еще Керниган и Ричи написали в своей книге «Язык С», что «единственный способ изучить новый язык — писать на нем программы». Поэтому, не будем изобретать велосипед, а пойдем по проторенной дорожке — то есть через практику.

В этой статье мы познакомимся с организацией очередей в Drupal, а также закрепим реализацию механизма инъекции зависимости (Dependency Injection), о которой начали говорить в статье Использование Dependency Injection в Drupal 8.

Крон (Cron)

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

  • Опубликовать или снять с публикации выбранную заранее ноду;
  • Отправить админу еженедельный отчет с логами;
  • Удалить устаревший кеш;

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

Крон в Drupal 8

Крон в Drupal 8 в принципе ничем не отличается от крона в Drupal 7. Его настройки (весьма скромные) можно обнаружить по адресу Administration > Configuration > System > Cron. Здесь вы увидите выпадающее меню, где можно выбрать как часто запускать крон (каждый час, 3 часа, 6 часов, 12 часов, сутки, неделя, никогда) и галочку «Детализированное логирование запусков крона».

Какие задачи выполняются в Drupal при запуске крона?

Набор задач, которые необходимо выполнять регулярно, зависит от установленных в системе модулей, но зачастую список примерно такой:

  • Обновить поисковые индексы если активен модуль Search (входящий в стандартную поставку Drupal 8)
  • Опубликовать или снять с публикации определенные ноды при использовании модуля Scheduler
  • Если включен Update Manager, проверить обновления
  • Удалить загруженные через File API файлы, помеченные как временные ($fileObj->setTemporary())

Как работает крон в Drupal?

Базовый крон в Drupal работает весьма незамысловато. При каждом запросе любой страницы проверяется время прошедшее с последнего запуска крона, и если оно превышает указанное в настройках, крон будет запущен и будут выполнены все задачи, которые запрограммированы на выполнение при запуске крона. Если же на ваш сайт никто долгое время не заходил, крон по очевидным причинам не может быть выполнен (php сам себя не запустит!), и соответственно набор задач может остаться не выполненным. Это следует учитывать, и если необходимо, принудительно дергать крон по внешней ссылке-ключу (есть в настройках крона). Автоматический (гарантированный) запуск крона по ссылке можно настроить либо в панели управления хостинг аккаунтом, либо с помощью сервиса easycron.

Настройки задачи для крона в панели управления хостингом ukraine.com.ua. Вы задаете адрес-ключ, по которому можно запустить крон извне, и периодичность запроса.

Еще один нюанс, который следует учитывать — если крон содержит слишком тяжелые и ресурсоемкие задачи, которые не укладываются в таймаут php заданный на хостинге (обычно 30 сек), то крон может полностью никогда и не выполниться, так как будет «падать» на этих тяжелых задачах по истечении 30 сек с момента запуска, а так как задачи будут постепенно только накапливаться ничего хорошего из этого не выйдет.

Как добавить свою задачу в крон?

Добавить свою задачу в крон очень просто, и этот механизм ничем не отличается от Drupal 7 — вам просто нужно объявить hook_cron() в своем модуле (файл my_best_module.module), и описать там логику, которая должна выполниться при запуске крона.

Код:

/**
 * Implements hook_cron().
 */
function my_best_module_cron() {
  // Do something here, like delete expired cache,
  // or something else...
}

И это все! Очистите кеш, и при следующем запуске крона ваш хук будет обнаружен и выполнен.

Крон отлично подходит для легких периодичных задач. Однако часто мы сталкиваемся с задачами другого характера. Например, несколько пользователей подписались на получение отчетов в PDF по email. Генерация PDF — задача ресурсоемкая, и пытаться запихнуть набор таких задач в один запуск крона — идея малоперспективная, и даже более того, обреченная на провал. И здесь нам на помощь приходят очереди.

Очереди и их обработчики (Queues & QueueWorkers)

Представим, что нам нужно выполнить какой-то существенный объем задач. Как было показано выше, если разместить всю логику для их выполнения в hook_cron() и надеяться что все они выполнятся за один запуск крона, скорее всего результаты вас не обрадуют. Вместо этого нужно разбить большую задачу на составные подзадачи, поместить подзадачи в очередь, и отправить очередь на выполнение. Очередь будет постепенно выполняться при каждом запуске крона, пока не будут выполнены все задачи находящиеся в ней. Очевидно, что элементы очереди должны быть сформированы таким образом, чтобы выполниться в пределах одного php-таймаута.

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

Совет: чтобы иметь возможность наблюдать текущее состояние очередей и управлять ими вручную, установите модуль Queue UI. Если вы используете Composer, достаточно выполнить команду composer require drupal/queue_ui

Как создать очередь?

Чтобы создать очередь, нужно создать объект очереди и передать в него первый элемент:

//Create new (or get existing) queue object:
$queue = \Drupal::queue('my_test_queue');

//Add item to queue:
$queue->createItem($item);

Для простоты мы инстанциируем объект очереди в процедурном стиле, просто вызывая статический метод queue() с именем очереди в качестве параметра. Такой способ хорош для инициализации очереди, например, из hook_cron(). Если же вам нужно получить объект очереди из формы, лучше заранее внедрить необходимую зависимость в класс формы. Если еще не читали — вот первая статья про иньекцию зависимости в Drupal 8.

Обратите внимание на идентификатор очереди — в данном примере это ‘my_test_queue’. В принципе это произвольная строка, но она нам понадобится в дальнейшем, когда мы к этой очереди будем привязывать обработчика.

Затем создаем первый элемент в очереди с помощью метода createItem($item). Аргументом метода createItem может быть что угодно — простая переменная, например ID пользователя или ноды, массив, или даже целый объект. Все остальные элементы добавляются аналогично.

Как видите, инициализировать очередь проще простого — достаточно создать объект и передать в него элемент(ы). Делать это можно из любого места в коде — хоть из hook_cron(), хоть из формы по сабмиту.

Теперь, когда очередь создана, напишем к ней обработчик.

Обработчик для очереди

Обработчик для очереди оформляется в виде плагина, и размещать его принято в:
my_module/src/Plugin/QueueWorker/myFirstPlugin.php

Класс обработчика должен расширять класс Drupal\Core\Queue\QueueWorkerBase и реализовывать один единственный метод processItem($item), в котором будет содержаться логика для обработки элемента очереди. Классу должна предшествовать аннотация, указывающая системе что это QueueWorker плагин, и содержащая id очереди и другие настройки. В самом простом случае плагин-обработчик очереди выглядит так:

Код my_module/src/Plugin/QueueWorker/myFirstPlugin.php:

<?php

namespace Drupal\my_module\Plugin\QueueWorker;

use Drupal\Core\Queue\QueueWorkerBase;

/**
 * @QueueWorker(
 * id = "my_test_queue",
 * title = @Translation("My test queue"),
 * cron = {"time" = 20}
 * )
 */

class myFirstPlugin extends QueueWorkerBase {
  /**
   * {@inheritdoc}
   */
  public function processItem($item) {
    //Do anything on $item, depending on what we put
    //in $item, when queue has been initialized
  }
}

Еще раз обращаю внимание на id очереди, указанный в аннотации (my_test_queue) — он должен совпадать с id, выбранным на этапе создания очереди. title — это человеко-понятное название очереди, которое можно увидеть установив модуль Queue UI. cron — опциональный аргумент, указывающий максимальное время, которое можно выделить на выполнение задач данной очереди. В нашем примере это 20 сек, по умолчанию — 15 сек.

Единственный метод, который нужно обязательно реализовать в нашем классе — public function processItem($item), в него следует поместить логику обработки элемента очереди.

Совет: Для элементов, которые добавляются в очередь в автоматическом режиме (по крону), желательно предусмотреть какой-нибудь флаг типа is_processed, проверять его значение при постановке элемента в очередь и устанавливать в true по окончании обработки. Иначе можно получить побочный эффект в виде карусели — когда одни и те же элементы обрабатываются по кругу.

Для полноты картины отметим, что согласно интерфейсу Drupal\Core\Queue\QueueWorkerInterface, из метода processItem($data) можно выбрасывать три типа исключений, которые будут обработаны:

\Exception — обычное исключение. Крон запишет это исключение в лог, и оставит элемент в очереди, чтобы обработать в следующий раз.

\Drupal\Core\Queue\RequeueException — выбросьте это исключение, если элемент который получил обработчик очереди должен быть (по каким-то причинам) обработан позже. Может быть, нужно больше времени, ресурс еще не доступен, возможно задачу нужно передать другому обработчику, или задачу следует запускать только по понедельникам… в любом из этих случаев можно выбросить RequeueException. Элемент будет освобожден и может быть немедленно затребован другим процессом.

\Drupal\Core\Queue\SuspendQueueException — это исключение следует выбросить в том случае, когда в процессе обработки элемента очереди стало понятно что появившаяся проблема затронет обработчики всех остальных элементов очереди. Например, колбэк, который делает HTTP запрос обнаруживает, что удаленный сервер не отвечает. Крон обработает это как обычное исключение, и кроме того, не будет пытаться обработать все остальные элементы очереди во время текущего запуска.

Инъекция зависимости для плагина

Про Drupal поговорили, а инъекцию зависимости и не вспомнили. Непорядок. А если серьезно, то я думаю будет не лишним вновь поднять тему инъекции зависимости по той причине, что для плагина механизм внедрения зависимости отличается от случая с формой, который мы рассматривали ранее.

Основное отличие в том, что класс QueueWorkerBase от которого мы наследуемся, не содержит статического метода create(), с помощью которого инициализируются объекты зависимостей и передаются в конструктор. Поэтому просто переопределить create() как в случае с классом формы не выйдет.

Для простоты будем считать что мы хотим внедрить в наш плагин объект подключения к БД. Тода алгоритм «прикручивания» инъекции зависимости будет выглядеть так:

Указываем, что наш класс, кроме того что расширяет базовый класс (в рассматриваемом случае QueueWorkerBase), еще и реализует интерфейс Drupal\Core\Plugin\ContainerFactoryPluginInterface:

class myFirstPlugin extends QueueWorkerBase implements ContainerFactoryPluginInterface {

Добавляем в наш класс свойство $dbConnection, где и будет храниться интересующий нас объект:

/**
 * @var Connection $dbConnection
 */
protected $dbConnection;

Объявляем конструктор, сигнатура которого будет почти совпадать с сигнатурой конструктора родительского класса, за исключением того что у нашего конструктора будет на 1 аргумент больше. Этим последним аргументом мы и будем передавать $dbConnection:

public function __construct(array $configuration, string $plugin_id, $plugin_definition, Connection $dbConnection) {
  parent::__construct($configuration, $plugin_id, $plugin_definition);
  $this->dbConnection = $dbConnection;
}

Подсказка — просто начните писать конструктор в PhpStorm, и он сам сгенерирует основной код. Останется только добавить свои аргументы ($dbConnection).

Но кто же вызовет наш конструктор? Конечно же create(), куда же без него. create() должен выглядеть примерно так:

/**
 * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
 * @param array $configuration
 * @param string $plugin_id
 * @param mixed $plugin_definition
 *
 * @return static
 */
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
  /**
   * @var Connection $dbConnection
   */
  $dbConnection = $container->get('database');

  return new static($configuration, $plugin_id, $plugin_definition, $dbConnection);
}

Полный код класса плагина будет выглядеть так:

<?php

namespace Drupal\my_module\Plugin\QueueWorker;

use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\Database\Connection;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * @QueueWorker(
 * id = "my_test_queue",
 * title = @Translation("My test queue"),
 * cron = {"time" = 20}
 * )
 */

class myFirstPlugin extends QueueWorkerBase implements ContainerFactoryPluginInterface {
  /**
   * @var Connection $dbConnection
   */
  protected $dbConnection;

  public function __construct(array $configuration, string $plugin_id, $plugin_definition, Connection $dbConnection) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->dbConnection = $dbConnection;
  }

  /**
   * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
   * @param array $configuration
   * @param string $plugin_id
   * @param mixed $plugin_definition
   *
   * @return static
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    /**
     * @var Connection $dbConnection
     */
    $dbConnection = $container->get('database');

    return new static($configuration, $plugin_id, $plugin_definition, $dbConnection);
  }

  /**
   * {@inheritdoc}
   */
  public function processItem($item) {
    //Do anything on $item, depending on what we put
    //in $item, when queue has been initialized
  }
}

Ок, но откуда вызывающий код будет знать, что у нас объявлен метод create(), который нужно вызвать? Ответ мы можем найти в самом вызывающем коде (Drupal\Core\Plugin\Factory\ContainerFactory.php):

// If the plugin provides a factory method, pass the container to it.
if (is_subclass_of($plugin_class, 'Drupal\Core\Plugin\ContainerFactoryPluginInterface')) {
  return $plugin_class::create(\Drupal::getContainer(), $configuration, $plugin_id, $plugin_definition);
}

// Otherwise, create the plugin directly.
return new $plugin_class($configuration, $plugin_id, $plugin_definition);

То есть, если плагин реализует фабричный метод (путем реализации интерфейса ContainerFactoryPluginInterface), то в него будет передан сервис-контейнер, в противном случае просто создается объект плагина.

Вот так сложный механизм реализации инъекции зависимости для плагина при детальном рассмотрении оказался весьма понятным и логичным. Успешного вам кодинга на Drupal!

Заголовочное изображение: Очередь в Мавзолей Ленина, 12 марта 1960, Радио Свобода.

Оставьте первый комментарий

Оставить комментарий