Chargement...
Accueil /  Blog /  Symfony /  Le composant workflow de Symfony

Le composant workflow de Symfony

Publié le jeudi 11 janvier 2018

Le composant Workflow est encore récent. Il n'est pas le plus connu, mais gagne à l'être.
Petit aperçu des possibilités, exemples à la clé, et proposition d'améliorations.

Présentation

Un workflow est la définition du cycle de vie d'un objet/système qui change d'état lorsqu'on lui applique une action particulière (transition).

Prenons pour exemple le cycle de vie d'une issue :

  • créée
  • affectée à un utilisateur
  • en cours de traitement
  • terminée
  • clôturée

Voici une implémentation de notre issue:

<?php

namespace AppBundle\Entity;

class Issue
{
    /**
     * @var string
     *
     * @ORM\Id
     * @ORM\Column(type="string")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank()
     */
    private $title;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $content;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     */
    private $state;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime")
     * @Assert\DateTime()
     */
    private $createdAt;

    // ...
}

Configuration

Le composant Workflow de Symfony permet de définir via la configuration les différents états et transitions de notre Issue.

Le workflow single_state

Reprenons l'exemple de notre issue :

framework:
    workflows:
        issue:
            marking_store:
                type: single_state
                arguments:
                    - state
            supports: AppBundle\Entity\Issue
            initial_place: opened
            places:
                - opened # créée
                - affected # affectée à un utilisateur
                - in_progress # en cours de traitement
                - completed # terminée
                - closed # clôturée
            transitions:
                affect:
                    from: opened
                    to: affected
                treat:
                    from: affected
                    to: in_progress
                complete:
                    from: in_progress
                    to: completed
                close:
                    from: completed
                    to: closed

Chaque workflow porte un nom, ici "issue".

  • marking_store possède deux options :
    • type : qui peut prendre deux valeurs single_state s'il ne peut y avoir qu'un seul état à la fois, ou multiple_state si on gère plusieurs états en parallèle.
    • arguments : les arguments à ajouter au service marking_store du workflow. La première valeur représente le nom de la propriété qui stockera l'état (ici state).
  • supports : définit quel objet est concerné par ce workflow. Il est possible de définir une support_strategy mais pour l'instant seule la stratégie ClassInstanceSupportStrategy est implémentée.
  • initial_place : le statut initial de l'objet (attention il n'est pas affecté automatiquement)
  • places : liste les différents états possibles
  • transitions : liste les différentes actions qui déclenchent un changement d'état avec pour chacune un départ et une cible. L'objet doit valider l'état de départ pour que la transition soit applicable.

Ci-dessous un schéma représentant le cycle de vie (workflow) de notre issue :

issue workflow

* les ronds représentent les états (bleu pour l'état initial) et les carrés des transitions

Le workflow multiple_state

Dans le cas d'un workflow de type multiple_state il est possible de définir plusieurs branches qui évolueront en simultané. Cela signifie aussi que certaines transitions ne seront applicables que si et seulement si tous les statuts de départ sont validés.

Prenons pour exemple le cas d'une pull request.

<?php

namespace AppBundle\Entity;

class PullRequest
{
    /**
     * @var string
     *
     * @ORM\Id
     * @ORM\Column(type="string")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank()
     */
    private $title;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $content;

    /**
     * @var array
     *
     * @ORM\Column(type="json_array")
     */
    private $states;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime")
     * @Assert\DateTime()
     */
    private $createdAt;

    // ...
}

Scénario:

  • Un développeur ouvre la pull request
  • Il fait une demande de review
  • La PR doit être validée par 2 entités : Travis exécute les tests automatisés et un développeur relit le code produit. Ce qui correspond à deux statuts en simultané : travis_build et dev_review
  • Chacun des deux axes va évoluer de façon autonome Le build travis peut réussir ou échouer, de même que la relecture de code
  • À la fin, pour être mergée, la PR devra avoir passé avec succès le build Travis ainsi que la code review
framework:
    workflows:
        pull_request:
            marking_store:
                type: multiple_state
                arguments:
                    - states
            supports: AppBundle\Entity\PullRequest
            initial_place: opened
            places:
                - opened
                - travis_build
                - travis_build_ok
                - travis_build_fail
                - dev_review
                - dev_review_ok
                - dev_review_fail
                - merged
            transitions:
                review:
                    from: opened
                    to:   [travis_build, dev_review]
                travis_build_success:
                    from: travis_build
                    to:   travis_build_ok
                travis_build_failure:
                    from: travis_build
                    to:   travis_build_fail
                dev_review_success:
                    from: dev_review
                    to:   dev_review_ok
                dev_review_failure:
                    from: dev_review
                    to:   dev_review_fail
                merge:
                    from: [travis_build_ok, dev_review_ok]
                    to:   merged

Ci-dessous la représentation de ce nouveau workflow :

issue workflow

* dès lors que plusieurs flèches pointent sur une transition, chaque état d'origine doit être validé pour appliquer la transition.

Utilisation

Pour manipuler les workflows, Symfony implémente un registre qui permet de récupérer celui qui concerne notre objet. Si plusieurs workflows sont définis il faudra fournir explicitement le nom d'un workflow au registre.

Initialisation

Le workflow ne s'initialise pas tout seul, il faudra marquer notre objet lors de sa création. Ce qui revient dans notre exemple à donner le statut opened à notre PullRequest.

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\PullRequest;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class PullRequestController extends Controller
{
    public function newAction()
    {
        $pullRequest = new PullRequest();

        $workflow = $this->get('workflow.registry')->get($pullRequest);
        $workflow->getMarking($pullRequest); // getMarking est appelé afin de forcer l'initialisation de l'état de départ (initial_place)

        //...
    }
}

Pour modifier le statut de notre pull request, il faut lui appliquer une transition. Les conditions de départ doivent être validées. En cas d'erreur la methode apply lèvera une LogicException.

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\PullRequest;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Workflow\Exception\LogicException;

class PullRequestController extends Controller
{
    public function switchAction(PullRequest $pullRequest, $transition)
    {
        $workflow = $this->get('workflow.registry')->get($pullRequest);

        try {
            $workflow->apply($pullRequest, $transition);
            $this->getEntityManager()->flush();

        } catch (LogicException $e) {
            $this->getSession()->getFlashBag()->add(
                'error',
                sprintf('Can not execute transition %s for PR: %d', $transition, $pullRequest->getId())
            );
        }

        //...
   }
}

Debug

Les représentations graphiques des workflows présents dans ce billet ont été générées à l'aide de deux outils:

  • La commande workflow:dump qui permet de générer un fichier .dot décrivant le workflow.
  • dot (graphiz) qui permet de générer une image à partir du fichier .dot.
php bin/console workflow:dump pull_request > pull_request.dot
dot -Tpng pull_request.dot -o pull_request.png

Twig

Le composant Workflow vient avec une sa propre extension Twig.

Elle permet par exemple de tester si une transition peut être appliquée à l'objet afin d'afficher ou non un bouton d'action :

{% if workflow_can(pull_request, 'review') %}
    <a href="...">Review</a>
{% endif %}

Mais aussi afficher la listes des actions disponibles :

{% for transition in workflow_transitions(pull_request) %}
    <a href="{{ path('pull_request_switch', {id: pull_request.id, transition: transition.name}) }}">{{ transition.name }}</a>
{% else %}
    No actions available.
{% endfor %}

La documentation officielle contient la liste exhaustive de toutes les fonctions implémentées par cette extension.

Events

Le composant Workflow fournit des événements pour chaque état/transition du cycle de vie (en prenant la transition review de notre Workflow pull_request pour exemple):

  • Lorsqu'il est question de vérifier la possibilité d'appliquer une transition :
    • workflow.guard
    • workflow.pull_request.guard
    • workflow.pull_request.guard.review
  • Lorsqu'un objet quitte un état :
    • workflow.leave
    • workflow.pull_request.leave
    • workflow.pull_request.leave.opened
  • Lorsqu'une transition est appliquée :
    • workflow.transition
    • workflow.pull_request.transition
    • workflow.pull_request.transition.review
  • Lorsqu'un objet s'apprête à entrer dans un état :
    • workflow.enter
    • workflow.pull_request.enter
    • workflow.pull_request.enter.travis_build
    • workflow.pull_request.enter.dev_review
  • Lorsqu'un objet vient d'entrer dans un état :
    • workflow.entered
    • workflow.pull_request.entered
    • workflow.pull_request.entered.travis_build
    • workflow.pull_request.entered.dev_review
  • Lorsqu'il est question d'annoncer les états disponibles :
    • workflow.announce
    • workflow.pull_request.announce
    • workflow.pull_request.announce.travis_build
    • workflow.pull_request.announce.travis_build_success
    • workflow.pull_request.announce.travis_build_failure
    • workflow.pull_request.announce.dev_review_success
    • workflow.pull_request.announce.dev_review_failure

Il est possible d'utiliser ces événements pour logger chaque changement d'état par exemple.

La documentation officielle contient la liste exhaustive de tous les événements propagés.

Guard

Il est possible d'appliquer des restrictions aux transitions pour vérifier (par exemple) que l'utilisateur est bien authentifié ou qu'il possède les droits nécessaires.

Pour cela, la définition de chaque transition supporte une option guard. Cette option utilise le composant expression language pour faire appel à des fonctions telles que is_fully_authenticated() ou is_granted(), et même accèder aux attributs de l'objet concerné : subject (notre pull request).

Exemple de configuration:

framework:
    workflows:
        pull_request:
            transitions:
                dev_review_success:
                    guard: "is_fully_authenticated() and has_role('ROLE_REVIEWER') and is_granted('ACCEPT', subject)"
                    from: dev_review
                    to:   dev_review_ok
                dev_review_failure:
                    guard: "is_fully_authenticated() and has_role('ROLE_REVIEWER') and is_granted('REJECT', subject)"
                    from: dev_review
                    to:   dev_review_fail
                merge:
                    guard: "is_fully_authenticated() and has_role('ROLE_MERGER') and is_granted('MERGE', subject)"
                    from: [travis_build_ok, dev_review_ok]
                    to:   merged

Nouvelle feature

Les fonctionnalités de l'option guard sont pour l'instant assez limitées.

Nous avons eu l'idée de l'enrichir en lui ajoutant le support d'une nouvelle méthode is_valid(). Cette fonction attend en paramètre l'objet à valider ainsi que des groupes de validation (optionnellement).

Au même titre que is_granted(), celle-ci est parsée par le composant expression language. Elle fait appel au composant de validation de Symfony.

Une pull request pour cette nouvelle feature a été créée.

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.