Następca CRON’a już jest!

Mowa w dzisiejszym wpisie o Symfony Scheduler. Jakże długo czekałem w świecie PHP na takie narzędzie, jakże długo zazdrościłem programistom Javy używającym w Java EE adnotacji @Schedule(dayOfWeek="Sun", hour="0"), dzięki której ich funkcja uruchamiała się w odpowiednim czasie. Tymczasem my musieliśmy kombinować z konfiguracją i całym tym boilerplate code w CRON-ie, który nigdy nie chciał współpracować po dobroci i ładnie integrować się z naszą aplikacją. Na szczęście to już przeszłość, ponieważ od Symfony 6.4 mamy do dyspozycji Symfony Scheduler. A co to takiego? To analogiczny do Javy planista, który również obsługuje adnotacje (atrybuty), dzięki czemu nasza klasa (handler) może być uruchamiana w sposób podobny do wykonywania jej przez unixowy CRON.

Jak zaimplementować to we własnym projekcie? Po pierwsze, musimy dodać tę bibliotekę:

composer require symfony/messenger symfony/scheduler

Widać tutaj, że potrzebujemy również Symfony Messenger, a to dlatego, że Scheduler jest właściwie nakładką na Messenger, która dostarcza własny interfejs. Zadania są w rzeczywistości wykonywane co sekundę przez worker Messenger, ale to planista decyduje, czy konkretne zadanie ma się rzeczywiście rozpocząć. Dlatego po instalacji należy skonfigurować uruchamianie workera, wydając polecenie:

bin/console messenger:consume -v scheduler_default

(default to nazwa domyślnej grupy zadań). Potrzebujemy jeszcze wiadomości, handlera i samej klasy definiującej częstotliwość uruchamiania się danej wiadomości. Może to wyglądać w ten sposób:

#[AsSchedule(name: 'default')]
class MySchedulerProvider implements ScheduleProviderInterface
{
        public function getSchedule(): Schedule
        {
        return (new Schedule())->add(
                RecurringMessage::every('3 days', new MyMessage())
                );
        }
}

Zatem nasza wiadomość MyMessage będzie wysyłana do swojego handlera co trzy dni. Można oczywiście nadawać wiele interwałów, a także korzystać z różnych kombinacji w określaniu odpowiedniego czasu (wspierana jest również składnia CRON-a). Polecam zapoznanie się z dokumentacją. Co jednak w przypadku, gdy dane zadanie musi zostać wykonane dwa lub więcej razy w tym samym momencie (bo wynika to z harmonogramu)? Jeśli mamy tylko jednego workera, sprawa jest prosta – zadania są uruchamiane jedno po drugim. W innym przypadku trzeba sięgnąć po mechanizm cache i Symfony Lock. Przykładowo:

(…)
public function getSchedule(): Schedule
{
        return (new Schedule())
                ->add(RecurringMessage::every('3 days', new MyMessage()))
                ->stateful($this->cache)
                ->lock($this->lockFactory->createLock('scheduler-default'));
}

Wcześniej oczywiście należy wstrzyknąć właściwości cache i lockFactory. Dzięki temu będziemy mieć pewność, że dane zadanie będzie wykonywane jedno po drugim.

Co z zadaniami, które mogą trwać naprawdę długo? Dobrą praktyką jest trzymanie się zasady, by zadania były krótkie w realizacji. Jeśli jednak jest to niemożliwe, można skorzystać z opcji przeniesienia (redispatch) zadania do kolejki asynchronicznej:

(new Schedule())->add(
        RecurringMessage::cron('15 4 */3 * *', new RedispatchMessage(new MyMessage(), 'async')))
);

Na koniec warto wspomnieć o jeszcze dwóch przydatnych opcjach: atrybucie AsPeriodicTask i poleceniu debug:scheduler. Atrybut AsPeriodicTask pozwala nam użyć polecenia lub dowolnej innej klasy jako zadania wykonywanego w zdefiniowanym przez ten atrybut interwale. Natomiast polecenie bin/console debug:scheduler dostarcza cennych wskazówek do debugowania, takich jak informacje o tym, kiedy i jakie zadania mają się wykonać. …Tak więc teraz pozostaje już tylko zdefiniować datę publikacji tego wpisu!