指定した間隔で繰り返しジョブ実行、"Scheduler"

[Symfony Component Advent Calendar 2023](https://qiita.com/advent-calendar/2023/symfony-component)の14日目の記事です。

## 指定した間隔で繰り返しジョブ実行、"Scheduler"

[Scheduler](https://symfony.com/blog/new-in-symfony-6-3-scheduler-component)は、事前に指定したスケジュールでジョブを実行するコンポーネントです。

### インストール

```shell
composer require symfony/scheduler
```

### 使い方

スケジュールに合わせてジョブを実行するには3つ必要になります。

- 送信する`Message`
- `Message`をハンドリングする`MessageHandler`
- スケジュールの設定をする`ScheduleProvider`

Message, MessageHandlerの詳細については去年の[Messenger](https://qiita.com/ippey_s/items/aa07dc7ed605c9377f00)を見ていただくとして、ここでは簡易的なものを記述します。


```php:SomeMessage.php
namespace App\Message;

final class SomeMessage
{
}
```

```php:SomeMessageHandler.php
namespace App\MessageHandler;

use App\Message\SomeMessage;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SomeMessageHandler
{
    public function __construct(private readonly ClockInterface $clock)
    {
        
    }
    public function __invoke(SomeMessage $message)
    {
        $now = $this->clock->now()->format('Y-m-d H:i:s');
        echo ("{$now}\n"); // 定期的に実行してるかわかりやすいように現在の時間を出力
    }
}
```

```php
namespace App\ScheduleProvider;

use App\Message\SomeMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule('sample')]
class SampleScheduleProvider implements ScheduleProviderInterface
{
    public function __construct()
    {
        
    }
    
    public function getSchedule(): Schedule
    {
        $schedule = new Schedule();
        $schedule->add(
            RecurringMessage::every('10 seconds', new SomeMessage())
            );
        
        return $schedule;
    }
}

```

3つのクラスを作成し、Messengerを起動します。ここも詳しくは`Messanger`の記事を見ていただくとして、ポイントは引数にスケジュール名を渡します。`SampleScheduleProvider`に`#[AsSchedule]`アトリビュートをつけていますが、そこに指定した名前を`scheduler_{指定した名前}`といったように`scheduler_`を頭につけて実行します。`#[AsSchedule]`で名前を指定しない場合は`default`になるので、`scheduler_default`となります。

```shell
bin/console messenger:consume -v scheduler_sample
```

上記を実行すると

```
2023-12-14 10:00:08
2023-12-14 10:00:18
2023-12-14 10:00:28
```

といった結果になり、10秒間隔でジョブが実行されていることがわかります。`ScheduleProvider`もオートワイヤリングが使えるので、必要なものを取得してMessageに渡すことができます。

:::note warn
定期的に実行されるMessageHandlerには**常に同じMessageオブジェクトが渡されます。**
:::

また、`RecurringMessage::cron("クロンタブのような設定")`を行えば、クロンタブと同じ感覚て定期的に実行させることができます。

:::note
cron()にする場合は別途`dragonmantank/cron-expression`が必要です。

```shell
composer require dragonmantank/cron-expression
```
:::

### Symfony6.4以降の新しい使い方

というのがSymfony6.3の話でした。Symfony6.4から、こんなやり方でなくても実行できるようになってました。Message, MessageHandler, ScheduleProviderが必要なくなりました。代わりに以下を持ったクラスを用意します。


- `#[AsPeriodicTask()]`もしくは`#[AsCronTask()]`アトリビュート
- `__invoke()`メソッド、もしくは`Command`を継承

例えば、

```php
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;

#[AsPeriodicTask(frequency: 10, arguments: ['From PeriodTask.'])]
class PeriodTask
{
    public function __construct(private readonly ClockInterface $clock)
    {
    }
    
    public function __invoke(string $arg)
    {
        $now = $this->clock->now()->format('Y-m-d H:i:s');
        echo "{$now}: {$arg}\n";
    }
}
```

このクラスを作って、

```shell
bin/console messenger:consume -v scheduler_default
```

を実行すれば

```
2023-12-14 10:00:10: From PeriodTask.
2023-12-14 10:00:20: From PeriodTask.
2023-12-14 10:00:30: From PeriodTask.
```

と、先のどの例と同じように定期的に実行することができます。アトリビュートで`arguments`を指定すれば、`__invoke`に引数として値を渡すことができます。

なお、コマンドの場合はこのように`arguments`に文字列で引数を渡します。

```php
#[AsCommand(name: 'app:schedule')]
#[AsCronTask('* * * * *', arguments: 'Symfony --option=test')]
class ScheduleCommand extends Command
{
    public function __construct(private readonly ClockInterface $clock)
    {
        parent::__construct();
    }
    
    public function configure()
    {
        $this->addArgument('name')
            ->addOption('option', mode: InputOption::VALUE_OPTIONAL);
    }


    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $name = $input->getArgument('name');
        $option = $input->getOption('option');
        
        $now = $this->clock->now()->format('Y-m-d H:i:s');
        echo "{$now}: {$name} {$option}\n";

        return Command::SUCCESS;
    }
}
```

実行結果
```
2023-12-10 13:56:00: Symfony test
```

## まとめ

今回は`Schehduler`を紹介しました。Symfony6.4からはさらに便利に使うことができるようになったので、定期実行をこちらに乗り換えるのもありかもしれません。