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.
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 :
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;
// ...
}
Le composant Workflow de Symfony permet de définir via la configuration les différents états et transitions de notre Issue
.
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
du workflow.
La première valeur représente le nom de la propriété qui stockera l'état (ici state
).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
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:
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.
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.
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())
);
}
//...
}
}
Les représentations graphiques des workflows présents dans ce billet ont été générées à l'aide de deux outils:
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
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.
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):
workflow.guard
workflow.pull_request.guard
workflow.pull_request.guard.review
workflow.leave
workflow.pull_request.leave
workflow.pull_request.leave.opened
workflow.transition
workflow.pull_request.transition
workflow.pull_request.transition.review
workflow.enter
workflow.pull_request.enter
workflow.pull_request.enter.travis_build
workflow.pull_request.enter.dev_review
workflow.entered
workflow.pull_request.entered
workflow.pull_request.entered.travis_build
workflow.pull_request.entered.dev_review
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.
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
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.