Aller au contenu principal
Aller au contenu principal

Bonjour, je suis Karim Boudjema. Développeur back-end senior établi à Montréal, au Canada, passionné par Drupal, l'IA et les tests automatisés.

Traitement par lots (batch) avec une commande Drush en Drupal 11

Le traitement par lots compte sur presque tous les projets Drupal, et plus encore quand il faut traiter une grande quantité de données. L'idée est de découper une tâche lourde en petits morceaux, chacun exécuté comme sa propre requête de page, pour ne jamais demander au serveur de tout faire en une seule fois.

C'est ce qui évite au processus de mourir sur un timeout PHP, et cela laisse l'utilisateur suivre une barre de progression au lieu d'un écran figé. Quelques tâches typiques pour la Batch API :

  • importer ou migrer des données depuis une source externe,
  • nettoyer des données internes,
  • exécuter une action sur un ensemble de nodes,
  • dialoguer avec une API externe pour chaque élément.

Un batch est d'habitude lancé depuis un formulaire. Mais que faire si nous voulons qu'une crontab *nix le lance à intervalles réguliers, sans personne devant le navigateur ? Une des solutions les plus propres est de déclencher le batch depuis une commande Drush personnalisée, et de laisser la crontab appeler cette commande.

Dans ce post, nous allons construire une commande Drush personnalisée qui charge tous les nodes d'un type de contenu passé en argument (page, article, et ainsi de suite), puis lance un batch qui simule une longue opération sur chaque node. Ensuite, nous verrons comment lancer la commande depuis une crontab. L'exemple original en Drupal 8 se trouve sur github.com/KarimBoudjema/Drupal8-ex-batch-with-drush9-command ; le code ci-dessous est réécrit pour Drupal 11.

Voici l'arborescence du module. Remarquez comme elle est plus simple que la version Drupal 8 : plus de drush.services.yml ni de déclaration de service dans composer.json.

web/modules/custom/ex_batch/
|-- ex_batch.info.yml
`-- src
    |-- BatchService.php
    `-- Drush
        `-- Commands
            `-- ExBatchCommands.php

Nous allons procéder en trois étapes :

  1. une classe BatchService pour héberger les deux callbacks du batch (BatchService.php) ;
  2. une commande Drush 13 personnalisée qui charge les nodes et lance le batch (ExBatchCommands.php) ;
  3. une tâche crontab qui exécute la commande automatiquement à heures fixes.

Générons le squelette du module avec le générateur de Drush 13 (Drupal Console n'existe plus) :

drush generate module

1. Une classe BatchService pour les callbacks du batch

Un batch s'articule autour de deux callbacks : l'un qui traite chaque morceau, l'autre qui s'exécute à la fin. C'est une bonne pratique de les garder hors du fichier .module ; ici ils vivent donc comme deux méthodes statiques d'une petite classe que nous pourrions réutiliser plus tard : processNode() pour chaque élément, et finished() pour la conclusion.

<?php

declare(strict_types=1);

namespace Drupal\ex_batch;

/**
 * Héberge les callbacks d'opération et de fin du batch.
 */
final class BatchService {

  /**
   * Traite un node. Appelée une fois par opération du batch.
   *
   * @param int $nid
   *   L'ID du node à traiter.
   * @param string $operationDetails
   *   Un court message décrivant l'opération.
   * @param array $context
   *   Le contexte du batch, passé par référence et conservé entre les morceaux.
   */
  public static function processNode(int $nid, string $operationDetails, array &$context): void {
    // Simule une longue opération. Ici nous chargerions le node, appellerions
    // une API externe, réécririons un champ, et ainsi de suite.
    usleep(100000);

    // Tout ce qui est stocké sous 'results' est transmis à finished() à la fin.
    $context['results'][] = $nid;

    // Affiché sous la barre de progression pendant l'exécution du batch.
    $context['message'] = t('Traitement du node @nid : @details', [
      '@nid' => $nid,
      '@details' => $operationDetails,
    ]);
  }

  /**
   * S'exécute une fois, quand toutes les opérations sont terminées.
   *
   * @param bool $success
   *   TRUE si aucune opération n'a laissé remonter d'exception.
   * @param array $results
   *   Tout ce que les opérations ont déposé dans $context['results'].
   * @param array $operations
   *   Les opérations non traitées (seulement en cas d'échec).
   */
  public static function finished(bool $success, array $results, array $operations): void {
    $messenger = \Drupal::messenger();
    if ($success) {
      $messenger->addStatus(t('@count node(s) traité(s).', ['@count' => count($results)]));
      return;
    }
    // En cas d'échec, $operations contient ce qui restait à faire.
    $failed = reset($operations);
    $messenger->addError(t('Une erreur est survenue lors du traitement : @op', [
      '@op' => print_r($failed, TRUE),
    ]));
  }

}

processNode() est l'endroit où irait le vrai travail : ici nous attendons simplement 100 millisecondes avec usleep() pour figurer une tâche lente. Deux lignes méritent qu'on s'y arrête. $context['results'][] collecte une valeur par élément, et c'est tout ce tableau qui parvient à finished() via son argument $results. $context['message'] est la ligne que Drupal affiche sous la barre de progression. Dans finished(), nous indiquons simplement combien de nodes sont passés, ou nous faisons remonter ce qui restait en cas d'échec.

2. La commande Drush 13 personnalisée

C'est le cœur du module. En Drupal 8, une commande Drush 9 demandait trois fichiers : un drush.services.yml, un composer.json avec une section extra.drush.services, et la classe de commande elle-même avec ses annotations @command. Plus rien de tout cela n'est nécessaire. En Drush 13, une commande n'est qu'une classe placée sous src/Drush/Commands/, et Drush la découvre grâce à l'attribut PHP #[CLI\Command] : les annotations ont disparu en même temps que l'ancien fichier de services.

<?php

declare(strict_types=1);

namespace Drupal\ex_batch\Drush\Commands;

use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\ex_batch\BatchService;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Lance un batch sur tous les nodes d'un type de contenu donné.
 */
final class ExBatchCommands extends DrushCommands {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly LoggerChannelFactoryInterface $loggerFactory,
  ) {
    parent::__construct();
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): self {
    return new self(
      $container->get('entity_type.manager'),
      $container->get('logger.factory'),
    );
  }

  /**
   * Traite dans un batch tous les nodes publiés d'un type de contenu.
   */
  #[CLI\Command(name: 'exbatch:update-nodes', aliases: ['exbatch'])]
  #[CLI\Argument(name: 'type', description: 'Le type de contenu (bundle de node) à traiter.')]
  #[CLI\Usage(name: 'drush exbatch:update-nodes article', description: 'Lance le batch sur tous les articles publiés.')]
  public function updateNodes(string $type = 'article'): void {
    $this->loggerFactory->get('ex_batch')->info('Batch de mise à jour démarré pour les nodes @type.', ['@type' => $type]);

    // 1. Charger tous les nodes publiés de ce type.
    $storage = $this->entityTypeManager->getStorage('node');
    $nids = $storage->getQuery()
      ->condition('type', $type)
      ->condition('status', 1)
      ->accessCheck(FALSE)
      ->execute();

    if (!$nids) {
      $this->logger()->warning(dt('Aucun node @type publié trouvé.', ['@type' => $type]));
      return;
    }

    // 2. Construire le batch : une opération par node.
    $batch = (new BatchBuilder())
      ->setTitle(dt('Traitement de @count node(s) @type', ['@count' => count($nids), '@type' => $type]))
      ->setFinishCallback([BatchService::class, 'finished']);

    foreach ($nids as $nid) {
      $batch->addOperation(
        [BatchService::class, 'processNode'],
        [(int) $nid, dt('Mise à jour du node @nid', ['@nid' => $nid])],
      );
    }

    // 3. Enregistrer le batch, puis laisser Drush l'exécuter jusqu'au bout.
    batch_set($batch->toArray());
    drush_backend_batch_process();

    $this->logger()->success(dt('Batch de mise à jour terminé.'));
  }

}

Nous injectons deux services de cœur dans le constructeur : entity_type.manager pour charger les nodes, et logger.factory pour journaliser le début et la fin. La méthode create() les câble depuis le conteneur, exactement comme le ferait un contrôleur.

La commande elle-même est la méthode updateNodes(), et trois attributs la décrivent :

#[CLI\Command(name: 'exbatch:update-nodes', aliases: ['exbatch'])]
#[CLI\Argument(name: 'type', description: 'Le type de contenu (bundle de node) à traiter.')]
#[CLI\Usage(name: 'drush exbatch:update-nodes article', description: 'Lance le batch sur tous les articles publiés.')]

#[CLI\Command] donne à la commande son nom (et un alias) ; #[CLI\Argument] documente l'argument $type ; #[CLI\Usage] affiche un exemple dans l'aide de la commande. Cela remplace terme pour terme les anciennes annotations @command, @aliases et @usage.

La partie intéressante, c'est le batch lui-même. En Drupal 8, nous écrivions un tableau $batch à la main ; en Drupal 11, nous le construisons de manière fluide avec BatchBuilder :

$batch = (new BatchBuilder())
  ->setTitle(dt('Traitement de @count node(s) @type', ['@count' => count($nids), '@type' => $type]))
  ->setFinishCallback([BatchService::class, 'finished']);

foreach ($nids as $nid) {
  $batch->addOperation([BatchService::class, 'processNode'], [(int) $nid, $details]);
}

addOperation() met en file un appel à notre callback processNode() par node, avec ses arguments ; setFinishCallback() pointe vers finished(). Une fois le batch prêt, nous l'enregistrons avec batch_set($batch->toArray()), puis nous appelons drush_backend_batch_process(), l'utilitaire Drush qui pilote un batch Drupal depuis la ligne de commande en générant lui-même les requêtes.

C'est tout. Videz le cache avec drush cr et lancez la commande :

drush exbatch:update-nodes article

3. Lancer la commande Drush depuis une crontab

Nous voulons maintenant que la crontab exécute la commande d'elle-même, à intervalles réguliers. Les étapes exactes dépendent du système d'exploitation du serveur ; sous Linux, macOS et Unix, nous éditons une crontab qui exécute des tâches à intervalles définis.

Ouvrez votre crontab pour l'éditer (cela ouvre votre éditeur par défaut) :

crontab -e

Ajoutez une ligne planifiée avec notre commande (remplacez [docroot] par le chemin du docroot de votre site). La première ligne définit un PATH pour que cron trouve le binaire drush :

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * cd [docroot] && drush exbatch:update-nodes article

Cela exécute la commande toutes les cinq minutes. Comme nous journalisons le début et la fin dans la commande, nous pouvons surveiller le journal de Drupal pour confirmer qu'elle se déclenche, ou lire le courrier de cron :

sudo tail -f /var/mail/root

En résumé. Nous avons écrit une classe BatchService avec deux callbacks statiques, processNode() pour chaque élément et finished() pour la conclusion. Nous avons écrit une commande Drush 13 personnalisée qui charge tous les nodes publiés d'un type de contenu, construit le batch avec BatchBuilder, l'enregistre avec batch_set() et l'exécute avec drush_backend_batch_process(). Enfin, nous avons planifié la commande dans une crontab. Modernisé pour Drupal 11, le vrai changement par rapport à l'original Drupal 8 est la commande Drush elle-même : plus de drush.services.yml, plus de bloc de service dans composer.json, juste une classe sous src/Drush/Commands/ avec l'attribut #[CLI\Command], et un batch construit avec BatchBuilder au lieu d'un tableau brut.

Nous pouvons ainsi lancer des traitements lourds à intervalles réguliers sans jamais surcharger le serveur. Et vous, comment lancez-vous vos batchs, depuis un formulaire ou depuis la CLI ? Dites-le-nous dans les commentaires.