Traitement par lots (batch) avec une commande Drush 9 sous Drupal 8

Le traitement par lots (Batch processing en anglais) est souvent un aspect important de tout projet Drupal et plus encore lorsque nous devons traiter de grands volumes de données.

L'un des avantages principaux du traitement par lots réside dans le fait de pouvoir traiter un nombre important de données par petites étapes (chunk) qui se traduisent chacune par une requête indépendante au lieu d'une seule grosse requête pour traiter toutes les données à la fois. Cela nous permettra de réaliser de lourds traitement sans devoir utiliser toutes les ressources mémoires disponibles de notre serveur.

Le fait de diviser un traitement en petits morceaux nous permet aussi d'éviter que le temps allouer à l'exécution de nos scripts PHP ne soit atteint tout en informant nos utilisateurs sur l'avancée de notre traitement.

L'utilisation de la batch API en Drupal peut s'applique aux cas suivants:

  • Importer ou migrer des données depuis ou vers une source externe
  • Nettoyer des données
  • Appliquer des opérations précises sur plusieurs nodes à la fois
  • Communiquer avec une API externe

Normalement les traitement par lots se lancent par un formulaire personnalisé. Cependant, que pouvons nous faire si nous désirons que ces traitement soient lancés par un procédé *nix de type crontab de manière régulière? Une des solutions préférées dans la communauté est de le faire par une commande Drush personnalisée que l'on lancera par ce crontab.

Dans ce post nous allons créer un commande personnalisée de Drush 9 qui récupérera tous les nodes d'un certain type (page, article, ...) grâce à un argument ou paramètre. Ensuite cette commande lancera un traitement par lots pour chaque node ainsi récupéré. Enfin, nous lancerons cette commande par un crontab du système d'exploitation.

Vous pouvez trouver le code du module ici

Voici l'arbre de ce module:

tree web/modules/custom/ex_batch_drush9/
web/modules/custom/ex_batch_drush9/
|-- composer.json
|-- drush.services.yml
|-- ex_batch_drush9.info.yml
|-- README.txt
`-- src
    |-- BatchService.php
    `-- Commands
        `-- ExBatchDrush9Commands.php

Nous allons procéder en trois étapes:

  1. Créer une classe qui aura nos deux méthodes principales pour le traitement par lots (BatchService.php). Nous appellerons ces deux méthodes depuis notre commande personnalisée.
  2. Créer une commande Drush 9 qui récupérera les nodes voulus, créera et traitera la pile (batch sets) par lots (ExBatchDrush9Commands.php).
  3. Créer un tâche de type crontab pour lancer la commande Drush de manière automatique à intervalles réguliers depuis notre système d'exploitation.

 

1. Créer une classe BatchService pour le traitement par lots

Généralement un traitement par lots se compose de deux méthodes: une pour traiter chaque lot, une autre pour réaliser un traitement ex-post une fois que nous avons terminer le traitement de toute la pile.

Dans notre cas nous auront deux méthodes: processMyNode() pour traiter chaque lot (ou élément de la pile) , et processMyNodeFinished() qui sera lancée lorsque le traitement sera terminé.

On considère comme une bonne pratique de placer ces deux méthodes (ou callback functions antérieurement) dans leur propre fichier (ou classe). Cela les séparera de tout autre procédé que notre module implémentera. Dans notre cas, j'ai préféré les mettre dans une classe que nous pouvons réutiliser par la suite comme un service.

Voici le code de la classe BatchService.php

<?php
namespace Drupal\ex_batch_drush9;
/**
 * Class BatchService.
 */
class BatchService {
  /**
   * Batch process callback.
   *
   * @param int $id
   *   Id of the batch.
   * @param string $operation_details
   *   Details of the operation.
   * @param object $context
   *   Context for operations.
   */
  public function processMyNode($id, $operation_details, &$context) {
    // Simulate long process by waiting 100 microseconds.
    usleep(100);
    // Store some results for post-processing in the 'finished' callback.
    // The contents of 'results' will be available as $results in the
    // 'finished' function (in this example, processMyNodeFinished()).
    $context['results'][] = $id;
    // Optional message displayed under the progressbar.
    $context['message'] = t('Running Batch "@id" @details',
      ['@id' => $id, '@details' => $operation_details]
    );
  }
  /**
   * Batch Finished callback.
   *
   * @param bool $success
   *   Success of the operation.
   * @param array $results
   *   Array of results for post processing.
   * @param array $operations
   *   Array of operations.
   */
  public function processMyNodeFinished($success, array $results, array $operations) {
    $messenger = \Drupal::messenger();
    if ($success) {
      // Here we could do something meaningful with the results.
      // We just display the number of nodes we processed...
      $messenger->addMessage(t('@count results processed.', ['@count' => count($results)]));
    }
    else {
      // An error occurred.
      // $operations contains the operations that remained unprocessed.
      $error_operation = reset($operations);
      $messenger->addMessage(
        t('An error occurred while processing @operation with arguments : @args',
          [
            '@operation' => $error_operation[0],
            '@args' => print_r($error_operation[0], TRUE),
          ]
        )
      );
    }
  }
}

La méthode processMyNode() traitera chaque élément de la pile. Comme vous pouvez le voir, dans cette méthode nous simulons une longue opération avec la function usleep() de PHP. Ici nous pourrions loader chaque node et réaliser une opération sur ce dernier, ou nous connecter à une API externe. Ensuite nous enregistrons certaines données dans la variable 'context' pour le traitement ex-post.

Dans la méthode processMyNodeFinished() nous affichons certaines données pertinentes pour l'utilisateur final et nous pouvons aussi sauvegarder les opérations qui n'ont pas pu être réalisées pour un traitement futur.

Nous avons donc les deux méthodes qui traiterons chaque élément de notre pile. Nous allons à présent créer la commande qui nous permettra de récupérer certains nodes et de les traiter grâce aux méthodes que nous venons de définir.

2. Créer une commande personnalisée Drush 9 pour lancer le traitement

Nous nous trouvons à présent dans la partie la plus importante de notre module. Avec cette commande Drush, nous allons récupérer les données dont nous avons besoin et nous allons lancer le traitement par lots de ces données.

Dorénavant les commandes Drush se basent sur des classes avec un format d'annotations. Cela change fondamentalement la structure des commandes Drush. Le bon côté de ce changement c'est que nous pouvons à présent injecter des services dans nos commandes et ainsi tirer tous les avantages de la force OO de Drupal 8.

Une commande Drush 9 es composée de trois éléments:

drush.services.yml - C'est dans ce fichier que se trouve la définition de notre commande Drush. C'est une définition de service comme en Symfony. N'utilisez pas votre fichier services.yml comme vous le faisiez avec Drush 8 sinon vous aurez une erreur PHP.

Comme vous pouvez le voir ci-dessous, nous injectons deux services du coeur de Drupal dans notre commande : entity_type.manager pour récupérer les nodes que nous allons traiter et logger.factory pour enregistrer dans le log des informations tout au long du traitement.

services:
  ex_batch_drush9.commands:
    class: \Drupal\ex_batch_drush9\Commands\ExBatchDrush9Commands
    tags:
      - { name: drush.command }
    arguments: ['@entity_type.manager', '@logger.factory']

composer.json - C'est ici que nous allons déclarer la version de Drush que nous allons utiliser pour chaque commande Drush de notre module. Ce n'est pas obligatoire, mais ce le sera avec Drush 10.

{
    "name": "org/ex_batch_drush9",
    "description": "This extension provides new commands for Drush.",
    "type": "drupal-drush",
    "authors": [
        {
            "name": "Author name",
            "email": "author@example.com"
        }
    ],
    "require": {
        "php": ">=5.6.0"
    },
    "extra": {
        "drush": {
            "services": {
                "drush.services.yml": "^9"
            }
        }
    }
}

MyModuleCommands.php - (src/Commands/ExBatchDrush9Commands.php dans notre cas) C'est dans cette classe que nous allons définir les commandes Drush de notre module. Cette classe utilise des annotations pour les méthodes de nos commande, ce qui veut dire qu'à présent, chaque commande est une méthode avec son annotation qui définit son nom, ses alias, ses arguments, ect... Cette classe peut aussi définir des hooks avec l'annotation @hook.

Certaines des annotations disponibles sont:

@command: Défini le nom de la commande. N'oubliez pas d'appliquer la structure de commande de Symfony module:command . Dans notre cas ce sera update:node.
@aliases: Un alias pour notre commande.
@param: Défini les paramètres ou arguments de la commande. Par exemple, @param: string $type. Dans ce cas nos attendrons de la commande un paramètre (le type de node) de type string dans la variable $type.
@option: Défini les options disponibles de notre commande. Ce doit être un vecteur où la clé sera le nom de l'option et la valeur peut être: false, true, string, InputOption::VALUE_REQUIRED, InputOption::VALUE_OPTIONAL.
@default: Défini l'option par défaut.
@usage: Démontre comment la commande devrait être utilisée. Par exemple, @usage: mymodule:command param --option
@hook: Défini un hook à exécuter. Le format par défaut est le suivant: @hook type target, où type détermine quand le hook doit être lancé, et target où le hook est appelé.

Pour une liste complète de tous les hooks disponibles, référez-vous à ce lien: https://github.com/consolidation/annotated-command

Voici le code de notre commande Drush

<?php
namespace Drupal\ex_batch_drush9\Commands;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drush\Commands\DrushCommands;
/**
 * A Drush commandfile.
 *
 * In addition to this file, you need a drush.services.yml
 * in root of your module, and a composer.json file that provides the name
 * of the services file to use.
 *
 * See these files for an example of injecting Drupal services:
 *   - http://cgit.drupalcode.org/devel/tree/src/Commands/DevelCommands.php
 *   - http://cgit.drupalcode.org/devel/tree/drush.services.yml
 */
class ExBatchDrush9Commands extends DrushCommands {
  /**
   * Entity type service.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  private $entityTypeManager;
  /**
   * Logger service.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  private $loggerChannelFactory;
  /**
   * Constructs a new UpdateVideosStatsController object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   Entity type service.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerChannelFactory
   *   Logger service.
   */
  public function __construct(EntityTypeManagerInterface $entityTypeManager, LoggerChannelFactoryInterface $loggerChannelFactory) {
    $this->entityTypeManager = $entityTypeManager;
    $this->loggerChannelFactory = $loggerChannelFactory;
  }
  /**
   * Update Node.
   *
   * @param string $type
   *   Type of node to update
   *   Argument provided to the drush command.
   *
   * @command update:node
   * @aliases update-node
   *
   * @usage update:node foo
   *   foo is the type of node to update
   */
  public function updateNode($type = '') {
    // 1. Log the start of the script.
    $this->loggerChannelFactory->get('ex_batch_drush9')->info('Update nodes batch operations start');
    // Check the type of node given as argument, if not, set article as default.
    if (strlen($type) == 0) {
      $type = 'article';
    }
    // 2. Retrieve all nodes of this type.
    try {
      $storage = $this->entityTypeManager->getStorage('node');
      $query = $storage->getQuery()
        ->condition('type', $type)
        ->condition('status', '1');
      $nids = $query->execute();
    }
    catch (\Exception $e) {
      $this->output()->writeln($e);
      $this->loggerChannelFactory->get('ex_batch_drush9')->warning('Error found @e', ['@e' => $e]);
    }
    // 3. Create the operations array for the batch.
    $operations = [];
    $numOperations = 0;
    $batchId = 1;
    if (!empty($nids)) {
      foreach ($nids as $nid) {
        // Prepare the operation. Here we could do other operations on nodes.
        $this->output()->writeln("Preparing batch: " . $batchId);
        $operations[] = [
          '\Drupal\ex_batch_drush9\BatchService::processMyNode',
          [
            $batchId,
            t('Updating node @nid', ['@nid' => $nid]),
          ],
        ];
        $batchId++;
        $numOperations++;
      }
    }
    else {
      $this->logger()->warning('No nodes of this type @type', ['@type' => $type]);
    }
    // 4. Create the batch.
    $batch = [
      'title' => t('Updating @num node(s)', ['@num' => $numOperations]),
      'operations' => $operations,
      'finished' => '\Drupal\ex_batch_drush9\BatchService::processMyNodeFinished',
    ];
    // 5. Add batch operations as new batch sets.
    batch_set($batch);
    // 7. Process the batch sets.
    drush_backend_batch_process();
    // 8. Show some information.
    $this->logger()->notice("Batch operations end.");
    // 9. Log some information.
    $this->loggerChannelFactory->get('ex_batch_drush9')->info('Update batch operations end.');
  }
}

Dans cette classe nous injectons deux services du coeur de Drupal dans la méthode __construct(): entity_type.manager et logger.factory.

Ensuite dans la méthode updateNode() nous définissons notre commande grâce à trois annotations:

@param string $type - Défini l'argument, dans notre cas, le type de contenu duquel nous allons récupérer tous les nodes.
@command update:node - Défini le nom de la commande. Dans notre cas, nous pourrons lancer la commande avec: drush update:node
@aliases update-node - Défini l'alias de la commande

La partie importante de cette commande est la création du vecteur d'opérations (la pile) pour notre traitement par lots (Voyez les points 3,4,5 et 6 du code ci-dessus). Rien de bien extraordinaire ici, nous initialisons notre vecteurs d'opérations (3 et 4) qui pointe vers nos deux callback méthodes que nous avons définies dans la classe BatchService.php.

Une fois l'ensemble des opérations (les lots à traiter) de notre pile définies (5), nous allons traiter ces lots avec la fonction drush_backend_batch_process(). Cette fonction remplace la function existante de Drupal batch_process(). Elle va traiter les lots en appelant à chaque fois un processus Drush.

Enfin, dans les points 8 et 9, nous affichons certaines données et nous en sauvegardons aussi dans le log au cas où.

Voilà! Nous pouvons à présent tester notre toute nouvelle commande Drush 9 !

Pour ce faire, nous nettoyons le cache avec drush cr et lançons la commande avec: drush update:node

3. Bonus: Lancer la commande Drush depuis crontab

Comme nous l'avons vu plus haut, nous voulons lancer cette commande depuis crontab pour pouvoir effectuer notre update de manière automatique à un intervalle donné.

La procédure à suivre peut varier selon votre système d'exploitation. Pour Mac OS et Linux, nous gérons nos tâches programmées en créant et éditant des crontabs.

1. Ouvrez votre terminal et introduisez la commande suivante pour éditer votre crontab - cela ouvrira un éditeur de texte par défaut (normalement vi):

crontab -e

2. Introduisez la tâche suivante avec notre commande Drush (où [docroot_path] est le chemin de votre installation Drupal) et sauvagardez :

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * drush --root=[docroot_path] update:node

Nous avons besoin tout d'abord de définir un chemin d'environnement à la première ligne. Ensuite nous définissons l'intervalle (toutes les 5 minutes) et la commande Drush.

3. Relancez le service cron avec la commande suivante:

systemctl restart cron

4. Vérifier si la commande s'exécute
Nous pourrions réviser le log de Drupal vu que nous l'alimentons avec la commande en elle-même. Mais nous pouvons aussi utiliser la commande suivante pour voir les résultats en temps réel:

sudo tail -f /var/mail/root

 

Récapitulons.

Notre objectif était de pouvoir lancer un traitement par lots via une commande externe à Drupal (commande Drush), pour cela nous avons:

- créé une classe avec nos deux méthodes pour réaliser le traitement par lots (BatchService.php).
- crée une commande personnalisée de Drush 9 pour récupérer les nodes d'un certain type et traiter les lots en y injectant des services, comme entity_type.manager pour accéder aux nodes et  logger.factory pour sauvegarder des informations dans le log.
- créé une tâche crontab pour exécuter notre commande Drush automatiquement à intervalles réguliers.

De cette manière nous pouvons traiter de grands volumes information, automatiquement, à un intervalle donné, sans pour autant mettre en péril la mémoire de notre serveur.

Une fois encore, Drupal nous démontre sa puissance mais aussi toute sa flexibilité. Serait-ce pour cela que nous l'aimions autant? A vous de juger !