MagentoのObserver、Cron、コマンドラインツールを理解しよう〜エクステンション開発上級編その2〜

前回から、Magentoのエクステンションを作る際のテクニックを解説しています。前回はPluginとProxyをご紹介しました。今回はMagentoに用意されている以下の3つの仕組みについてご説明しましょう。

  • Observer
  • Cron
  • コマンドラインツール

Observer

Observerは、Magento1の頃から存在している仕組みです。予め定義されている箇所(イベントといいます)に処理が到達すると、その箇所に関連付けられている処理が自動的に実行される、という仕組みです。前回とりあげたPluginと異なるのは、Pluginはpublicメソッドしか対象にしていないのに対し、Observerは定義さえ書かれていればprivateやprotectedメソッドも対象にできるという点です。また、Pluginはそのメソッドに入ってくる値やメソッドが返す値を改変することが目的ですが、Observerは「何らかの処理」を行うことが目的です。実装する内容自体には特に制限はありません。

イベントが定義されている箇所

イベントはMagentoの標準実装だけで、100箇所以上に定義されています。もちろん自分でも定義することはできますが、最初は標準に用意されているものから理解していくことをおすすめします。

イベントを探す際は、Magentoのコードを「eventManager->dispatch」で検索すると比較的簡単に探せます。検索結果をみていただくとわかりますが、以下のようにイベント名と渡す値が定義されています。

$this->_eventManager->dispatch('model_load_after', ['object' => $this]);

この例は、Magento\Framework\Moel\AbstractModel.phpの580行目にあるものですが、「model_load_after」というイベント名を定義し、呼び出されるObserver側に自分自身を渡しています。このようにイベントを定義する場合は、イベントを管理しているオブジェクトのdispatchメソッドを呼んで、イベント名と渡す値を配列型で指定します。あとはObserverクラス側で好きなように実装ができます。

Observerを定義するには

Observerを定義するには、以下の2つを作成する必要があります。

  • events.xml
  • Observerクラス

events.xml

events.xmlはどのイベントでどのObserverクラスを実行するかを定義するためのファイルです。このファイルがないとObserverは動かないので、Observerを使いたい場合には必ず定義しましょう。exents.xmlは下記のような形で定義を記述します。配置する場所は他の設定ファイルと同じ、エクステンションディレクトリ内のetcディレクトリです。

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_save_after">
        <observer name="my_observer" instance="My\Extension\Observer\MyObserverAction" />
    </event>
</config>

Observerクラス

Observerクラスは、Observerで実現したい処理の実体です。Magento\Framework\Event\ObserverInterfaceを実装する事になっています。Magento2の仕様では、Observerは以下の規則に沿って実装することになっています。

  • 1つのObserverには1つの処理のみを書く
  • ObserverInterfaceで定義されている、executeメソッドを実装する

他のメソッドを書いても実行はされないので、注意してください。
Observerの基本的な書き方は下記の例のようになります。

declare(strict_types=1);

namespace My\Extension\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order;

class MyObserverAction implements ObserverInterface
{
    /**
     * @param Observer $observer
     */
    public function execute(Observer $observer)
    {
        //なにか処理を記述

        return;
    }
}
呼び出し元から与えられる値を取得したい場合は

Observerは、ある特定のイベントに対して、なにか処理を実行するために作成します。このとき、イベント側から渡された値を使用したいことが多々あります。そういうときは以下のようにexecuteメソッド内で記述することができます。(以下の例は、sales_order_save_afterイベントの場合の例です)

/** @var Order $order */
$order = $observer->getEvent()->getData('order');

この例では、executeメソッドに入ってくる$observerのgetEventメソッドを呼び、更にその戻り値に対してgetDataメソッドを呼んでいます。イベント呼び出し側が、dispatchメソッドの第二引数で与えた配列データは、$observerのgetEventメソッドで得られる「Magento\Framework\Event」型のオブジェクトに格納されています。このオブジェクトのgetDataメソッドにdispatch時に指定したキー値を与えると、その値が得られるという仕組みです。得られる値やキー値についてはイベントごとに異なるので、Observerを実装するときによく調べてから使用するようにしてください。

Observerを使う場合の注意

Observerはシンプルで、かつ呼び出し元に改変を加えることなくMagentoを拡張できる仕組みですが、反面油断をすると問題を発生させる恐れがあります。例えば、以下のようにObserverの処理内で起きたエラーを適切に処理していない場合、元々の処理側にも影響を及ぼす恐れがあります。

$order = $observer->getEvent()->getData('order');
$orderItems = $order->getAllItems();

foreach($orderItems as $item) {
    $productId = $item->getProductId();
    //productRepository->getById() はデータがないと例外を発生する。
    $product = $this->productRepository->getById($productId);
}

このような場合は当たり前ですが、以下のようにきちんと例外をキャッチして、呼び出し元に影響が及ばないようにしましょう。

$order = $observer->getEvent()->getData('order');
$orderItems = $order->getAllItems();

foreach($orderItems as $item) {
    $productId = $item->getProductId();

    try {
        //productRepository->getById() はデータがないと例外を発生する。
        $product = $this->productRepository->getById($productId);
    } catch (\Exception $e) {
        //適切な例外処理を記述して、スコープ外にでないようにする    
    }
}

ちなみにMagentoにはいろいろな種類の例外が定義されています。例ではPHPに元々あるExceptionクラスを指定していますが、適切な型の例外を指定して、処理を分岐させても良いでしょう。いずれにしても、呼び出し元に影響するような書き方は避けたほうがよいと思います。

また、1つのイベントに複数のObserverを定義できるため、以下のような正しくない書き方をしてしまうとObserverのループに陥る恐れがあります。(以下の例は、注文データの保存後に実行されるsales_order_save_afterイベントを想定しています)

$order = $observer->getEvent()->getData('order');
$orderItems = $order->getAllItems();

foreach($orderItems as $item) {
    //注文商品に追加データをセットしてみる
    $item->setAdditionalData('追加データ');
}

//注文データを保存すると、保存に伴うイベントが再発
$this->orderRepository->save($order);

上記のような例の場合は、

  • Observerの中で保存処理をしない
  • 対象データがデータベースに保存される前のイベントを使う

という方法を取るのが無難です。sales_order_save_afterイベントは保存後のイベントなので、sales_order_save_beforeイベントなどの他のイベントを使うのがよいでしょう。保存前であれば、単純に値をセットしておくだけで、後続の処理が適切に保存してくれます。

Pluginもそうですが、Observerを実装する際は、他の処理に影響を及ぼさないかどうか、よく検証と試験を行うことをおすすめします。

Cron

Linuxに慣れ親しんでいる方であれば特に説明の必要はないかと思いますが、Cronというのは定時処理のことです。Magentoでは多くの定時処理が走りますが、その処理時間や処理タイミングを管理しているのがCronです。通常MagentoのCronは、その実行予定をデータベース上のcron_scheduleテーブルで管理しています。

Cronを実行するには

Magentoのインストール後に行う設定の1つに、LinuxのCronジョブを設定するというものがあります。DevDocsにもきちんと書いてありますが、以下のようなCron定義をMagentoを動かすサーバー上で事前に行う必要があります。

* * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run | grep -v Ran jobs by schedule >> /var/www/html/magento2/var/log/magento.cron.log
* * * * * /usr/bin/php /var/www/html/magento2/update/cron.php >> /var/www/html/magento2/var/log/update.cron.log
* * * * * /usr/bin/php /var/www/html/magento2/bin/magento setup:cron:run >> /var/www/html/magento2/var/log/setup.cron.log

通常は毎分実行するようになっていて、Magentoの内部処理でcron_scheduleテーブルから実行予定のジョブを取り出して実行していきます。LinuxのCronジョブを設定しない場合はMagentoのCronも動作しないので、必ず設定するようにしましょう。

Cron実行の設定

MagentoにはCron実行の設定をする機能があり、どの程度のタイミングで新しいジョブを作成したり、古いジョブを削除したりするかを定義できます。管理画面の「店舗>設定>システム」に行くと、Cronジョブの設定をするタブがあります。ここで新しくジョブを作成するタイミングや、処理が終わったジョブやエラーになったジョブを削除するタイミングを指定します。

なお、現在のMagentoにはCronジョブグループという概念があり、標準ではデフォルトグループとインデックスグループの2つが用意されています。もちろん、このグループは独自に定義することができ、処理に時間のかかるジョブをMagento標準のジョブグループから切り離すこともできます。

Cronジョブの定義

Cronジョブを定義するには、以下の3つを作成する必要があります。

  • crontab.xml
  • cron_groups.xml(任意)
  • Cronジョブクラス

crontab.xml

最初に、Cronジョブの定義を作成します。XMLファイルを書くだけで定義はできるので、Observerの定義と同じように書いてしまいましょう。基本的なcrontab.xmlは以下のように定義します。

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
        <job name="my_example_cron"
             instance="My\Example\Cron\FirstCronJob"
             method="execute">
            <schedule>*/5 * * * *</schedule>
        </job>
    </group>
</config>

crontab.xml固有の定義として、Cronジョブの実行時間指定があります。scheduleタグの値で指定する部分ですね。この例では5分おきに処理をしています。基本的にはLinuxのCronと同じ書き方が使えるので、Linuxに慣れている方には難しくないと思います。

cron_groups.xml

cron_groups.xmlはCronグループの定義ファイルです。独自のCronグループを定義しないのであれば省略しても構いません。cron_groups.xmlを定義した場合は、crontab.xmlのグループ指定にcron_groups.xmlで宣言した値が使えるようになります。定義したい場合は、以下のようにファイルを作成して記述します。

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
    <group id="my_example_group">
        <schedule_generate_every>5</schedule_generate_every>
        <schedule_ahead_for>4</schedule_ahead_for>
        <schedule_lifetime>2</schedule_lifetime>
        <history_cleanup_every>10</history_cleanup_every>
        <history_success_lifetime>60</history_success_lifetime>
        <history_failure_lifetime>600</history_failure_lifetime>
        <use_separate_process>1</use_separate_process>
    </group>
</config>

Cronジョブクラス

仕上げにCronジョブクラスを定義します。Cronジョブに使うクラスは、他のクラスを継承したり、インターフェイスを実装する必要はありません。唯一気をつけることは、crontab.xmlで指定したメソッドを実装することだけです。あとはジョブでどんなことをするかは実装者の自由です。今回は以下のようにお試しのコードを書いてみましょう。

declare(strict_types=1);

namespace My\Example\Cron;

use \Psr\Log\LoggerInterface;

class MyFirstCron
{
    private $logger;

    /**
     *  @param LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger) {
        $this->logger = $logger;
    }

    public function execute()
    {
        $this->logger->info('Cron Works');
    }
}

上記の例では、ログファイルに所定の文字を出力する、という処理を実行しています。もちろん他の処理を書くことで、様々な定時処理を実現できます。

Cronジョブで注意するべきこと

Cronジョブは実行される際に、グループ単位で実行されます。ジョブ自体は同じグループ内のものを1つずつ順番に実行していく仕組みになっています。もし途中に時間のかかるジョブがある場合、そのジョブが終わるまでの間は他のジョブは待たされ続けます。ところが、あまりにも予定よりもジョブの実行開始が遅くなる場合、Magentoはジョブをスキップしてしまうことがあります。頻繁に実行するようなジョブであればさほど問題にはならないかもしれませんが、1日1回しか実行しないようなジョブの場合、ジョブがスキップされてしまうと翌日に回されてしまうことになります。プロモーションルールの適用などは1日1回しか実行しないジョブなので、他のジョブによってスキップされることがないように注意しましょう。

コマンドラインツールとは

MagentoにはPHPフレームワーク・Symfonyのコンポーネントを使ったコマンドラインツールが用意されています。インストールだけでなく、様々な操作でコマンド操作を伴うものがあります。標準のMagentoにどのようなコマンドが用意されているかは、Magentoのルートディレクトリで

php bin/magento

を実行するとわかります。

さらに個別のコマンドで「—help」をつけて実行すると、以下のように使い方が表示されます。例えば、

php bin/magento admin:user:create --help

と実行すると、以下のようなヘルプが表示されます。

独自にコマンドを定義するには

コマンドを定義するためには、以下の2つが必要です。

  • di.xmlの定義
  • コマンド用の個別クラス

難しい定義が必要なわけではありませんが、コマンドは基本的にLinuxのシェルやCronから実行されることが前提になっていることを忘れないでください。

di.xmlの定義

最初にdi.xmlを作成します。定義自体は10行程度のもので、さほど複雑ではありません。以下のように書くことで、コマンドを使うための定義は完了です。

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Console\CommandList">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="MyFirstCommand" xsi:type="object">My\Example\Command\MyFirstCommand</item>
            </argument>
        </arguments>
    </type>
</config>

コマンドの定義を増やしたい場合は、itemタグを増やしていくだけで構いません。

コマンド用の個別クラスの作成

di.xmlの定義ができたら、コマンド用のクラスを作成します。コマンド用のクラスは「Symfony\Component\Console\Command\Command」を継承して実装します。このとき、決まりごととして以下の2つを守るようにしてください。

  • クラス名を「Command」で終わるように定義する
  • protectedメソッドのexecuteを実装する

前者は命名規則でそうなっているので、おとなしく従いましょう。後者の方は実装しないと例外が投げられるようになっています。必ず実装してください。今回はシンプルなコマンドを実装するので、以下のようにクラスを作成します。

declare(strict_types=1);

namespace My\Example\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument;

class MyFirstCommand extends Command
{

    protected function execute(InputInterface $input, OutputInterface $output)
    {
         $output->writeln('Command was run.');
    }

    protected function configure()
    {
         $this->setName('my:first:command');
         parent::configure();
    }
}

コマンドが認識されるか試してみよう

実装ができたら、一旦Magentoのキャッシュをクリアします。その後、magentoコマンドを実行し、コマンドが認識されているかを確認してみましょう。正しく定義と実装ができていれば、利用可能なコマンドの一覧に作成したコマンドが表示されているかと思います。

コマンドライン引数の受け取り

コマンドを実装する場合、ファイル名の指定や処理モードの切替などを行いたくなります。そういうときはコマンドライン引数を受け取れるようにすれば解決できます。元になる実装はすでに親クラス側が用意してくれているので、executeメソッド内で以下のように書けばOKです。

$text = $input->getOption('text');

今回はこのテキストを標準出力に出すので、executeメソッドを以下のように改造します。

protected function execute(InputInterface $input, OutputInterface $output)
{
    $text = $input->getOption('text');
    $output->writeln($text);
}

ヘルプの実装

コマンドライン引数を定義しても、引数の使い方がわからないと不親切です。configureメソッドに以下のように定義すると、引数のヘルプを表示できるようになります。

protected function configure()
{
    $this->setName('my:first:command');
    $this->setDescription('Text for you hope to show');

    $options = [
        new InputOption(
            'text',
            null,
            InputOption::VALUE_REQUIRED,
            'Text for you hope to show'
        )
    ];

    $this->setDefinition($options);
    $this->setHelp(
        <<<HELP
This command just show your input text into cli output.
To show:
  <comment>%command.full_name% --text='you hope to show'</comment>
HELP
    );
    parent::configure();
}

ヘルプを表示させてみよう

続いて、先程実装したコマンドライン引数のヘルプを表示させてみましょう。

php bin/magento my:first:command --help

とタイプすると、画面上にコマンドライン引数のヘルプが表示されます。

あとはヘルプに従って、

php bin/magento my:first:command --text=test

と実行すると、

test

という結果が得られると思います。

今回の例は簡単なサンプルですが、Magentoのソースツリー内にある他のクラスを利用したコードを実行することはもちろん可能です。Magento自体、日常の運用でコマンドを実行することが多いアプリケーションなので、独自コマンドの作成方法を知っていると開発者目線での拡張がいろいろとできます。

まとめ

  • ObserverはMagento上の既定の箇所で別の処理に分岐を発生させられるフック的な仕組み。標準でも多くのイベントが定義されているので、上手に活用しよう。
  • Cronは定時処理を実行するための仕組み。Magento2からはCronグループ別に実行ができるので、処理に時間がかかりそうなものはグループを分けよう。
  • コマンドラインツールはSymfonyフレームワークのコンポーネントを使って実装する。定型的な処理を実装するのに適しているので、Webの管理画面と合わせて上手に活用しよう。

次回はMagentoエクステンション開発でとっつきにくい、di.xmlについて解説します。