【Drupal】バッチ処理を実装してみた

こちらのサイトではDRUPAL10に関連する記事を掲載しています。

はじめに

ウェブ上で時間のかかる処理を行うと504タイムアウトエラーが発生して処理が中断することがよくあります。

Drupalではバッチ処理を簡単に実装することができますのでその方法を紹介します。

ここでは、開発者がモジュール開発を行う際どのようなコードを書けば良いのかの例を示すDrupalのコントリビュートモジュールを使用して説明します。

Access to this page has been denied.

実装の方法

まず、バッチ処理用に新規モジュールを作成します。

モジュール定義用Yaml

name: Batch Example
type: module
description: An example outlining how a module can define batch operations.
package: Example modules
core_version_requirement: ^9.4 || ^10
dependencies:
  - examples:examples
  - drupal:toolbar

# Information added by Drupal.org packaging script on 2023-03-01
version: '4.0.2'
project: 'examples'
datestamp: 1677694704

次にサンプル処理を呼び出すフォームを作成します。

フォームモジュール

<?php

namespace Drupal\batch_example\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Form with examples on how to use cache.
 */
class BatchExampleForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'batch_example_form';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['description'] = [
      '#type' => 'markup',
      '#markup' => $this->t('This example offers two different batches. The first does 1000 identical operations, each completed in on run; the second does 20 operations, but each takes more than one run to operate if there are more than 5 nodes.'),
    ];
    $form['batch'] = [
      '#type' => 'select',
      '#title' => 'Choose batch',
      '#options' => [
        'batch_1' => $this->t('batch 1 - 1000 operations'),
        'batch_2' => $this->t('batch 2 - 20 operations.'),
      ],
    ];
    $form['submit'] = [
      '#type' => 'submit',
      '#value' => 'Go',
    ];

    return $form;

  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    // Gather our form value.
    $value = $form_state->getValues()['batch'];
    // Set the batch, using convenience methods.
    $batch = [];
    switch ($value) {
      case 'batch_1':
        $batch = $this->generateBatch1();
        break;

      case 'batch_2':
        $batch = $this->generateBatch2();
        break;
    }
    batch_set($batch);
  }

  /**
   * Generate Batch 1.
   *
   * Batch 1 will process one item at a time.
   *
   * This creates an operations array defining what batch 1 should do, including
   * what it should do when it's finished. In this case, each operation is the
   * same and by chance even has the same $nid to operate on, but we could have
   * a mix of different types of operations in the operations array.
   */
  public function generateBatch1() {
    $num_operations = 1000;
    $this->messenger()->addMessage($this->t('Creating an array of @num operations', ['@num' => $num_operations]));

    $operations = [];
    // Set up an operations array with 1000 elements, each doing function
    // batch_example_op_1.
    // Each operation in the operations array means at least one new HTTP
    // request, running Drupal from scratch to accomplish the operation. If the
    // operation returns with $context['finished'] != TRUE, then it will be
    // called again.
    // In this example, $context['finished'] is always TRUE.
    for ($i = 0; $i < $num_operations; $i++) {
      // Each operation is an array consisting of
      // - The function to call.
      // - An array of arguments to that function.
      $operations[] = [
        'batch_example_op_1',
        [
          $i + 1,
          $this->t('(Operation @operation)', ['@operation' => $i]),
        ],
      ];
    }
    $batch = [
      'title' => $this->t('Creating an array of @num operations', ['@num' => $num_operations]),
      'operations' => $operations,
      'finished' => 'batch_example_finished',
    ];
    return $batch;
  }

  /**
   * Generate Batch 2.
   *
   * Batch 2 will process five items at a time.
   *
   * This creates an operations array defining what batch 2 should do, including
   * what it should do when it's finished. In this case, each operation is the
   * same and by chance even has the same $nid to operate on, but we could have
   * a mix of different types of operations in the operations array.
   */
  public function generateBatch2() {
    $num_operations = 20;

    $operations = [];
    // 20 operations, each one loads all nodes.
    for ($i = 0; $i < $num_operations; $i++) {
      $operations[] = [
        'batch_example_op_2',
        [$this->t('(Operation @operation)', ['@operation' => $i])],
      ];
    }
    $batch = [
      'operations' => $operations,
      'finished' => 'batch_example_finished',
      // @current, @remaining, @total, @percentage, @estimate and @elapsed.
      // These placeholders are replaced with actual values in _batch_process(),
      // using strtr() instead of t(). The values are determined based on the
      // number of operations in the 'operations' array (above), NOT by the
      // number of nodes that will be processed. In this example, there are 20
      // operations, so @total will always be 20, even though there are multiple
      // nodes per operation.
      // Defaults to t('Completed @current of @total.').
      'title' => $this->t('Processing batch 2'),
      'init_message' => $this->t('Batch 2 is starting.'),
      'progress_message' => $this->t('Processed @current out of @total.'),
      'error_message' => $this->t('Batch 2 has encountered an error.'),
    ];
    return $batch;
  }

}

ここで重要なのはバッチ処理で使用するファンクション名とそのファンクションに引き渡すデータを配列で設定していることです。配列内には、バッチの個々の処理と終了時にエラー判定をする処理の2つの処理を記載します。

for ($i = 0; $i < $num_operations; $i++) {
   // Each operation is an array consisting of
   // - The function to call.
   // - An array of arguments to that function.
    $operations[] = [
     'batch_example_op_1',
    [
      $i + 1,
      $this->t('(Operation @operation)', ['@operation' => $i]),
    ],
  ];
}
$batch = [
  'title' => $this->t('Creating an array of @num operations', ['@num' => $num_operations]),
  'operations' => $operations,
  'finished' => 'batch_example_finished',
];

その配列を次のDrupalシステム関数に引き渡して呼び出すことで処理が実行されます

batch_set($batch);

フォームを呼び出すためには下記のルーティング設定が必要です。

ルーティングYaml

batch_example.form:
  path: '/examples/batch_example'
  defaults:
    _form: '\Drupal\batch_example\Form\BatchExampleForm'
    _title: 'Demo of batch processing'
  requirements:
    _permission: 'access content'

バッチ処理の関数自体は、フック処理を記述するModueファイル内に書くことになります。batch_setに引き渡す配列で指定したファンクション名の処理を作成してください。

Moduleファイル

<?php

/**
 * @file
 * Outlines how a module can use the Batch API.
 */

/**
 * @defgroup batch_example Example: Batch API
 * @ingroup examples
 * @{
 * Outlines how a module can use the Batch API.
 *
 * Batches allow heavy processing to be spread out over several page
 * requests, ensuring that the processing does not get interrupted
 * because of a PHP timeout, while allowing the user to receive feedback
 * on the progress of the ongoing operations. It also can reduce out of memory
 * situations.
 *
 * The @link batch_example.install .install file @endlink also shows how the
 * Batch API can be used to handle long-running hook_update_N() functions.
 *
 * Two harmless batches are defined:
 * - batch 1: Load the node with the lowest nid 100 times.
 * - batch 2: Load all nodes, 20 times and uses a progressive op, loading nodes
 *   by groups of 5.
 *
 * @see batch
 */

/**
 * Batch operation for batch 1: one at a time.
 *
 * This is the function that is called on each operation in batch 1.
 */
function batch_example_op_1($id, $operation_details, &$context) {
  // Simulate long process by waiting 1/50th of a second.
  usleep(20000);

  // 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, batch_example_finished()).
  $context['results'][] = $id;

  // Optional message displayed under the progressbar.
  $context['message'] = t('Running Batch "@id" @details',
    ['@id' => $id, '@details' => $operation_details]
  );
}

/**
 * Batch operation for batch 2: five at a time.
 *
 * This is the function that is called on each operation in batch 2.
 *
 * After each group of 5 control is returned to the batch API for later
 * continuation.
 */
function batch_example_op_2($operation_details, &$context) {
  // Use the $context['sandbox'] at your convenience to store the
  // information needed to track progression between successive calls.
  if (empty($context['sandbox'])) {
    $context['sandbox'] = [];
    $context['sandbox']['progress'] = 0;
    $context['sandbox']['current_node'] = 0;

    // Save node count for the termination message.
    $context['sandbox']['max'] = 30;
  }

  // Process in groups of 5 (arbitrary value).
  // When a group of five is processed, the batch update engine determines
  // whether it should continue processing in the same request or provide
  // progress feedback to the user and wait for the next request.
  // That way even though we're already processing at the operation level
  // the operation itself is interruptible.
  $limit = 5;

  // Retrieve the next group.
  $result = range($context['sandbox']['current_node'] + 1, $context['sandbox']['current_node'] + 1 + $limit);

  foreach ($result as $row) {
    // Here we actually perform our dummy 'processing' on the current node.
    usleep(20000);

    // 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, batch_example_finished()).
    $context['results'][] = $row . ' ' . $operation_details;

    // Update our progress information.
    $context['sandbox']['progress']++;
    $context['sandbox']['current_node'] = $row;
    $context['message'] = t('Running Batch "@id" @details',
      ['@id' => $row, '@details' => $operation_details]
    );
  }

  // Inform the batch engine that we are not finished,
  // and provide an estimation of the completion level we reached.
  if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
    $context['finished'] = ($context['sandbox']['progress'] >= $context['sandbox']['max']);
  }
}

/**
 * Batch 'finished' callback used by both batch 1 and batch 2.
 */
function batch_example_finished($success, $results, $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)]));
    $messenger->addMessage(t('The final result was "%final"', ['%final' => end($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),
        ]
      )
    );
  }
}

/**
 * @} End of "defgroup batch_example".
 */

バッチ処理の実行中の画面です。現在実行中のタスクがステータスバーで表示されます。

バッチ処理の実行中の画面

さいごに

バッチ処理を使用すれば大きな処理も分割して実行されるのでタイムアウトが起きにくくなります。しかしウェブ上から実行しているため、ブラウザタイムアウトは設定を最大にしておく必要があるため注意が必要です。またサーバー上のApacheやPHPの設定も関係しますので、時間のかかる処理をサーバーで行う場合は、Cronシステムを使用するとタイムアウトの心配はなく安全です。

このサイトに関するご意見・ご質問はこちらまで

この記事またはDrupalに関するご質問がございましたら、お気軽にお問い合わせください。

タイトルとURLをコピーしました