6 April 2017

Сети Петри с Symfony а-ля WorkFlow компонент

PHPSymfony
Tutorial
Давайте представим некоторый проект на GitHub, куда мы хотим оформить Pull Request. Здесь нас будет интересовать только тот огромный жизненный цикл нашего пулл реквеста, который он фактически может пройти с момента рождения до самого момента его принятия и мержа в основной код проекта.

image

Итак, если порассуждаем, то пулл реквест может иметь следующие варицации над состояниями, которые я специально усложнил, если не знать о WorkFlow и смотреть на подобное тз:

1. Открыт
2. Находится в проверке в Travis CI, причем может попасть туда после того как были сделаны какие-то исправления или любые изменения, связанные с нашим Pull Request, ведь проверить-то надо все, не так ли?
3. Ждет Review только после того как была сделана проверка в Travis CI
3.1. Требует обновлений кода после того как была сделана проверка в Travis CI
4. Требует изменения после Review
5. Принят после Review
6. Смержен после Review
7. Отклонен после Review
8. Закрыт после того, как был отклонен после Review
9. Открыт заново после того как был закрыт, после того как был отклонен, после того как было проведено Review
10. Изменения после того как был помечен «Требует изменений», после того как было проведено Review, при этом после этого он снова должен попасть в Travis CI (пункт 2), а от Review снова может с ним случиться только те состояния, которые мы описали выше

Жесть, правда?

То, что в квадратах — мы будем называть транзакциями, тем временем всё то, что находится в кругах — это те самые состояния, о которых мы ведем речь. Транзакция — это возможность перехода из определенного состояния (или нескольких состояний сразу) в другое состояние.
Здесь и вступает в игру WorkFlow компонент, который будет помогать нам управлять состояниями объектов внутри нашей системы. Смысл в том, что сами состояния задает разработчик, тем самым гарантируя, что данный объект всегда будет валиден с точки зрения бизнес логики нашего приложения.

Если человеческим языком, то пулл реквест никогда не сможет быть смержен, если он не прошел заданный нами ОБЯЗАТЕЛЬНЫЙ путь до определенного момента (от проверки в тревис и ревью до его принятия и самого мержа).

Итак, давайте создадим сущность PullRequest и зададим для неё правила перехода из одних состояний в другие.

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="pull_request")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\PullRequestRepository")
 */
class PullRequest
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $currentPlace;


    /**
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return PullRequest
     */
    public function setCurrentPlace($currentPlace)
    {
        $this->currentPlace = $currentPlace;

        return $this;
    }

    /**
     * @return string
     */
    public function getCurrentPlace()
    {
        return $this->currentPlace;
    }
}

Вот как это будет выглядеть, когда ты знаешь что такое WorkFlow:

# app/config/config.yml
framework:
    workflows:
        pull_request:
            type: 'state_machine'
            marking_store:
                type: 'single_state'
                argument: 'currentPlace'
            supports:
                - AppBundle\Entity\PullRequest
            places:
                - start
                - coding
                - travis
                - review
                - merged
                - closed
            transitions:
                submit:
                    from: start
                    to: travis
                update:
                    from: [coding, travis, review]
                    to: travis
                wait_for_review:
                    from: travis
                    to: review
                request_change:
                    from: review
                    to: coding
                accept:
                    from: review
                    to: merged
                reject:
                    from: review
                    to: closed
                reopen:
                    from: closed
                    to: review

Так же как и на картинке, мы задаем определенные состояния, в которых фактически может прибывать наша сущность (framework.workflow.pull_request.places): start, coding, travis, review, merged, closed и транзакции (framework.workflow.pull_request.transactions) с описанием, при каком условии объект может попасть в это состояние: submit, update, wait_for_review, request_change, accept, reject, reopen.

А теперь снова вернемся в жизнь:

Submit — это транзакция перехода из начального состояния в состояние проверки изменений в Travis CI.

Это наше самое первое действие, здесь мы оформляем наш пулл реквест и после этого Travis CI начинает проверять наш код на валидность.

Update — транзакция перехода из состояний coding (состояние написания кода), travis (состояние проверки на Travis CI), review (Состояние, когда происходит review кода) в состояние проверки Travis.
Это то действие, которое говорит системе, что нужно снова все перепроверить после каких-либо изменений в нашем pull request, т. е. в том, что готовится смержится в мастер.

Wait For Review — транзакция перехода из состояние Travis в состояние Review.
То бишь действие, когда мы запушили свой пулл реквест и он уже проверен Travis-ом, теперь пора программистам проекта взглянуть на наш код — сделать его ревью и принять решение что с этим делать дальше.

Request_Change — транзакция перехода состояния из Review в Coding.
Т.е. тот момент, когда (к примеру) команде проекта не понравилось то, как мы решили поставленную задачу и они хотят увидеть другое решение и мы вносим какие-то изменения в виде исправлений снова.

Accept — транзакция перехода состояния из Review в Merged, конечная точка, которая не имеет после себя никаких возможных транзакций.
Момент, когда программистам проекта нравится наше решение и они его мержат в проект.

Reject — транзакция перехода состояния из Review в Closed.

Момент, когда программисты не посчитали нужным принимать наш pull request по каким-либо причинам.

Reopen — транзакция перехода состояния Сlosed в состояние Review.

Например когда команда программистов проекта пересмотрела наш пулл реквест и решила его пересмотреть.

Теперь давайте уже наконец-таки напишем хоть какой-нибудь код:

use AppBundle\Entity\PullRequest;
use Symfony\Component\Workflow\Exception\LogicException;

$pullRequest = new PullRequest(); //совсем новый пулл реквест

$stateMachine = $this->getContainer()->get('state_machine.pull_request');
$stateMachine->can($pullRequest, 'submit'); //true
$stateMachine->can($pullRequest, 'accept'); //false

try {
    //делаем переход из состояния start в состояние travis
    $stateMachine->apply($pullRequest, 'submit');
} catch(LogicException $workflowException) {}

$stateMachine->can($pullRequest, 'update'); //true
$stateMachine->can($pullRequest, 'wait_for_review'); //true
$stateMachine->can($pullRequest, 'accept'); //false

try {
    //делаем переход из состояния update в состояние review
    $stateMachine->apply($pullRequest, 'wait_for_review');
} catch(LogicException $workflowException) {}

$stateMachine->can($pullRequest, 'request_change'); //true
$stateMachine->can($pullRequest, 'accept'); //true
$stateMachine->can($pullRequest, 'reject'); //true
$stateMachine->can($pullRequest, 'reopen'); //false

try {
    //делаем переход из состояния update в состояние review
    $stateMachine->apply($pullRequest, 'reject');
} catch(LogicException $workflowException) {}

$stateMachine->can($pullRequest, 'request_change'); //false
$stateMachine->can($pullRequest, 'accept'); //false
$stateMachine->can($pullRequest, 'reject'); //false
$stateMachine->can($pullRequest, 'reopen'); //true - можем снова открыть pull request

echo $pullRequest->getCurrentPlace(); //closed

try {
    //нарушим бизнес логику - закроем и так уже закрытый пулл реквест
    $stateMachine->apply($pullRequest, 'reject');
} catch(LogicException $workflowException) {
    echo 'Мне кажется мы сбились!!! :(';
}

$stateMachine->apply($pullRequest, 'reopen');
echo $pullRequest->getCurrentPlace(); //review

При этом, если абстрагироваться, то иногда бывает так, что сам объект может иметь несколько состояний одновременно. Помимо state_machine мы можем прописать нашему объекту тип workflow, что позволит одновременно иметь несколько статусов у одного объекта. Примером из жизни может послужить ваша первая публикация на хабре, которая одновременно может иметь статусы, например: «Мне нужна проверка на плагиат», «Мне нужна проверка на качество» и которая может перейти в статус «Опубликована» только после того как все эти проверки пройдены, ну это конечно при условии, что все эти процессы не автоматизированы, но мы сейчас ведем речь не об этом.

Для примера создадим новую сущность Article в нашей системе.

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="article")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\ArticleRepository")
 */
class Article
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="simple_array")
     */
    private $currentPlaces;

    
    public function getId()
    {
        return $this->id;
    }

    public function setCurrentPlaces($currentPlaces)
    {
        $this->currentPlaces = $currentPlaces;

        return $this;
    }
    
    public function getCurrentPlaces()
    {
        return $this->currentPlaces;
    }
}

Теперь создадим для него WorkFlow конфигурацию:

article:
            supports:
                - AppBundle\Entity\Article
            type: 'workflow'
            marking_store:
                type: 'multiple_state'
                argument: 'currentPlaces'
            places:
                - draft
                - wait_for_journalist
                - approved_by_journalist
                - wait_for_spellchecker
                - approved_by_spellchecker
                - published
            transitions:
                request_review:
                    from: draft
                    to:
                        - wait_for_journalist
                        - wait_for_spellchecker
                journalist_approval:
                    from: wait_for_journalist
                    to: approved_by_journalist
                spellchecker_approval:
                    from: wait_for_spellchecker
                    to: approved_by_spellchecker
                publish:
                    from:
                        - approved_by_journalist
                        - approved_by_spellchecker
                    to: published

Давайте посмотрим как будит выглядеть наш код:

$article = new Article();
$workflow = $this->getContainer()->get('workflow.article');

$workflow->apply($article, 'request_review');
/*
   array(2) {
      ["wait_for_journalist"]=>
      int(1)
      ["wait_for_spellchecker"]=>
      int(1)
    }
 */
var_dump($article->getCurrentPlaces());

//Окей, журналист проверил новость!
$workflow->apply($article, 'journalist_approval');

/*
   array(2) {
      ["wait_for_spellchecker"]=>
      int(1)
      ["approved_by_journalist"]=>
      int(1)
    }
 */
var_dump($article->getCurrentPlaces());
var_dump($workflow->can($article, 'publish')); //false, потому что не была проведена еще одна проверка

$workflow->apply($article, 'spellchecker_approval');
var_dump($workflow->can($article, 'publish')); //true, все проверки пройдены

Вы так же без проблем можете визуализировать то, что вы только что сделали, для этого мы будем пользоваться www.graphviz.org — ПО для визуализации графов, который на вход принимает в себя данные вида:

digraph workflow {
  ratio="compress" rankdir="LR"
  node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="1" width="1"];
  edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"];

  place_start [label="start", shape=circle, style="filled"];
  place_coding [label="coding", shape=circle];
  place_travis [label="travis", shape=circle];
  place_review [label="review", shape=circle];
  place_merged [label="merged", shape=circle];
  place_closed [label="closed", shape=circle];
  transition_submit [label="submit", shape=box, shape="box", regular="1"];
  transition_update [label="update", shape=box, shape="box", regular="1"];
  transition_update [label="update", shape=box, shape="box", regular="1"];
  transition_update [label="update", shape=box, shape="box", regular="1"];
  transition_wait_for_review [label="wait_for_review", shape=box, shape="box", regular="1"];
  transition_request_change [label="request_change", shape=box, shape="box", regular="1"];
  transition_accept [label="accept", shape=box, shape="box", regular="1"];
  transition_reject [label="reject", shape=box, shape="box", regular="1"];
  transition_reopen [label="reopen", shape=box, shape="box", regular="1"];
  place_start -> transition_submit [style="solid"];
  transition_submit -> place_travis [style="solid"];
  place_coding -> transition_update [style="solid"];
  transition_update -> place_travis [style="solid"];
  place_travis -> transition_update [style="solid"];
  transition_update -> place_travis [style="solid"];
  place_review -> transition_update [style="solid"];
  transition_update -> place_travis [style="solid"];
  place_travis -> transition_wait_for_review [style="solid"];
  transition_wait_for_review -> place_review [style="solid"];
  place_review -> transition_request_change [style="solid"];
  transition_request_change -> place_coding [style="solid"];
  place_review -> transition_accept [style="solid"];
  transition_accept -> place_merged [style="solid"];
  place_review -> transition_reject [style="solid"];
  transition_reject -> place_closed [style="solid"];
  place_closed -> transition_reopen [style="solid"];
  transition_reopen -> place_review [style="solid"];
}

Конвертировать наш граф в такой формат можно как с помощью PHP:

$dumper = new \Symfony\Component\Workflow\Dumper\GraphvizDumper();
echo $dumper->dump($stateMachine->getDefinition());

Так и с помощью готовой команды

 php bin/console workflow:dump pull_request > out.dot
 dot -Tpng out.dot -o graph.png

graph.png будет иметь следующий вид для PullRequest:


и для Article:


Дополнение:

Уже с выходом 3.3 в stable мы сможем использовать guard:

framework:
    workflows:
        article:
            audit_trail: true
            supports:
                - AppBundle\Entity\Article
            places:
                - draft
                - wait_for_journalist
                - approved_by_journalist
                - wait_for_spellchecker
                - approved_by_spellchecker
                - published
            transitions:
                request_review:
                    guard: "is_fully_authenticated()"
                    from: draft
                    to:
                        - wait_for_journalist
                        - wait_for_spellchecker
                journalist_approval:
                    guard: "is_granted('ROLE_JOURNALIST')"
                    from: wait_for_journalist
                    to: approved_by_journalist
                spellchecker_approval:
                    guard: "is_fully_authenticated() and has_role('ROLE_SPELLCHECKER')"
                    from: wait_for_spellchecker
                    to: approved_by_spellchecker
                publish:
                    guard: "is_fully_authenticated()"
                    from:
                        - approved_by_journalist
                        - approved_by_spellchecker
                    to: published
Tags:phpsymfonyworkflow
Hubs: PHP Symfony
+10
8.4k 54
Comments 23