PrestaConcept
Nos réalisations
Nos métiers
  • Découvrez nos métiers
  • Développement web sur mesure

    Nous développons en méthode agile des back sous le framework PHP Symfony, des front en Angular.

  • Maintenance d'applications

    Maintien en condition opérationnelle de votre plateforme Symfony.

  • Expertise Symfony

    Coaching, formation, audit et conseil.

  • Hébergement et Infogérance

    Une expertise de l'hébergement depuis plus de 15 ans et l’infogérance de centaines de machines en production actuellement.

  • Qui sommes nous
  • Découvrez Prestaconcept
  • PrestaConcept

    Notre histoire, nos convictions, notre vision... Découvrez ce qui nous anime !

  • L'équipe

    Plus de 15 ans minimum d'expérience sur Symfony.

  • Nos engagements RSE

    Une société engagée pour un numérique responsable.

  • Nos convictions

    Co-construction, transparence.. Les principes qui guident nos collaborations !

  • Nous rejoindre

    Envie de nous rejoindre ? Consultez nos offres !

  • Blog
    J'ai un projet Nous contacter
    J'ai un projet Nous contacter Menu
    • Accueil
    • Blog
    • Tech
    • Une crontab sous stéroïdes

    Blog

    Une crontab sous stéroïdes

    Yann Eugoné
    Yann Eugoné CTO
    Publié le jeudi 3 octobre 2024

    Proposition d'une alternative aux traditionnelles crontabs, dans un projet Symfony

    Nous utilisons des crontabs depuis tellement longtemps, que l'idée de chercher un terme alternatif peut même être un défi.
    Pourtant, ce concept n'est pas exempt de défaut (déploiement, observabilité, etc...).
    Cet article vous proposera une alternative, que vous pourrez facilement implémenter dans vos projets Symfony.

    Oui, cette alternative repose sur le composant Scheduler, mais ce n'est pas tout.
    Je vous réserve de quoi améliorer encore les choses.
    Alors même si vous êtes familier avec ce composant, prenez le temps de lire.

    J'ai préparé un projet Symfony (presque) vierge, contenant tout le code présenté ici.
    Les exemples et les captures d'écrans ont été réalisés sur la base de celui-ci :
    https://github.com/yann-eugone/yokai-batch-scheduler-ui

    Utilisation d'une crontab

    C'est quoi ?

    cron est un programme qui permet aux utilisateurs des systèmes Unix d’exécuter automatiquement des scripts, des commandes ou des logiciels à une date et une heure spécifiée à l’avance, ou selon un cycle défini à l’avance.
    https://fr.wikipedia.org/wiki/Cron

    Le format d'une crontab est assez simple à comprendre, c'est un fichier texte, qui contient 2 "colonnes", la fréquence à gauche, la commande à exécuter à droite.

    mm hh jj MMM JJJ    commande à exécuter
    

    Quel est le problème ?

    Composition

    Lorsque vous ajoutez une commande à la crontab, vous devez penser que :

    • elle n'offre aucune garantie quant à la parallélisation de commandes
    • si votre commande a une sortie, tout sera envoyé par email système

    Pour compenser ces faiblesses, on écrit souvent les entrées des crontabs comme suit :

    mm hh jj MMM JJJ    flock -xn .commande_normalisée.lock commande à exécuter &>> commande_normalisée.log
    

    On a entouré notre commande à exécuter par :

    • flock -xn .commande_normalisée.lock pour éviter que la même entrée ne tourne 2 fois
    • &>> commande_normalisée.log pour rediriger toute la sortie dans un fichier

    C'est efficace, mais légèrement fastidieux, et ça alourdi la lecture des crontabs.

    Déploiement

    Lorsque vous développez une nouvelle feature qui nécessite une entrée dans la crontab, vous devez penser à la mettre à jour, juste après avoir fait votre déploiement.
    Faute de quoi, votre feature ne sera pas complète.
    Vous ne pouvez pas le faire en avance, car la commande que vous avez ajoutée à votre code n'existe probablement pas encore.

    Observabilité

    Même en redirigeant la sortie de la commande, le résultat que vous aurez, est un empilement des sorties de vos commandes dans un même fichier.
    Et là encore plusieurs problèmes se posent :

    • vous devez vous connecter sur le serveur et parcourir le fichier de log
    • le fichier de log contient toutes les sorties de chacune des itérations de votre crontab
      • vous devez donc fouiller dans tout le fichier pour y chercher une occurrence
      • attention à ce que ce fichier ne vous pose pas de problème d'espace disque

    L'alternative Symfony Scheduler

    Depuis Symfony 6.3, le composant Scheduler se propose de remplacer vos crontabs.

    Le composant lui-même repose sur le composant Messenger, et permet de publier des messages dans le bus de manière programmée (plus qu'une simple crontab).

    Mise en place

    Elle est très bien documentée dans Symfony depuis le temps.
    Mais pour faire simple, et rester dans notre contexte des crontabs, pour programmer une nouvelle entrée, voilà ce que vous devez faire :

    namespace App\Scheduler\Task;
    
    use Symfony\Component\Scheduler\Attribute\AsCronTask;
    
    #[AsCronTask('0 0 * * *')]
    class SendDailySalesReports
    {
        public function __invoke()
        {
            // ...
        }
    }
    

    Problèmes résolus

    Remplacer une crontab par le composant Scheduler résout plusieurs de nos problèmes précédents :

    • Composition : plus de crontab, donc plus de commande à rallonge à produire
    • Déploiement : votre crontab est décrite par votre code, qui porte les tâches et leur fréquence, elle se met donc à jour à chaque déploiement

    Problèmes non résolus

    Malheureusement, il reste un problème non résolu, et c'est un peu le problème de Messenger de manière générale :
    Observabilité : les messages sont envoyés à messenger, au mieux, ils auront produit des logs, mélangés avec le reste des logs de l'application.

    Scheduler sous stéroïdes

    Posons-nous les bonnes questions : de quoi avons-nous besoin ?
    Chaque fois qu'une tâche programmée est exécutée, elle devrait pouvoir disposer de ses propres artefacts d'exécution.
    A posteriori, lorsque je souhaite comprendre ce qu'il s'est passé, je peux me concentrer sur le rapport d'une seule occurrence.

    La librairie qui a tout changé

    La librairie yokai/batch a été développée par plusieurs développeurs de PrestaConcept.
    Nous nous en servons depuis plusieurs années maintenant pour gérer toutes nos tâches de traitement par lot (ou batch processing, d'où le nom de la librairie).
    Dans ce contexte, l'observabilité est rapidement devenu un sujet, puisqu'une tâche d'import de plusieurs milliers de lignes, exécutée en asynchrone, nécessite d'être tracée afin d'indiquer à l'utilisateur le résultat.

    Pour faire simple, la librairie met à votre disposition le concept de Job.
    Un Job, ce n'est jamais qu'une classe qui implémente l'interface JobInterface :

    use Yokai\Batch\JobExecution;
    use Yokai\Batch\Job\JobInterface;
    
    class DoStuffJob implements JobInterface
    {
        public function execute(JobExecution $jobExecution): void
        {
            // ici la logique de votre job
        }
    }
    

    Mais la librairie prend à sa charge la fabrication et le stockage de l'objet JobExecution, dans lequel vous pouvez sauvegarder des informations de ce qu'il s'est passé.

    use Yokai\Batch\JobExecution;
    use Yokai\Batch\Job\JobInterface;
    
    class DoStuffJob implements JobInterface
    {
        public function execute(JobExecution $jobExecution): void
        {
            $jobExecution->getLogger()->error('Something failed.');
            $jobExecution->getSummary()->increment('errors');
        }
    }
    

    Et depuis n'importe quel emplacement de votre application, vous pouvez demander le lancement d'un job, dès lors que vous mettez la main sur le service prévu à cet effet : JobLauncherInterface.

    namespace App\Job;
    
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\HttpFoundation\Response;
    use Yokai\Batch\Launcher\JobLauncherInterface;
    
    final class ImportController extends AbstractController
    {
        public function __invoke(JobLauncherInterface $jobLauncher): Response
        {
            $jobExecution = $jobLauncher->launch('import');
            // now you can look for information in JobExecution
            // or if execution is asynchronous, redirect user to a UI where he will watch it ?
        }
    }
    

    Combiner les deux librairies

    Depuis quelques temps, la librairie dispose d'un bridge avec Messenger.
    Il est donc possible de demander l'exécution d'un job au travers d'un bus de messages, quel qu'il soit.

    Comme nous l'avons écrit plus haut, le composant Scheduler repose sur Messenger.
    Donc, vous le voyez venir : les 2 librairies sont compatibles !

    Qu'est-ce-qu'on cherche à faire ?

    • En tant que développeur, je souhaite pouvoir décrire une tâche cron, comme un job
    • À ce job, je vais devoir associer une fréquence d'exécution
    • Tous ces jobs devront être collectés et envoyés au Scheduler Symfony pour y être exécutés

    Commençons déjà par décrire ce Job un peu particulier, et comme c'est encore la solution la plus simple, nous allons lui créer une interface dédiée.

    namespace App\Cron;
    
    use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
    use Yokai\Batch\Bridge\Symfony\Framework\JobWithStaticNameInterface;
    use Yokai\Batch\Job\JobInterface;
    
    #[AutoconfigureTag]
    interface CronJobInterface extends JobInterface, JobWithStaticNameInterface
    {
        public static function schedule(): string;
    }
    

    Maintenant que Symfony sait trouver tous nos crons, nous allons pouvoir les injecter dans un ScheduleProviderInterface, comme Symfony le suggère dans sa documentation.

    namespace App\Cron;
    
    use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
    use Symfony\Component\Scheduler\Attribute\AsSchedule;
    use Symfony\Component\Scheduler\RecurringMessage;
    use Symfony\Component\Scheduler\Schedule;
    use Symfony\Component\Scheduler\ScheduleProviderInterface;
    use Yokai\Batch\Bridge\Symfony\Messenger\LaunchJobMessage;
    
    #[AsSchedule(name: 'cron')]
    final readonly class CronScheduleProvider implements ScheduleProviderInterface
    {
        public function __construct(
            /** @var iterable<CronJobInterface> */
            #[AutowireIterator(CronJobInterface::class)]
            private iterable $tasks,
        ) {
        }
    
        public function getSchedule(): Schedule
        {
            $schedule = new Schedule();
            foreach ($this->tasks as $task) {
                $schedule->add(
                    RecurringMessage::cron($task::schedule(), new LaunchJobMessage($task::getJobName())),
                );
            }
    
            return $schedule;
        }
    }
    

    À ce moment-là de l'histoire, chaque fois que que Symfony exécute une tâche programmée, un nouveau job démarre.
    Quand un job démarre, une JobExecution est créée, puis sauvegardée avant, pendant et après que le job ait été exécuté.
    On peut donc aller regarder dans le JobExecutionStorageInterface ce qu'il s'est passé dans telle ou telle occurrence d'une tâche programmée.

    Rendre l'ensemble disponible sur une UI

    Mais nous pouvons faire encore mieux !
    En effet, on a bien séparé nos artefacts, mais pour l'instant, il est toujours nécessaire d'aller sur le serveur pour voir ce qu'il se passe.
    Ne pourrait-on pas rendre tout ça visualisable depuis une interface graphique ?

    Collecter les crons

    La documentation Symfony parle d'une commande permettant de debug les Schedulers.
    A priori, cette commande est capable d'accéder et de décrire toutes les tâches programmées qui sont enregistrées.
    On devrait donc pouvoir s'en inspirer pour faire ce dont nous avons besoin.

    Mais nous allons simplifier, car nous n'avons pas l'utilité de plusieurs Scheduler ici. Nous utiliserons simplement le service CronScheduleProvider que nous avons écrit précédemment.
    On peut en extraire les messages récurrents qui le composent, il ne reste plus qu'à décrire tout ce que l'on souhaite pour chaque message.

    namespace App\Controller;
    
    use App\Cron\CronScheduleProvider;
    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
    use Symfony\Component\Clock\Clock;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Routing\Attribute\Route;
    
    #[Route(path: '/crons')]
    final class CronsController extends AbstractController
    {
        public function __construct(
            private readonly CronScheduleProvider $cronScheduleProvider,
        ) {
        }
    
        #[Route(path: '/list', name: 'admin_cron_list')]
        public function list(): Response
        {
            $this->getCrons();
            // ...
        }
    
        /**
         * @return array<string, array{
         *     trigger: string,
         *     description: string,
         *     nextRunDate: \DateTimeImmutable|null,
         * }>
         */
        private function getCrons(): array
        {
            $crons = [];
            foreach ($this->cronScheduleProvider->getSchedule()->getRecurringMessages() as $recurringMessage) {
                $trigger = $recurringMessage->getTrigger();
                $provider = $recurringMessage->getProvider();
    
                $crons[$provider->getId()] = [
                    'trigger' => (string)$trigger,
                    'description' => $provider instanceof \Stringable ? (string)$provider : $provider->getId(),
                    'nextRunDate' => $trigger->getNextRunDate(Clock::get()->now()),
                ];
            }
    
            return $crons;
        }
    }
    

    Reste à afficher ces données comme bon vous semble, dans un template, ou de les exposer dans une API de votre choix.

    Améliorer notre description des crons

    C'est assez dommage, avec ce que l'on a produit, la seule chose que voit Symfony de nos tâches programmées, c'est le FQCN des messages que l'on va publier.
    Sauf que nous publions toujours le même message : LaunchJobMessage, l'interface ne ressemble donc pas à grand chose.
    Essayons de décrire nos tâches un peu mieux.

    namespace App\Cron;
    
    + use Symfony\Component\Scheduler\Trigger\StaticMessageProvider;
    + use Symfony\Contracts\Translation\TranslatorInterface;
    
    final readonly class CronScheduleProvider implements ScheduleProviderInterface
    {
        public function __construct(
    +         private TranslatorInterface $translator,
        ) {
        }
    
        public function getSchedule(): Schedule
        {
            $schedule = new Schedule();
            foreach ($this->tasks as $task) {
                $schedule->add(
    -                 RecurringMessage::cron($task::schedule(), new LaunchJobMessage($task::getJobName())),
    +                 RecurringMessage::cron(
    +                     expression: $task::schedule(),
    +                     message: new StaticMessageProvider(
    +                         messages: [new LaunchJobMessage($task::getJobName())],
    +                         id: $task::getJobName(),
    +                         description: $this->translator->trans(
    +                             id: 'job.job_name.' . $task::getJobName(),
    +                             domain: 'YokaiBatchBundle',
    +                         ),
    +                     ),
    +                 ),
                );
            }
    
            return $schedule;
        }
    }
    

    Voilà, maintenant Symfony sera capable d'afficher la traduction associée à chaque job, que ce soit dans l'interface que nous avons créé, ou même dans sa commande de debug.

    Ajouter des informations de Yokai Batch

    Jusqu'à présent, on s'est contenté d'extraire les informations que le composant Scheduler nous met à disposition.
    Est-ce-qu'on pourrait y ajouter les informations à propos des dernières exécutions de chaque tâche ?
    On peut. Il faudra utiliser le JobExecutionStorageInterface, fourni par Yokai Batch, pour demander la dernière exécution de chaque job.

    namespace App\Controller;
    
    use Symfony\Component\HttpFoundation\Response;
    + use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
    use Symfony\Component\Routing\Attribute\Route;
    + use Yokai\Batch\JobExecution;
    + use Yokai\Batch\Storage\Query;
    + use Yokai\Batch\Storage\QueryableJobExecutionStorageInterface;
    + use Yokai\Batch\Storage\QueryBuilder;
    
    final class CronsController extends AbstractController
    {
        public function __construct(
            private readonly CronScheduleProvider $cronScheduleProvider,
    +         private readonly QueryableJobExecutionStorageInterface $jobExecutionStorage,
        ) {
        }
    
        /**
         * @return array<string, array{
         *     trigger: string,
         *     description: string,
         *     nextRunDate: \DateTimeImmutable|null,
    +      *     lastExecution: JobExecution|null,
         * }>
         */
        private function getCrons(): array
        {
            $crons = [];
            foreach ($this->cronScheduleProvider->getSchedule()->getRecurringMessages() as $recurringMessage) {
                $trigger = $recurringMessage->getTrigger();
                $provider = $recurringMessage->getProvider();
    
                $crons[$provider->getId()] = [
                    'trigger' => (string)$trigger,
                    'description' => $provider instanceof \Stringable ? (string)$provider : $provider->getId(),
                    'nextRunDate' => $trigger->getNextRunDate(Clock::get()->now()),
    +                 'lastExecution' => $this->getExecutions($provider->getId(), 1)[0] ?? null,
                ];
            }
    
            return $crons;
        }
    + 
    +     /**
    +      * @return array<JobExecution>
    +      */
    +     private function getExecutions(string $name, int $limit): array
    +     {
    +         try {
    +             $query = (new QueryBuilder())
    +                 ->jobs([$name])
    +                 ->sort(Query::SORT_BY_START_DESC)
    +                 ->limit($limit, 0)
    +                 ->getQuery();
    +         } catch (\Throwable $exception) {
    +             throw new BadRequestHttpException(previous: $exception);
    +         }
    + 
    +         $executions = [];
    +         foreach ($this->jobExecutionStorage->query($query) as $execution) {
    +             $executions[] = $execution;
    +         }
    + 
    +         return $executions;
    +     }
    }
    

    Historique d'exécution d'une tâche

    La dernière étape consiste à produire un écran de détail pour chacun de nos crons.
    Depuis cet écran, on souhaite afficher les dernières exécutions d'une tâche donnée.
    Là encore, on va pouvoir demander les dernières exécutions au JobExecutionStorageInterface.

    final class CronsController extends AbstractController
    {
    +     #[Route(path: '/{name}', name: 'admin_cron_show')]
    +     public function show(string $name): Response
    +     {
    +         $cron = $this->getCrons()[$name] ?? throw $this->createNotFoundException();
    +         $executions = $this->getExecutions($name, 10);
    +         //...
    +     }
    }
    

    Conclusion

    Grâce à 2 librairies bien utiles, nous avons facilement pu nous libérer des défauts de la crontab.
    Maintenant, nos tâches programmées sont pilotées par notre code, et systématiquement à jour.
    Chaque fois que nous en aurons besoin, nous pourrons accéder au détail de chaque exécution de chaque tâche.
    Avec quelques lignes de code, nous avons même été en mesure de construire une interface graphique pour visualiser tout ça, de telle sorte que tous les utilisateurs peuvent maintenant aller voir ce qu'il se passe :

    Une crontab sous stéroïdes - CRON list

    Une crontab sous stéroïdes - CRON detail

    Une crontab sous stéroïdes - Job detail

    Blog

    Pour continuer votre lecture ...

    Tech

    Différents types d'objets en PHP

    Par Yann Eugoné 01/02/2024

    Entité, Value Object, Data Transfer Object, Service.
    Réflexion autour de la classification des différents types d'objets, en PHP.

    Lire la suite
    Tech

    Utilisation de Stopwatch et WebProfiler dans Symfony

    Par Yann Eugoné 09/11/2023

    Comment utiliser le composant Stopwatch et le WebProfilerBundle pour détecter les lenteurs de vos applications.

    Lire la suite
    Tech

    Le pattern Décorateur avec Symfony

    Par Yann Eugoné 16/03/2022

    Apprenez à découper votre code devenu trop complexe avec le pattern décorateur, en vous aidant de Symfony.

    Lire la suite

    Vous avez un projet Laravel ?

    Nous sommes spécialisés en Symfony, et grâce à Web^ID, l’agence sœur du groupe Agile Invest, nous couvrons aussi toute l’expertise Laravel.

    Découvrir Web^ID

    Une question, un projet ?
    Planifiez un échange avec nous !

    Choisissez votre date
    PrestaConcept - Groupe Agile Invest
    5, imp. Morel, 69003 Lyon +33 (0)4 78 54 45 45
    Suivez-nous
    Ecoindex B

    Ce site internet est un site basse consommation. En savoir plus sur l'Ecoindex

    Nos réalisations

  • Logiciel de mise en conformité réglementaire
  • Application de suivi de production des centrales éoliennes
  • Outil d'aide à la décision
  • Portail client
  • Nos métiers

  • Développement sur-mesure
  • Reprise d'application Symfony
  • Expertise Symfony
  • Hébergement & Infogérance
  • Qui sommes-nous

  • PrestaConcept
  • Groupe Agile Invest
  • L'équipe
  • Engagement RSE
  • Blog

  • Tech
  • Méthodologie
  • PrestaConcept
  • RSE
  • © 2025 PrestaConcept
    Mentions légales Politique de confidentialité 🍪
    Retour en haut de page