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_storedu workflow. La première valeur représente le nom de la propriété qui stockera l'état (icistate).
- 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 :
* 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 :
* 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:dumpqui 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.guardworkflow.pull_request.guardworkflow.pull_request.guard.review
- Lorsqu'un objet quitte un état :
workflow.leaveworkflow.pull_request.leaveworkflow.pull_request.leave.opened
- Lorsqu'une transition est appliquée :
workflow.transitionworkflow.pull_request.transitionworkflow.pull_request.transition.review
- Lorsqu'un objet s'apprête à entrer dans un état :
workflow.enterworkflow.pull_request.enterworkflow.pull_request.enter.travis_buildworkflow.pull_request.enter.dev_review
- Lorsqu'un objet vient d'entrer dans un état :
workflow.enteredworkflow.pull_request.enteredworkflow.pull_request.entered.travis_buildworkflow.pull_request.entered.dev_review
- Lorsqu'il est question d'annoncer les états disponibles :
workflow.announceworkflow.pull_request.announceworkflow.pull_request.announce.travis_buildworkflow.pull_request.announce.travis_build_successworkflow.pull_request.announce.travis_build_failureworkflow.pull_request.announce.dev_review_successworkflow.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.