Workflow

Дата оновлення перекладу 2022-12-23

Workflow

Використання компонента Workflow всередині додатку Symfony вимагає для початку знань базової теорії та концептів робочих процесів та машин станів. Прочитайте цю статтю, щоб отримати загальне уявлення.

Установка

У додатках, що використовують Symfony Flex , виконайте цю команду, щоб встановити функцію робочого процесу, перед її використанням:

1
$ composer require symfony/workflow

Конфігурація

Щоб побачити всі опції конфігурації, якщо ви використовуєте компонент всередині проекту Symfony, виконайте цю команду:

1
$ php bin/console config:dump-reference framework workflows

Створення Workflow

Workflow - це процес або життєвий цикл, який проходять ваші обʼєкти. Кожний крок або етап процесу називається місцем. Ви також визначаєте переходи, які описують дію, щоб переміститися з одного місця в інше.

Набір місць та переходів створює визначення. Робочому процесу необхідно Definition і спосіб записувати стани в обʼєкти (тобто, екземпляр MarkingStoreInterface.)

Розгляньте наступний приклад для посту блогу. Пост може мати такі місця: draft, reviewed, rejected, published. Ви можете визначити робочий процес таким чином:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            type: 'workflow' # або 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'currentPlace'
            supports:
                - App\Entity\BlogPost
            initial_marking: draft
            places:
                - draft
                - reviewed
                - rejected
                - published
            transitions:
                to_review:
                    from: draft
                    to:   reviewed
                publish:
                    from: reviewed
                    to:   published
                reject:
                    from: reviewed
                    to:   rejected

Tip

Якщо ви створюєте ваші перші робочі процеси, подумайте про використання команди workflow:dump, щоб налагодити зміст робочого процесу.

Сконфігурована властивість буде використана через її реалізовані методи гетера/сетера сховищем маркування:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/Entity/BlogPost.php
namespace App\Entity;

class BlogPost
{
    // сконфігурована властивіть сховища маркування має бути оголошена
    private $currentPlace;
    private $title;
    private $content;

    // методи гетера/сетера повинні існувати для того, щоб властивість була доступна сховищу маркування
    public function getCurrentPlace()
    {
        return $this->currentPlace;
    }

    public function setCurrentPlace($currentPlace, $context = [])
    {
        $this->currentPlace = $currentPlace;
    }
}

Note

Тип сховища маркування може бути "multiple_state" або "single_state". Сховище маркування одного стану не підтримує модель, розташовану у декількох місцях водночас. Це означає, що "workflow" має використовувати сховище маркування "multiple_state", а "state_machine" має використовувати сховище маркування "single_state". Symfony конфігурує сховище маркування відповідно до "type" за замовчуванням, тому його краще не конфігурувати.

Сховище маркування одного стану використовує string для зберігання даних. Сховище маркування багатьох станів використовує array для зберігання даних.

Tip

Атрибути marking_store.type (значення за замовчуванням залежить від значення type) і property (значення за замовчуванням ['marking']) опції marking_store - не обовʼязкові. Якщо їх пропутсити, будуть використані їх значення за замовчуванням. Дуже рекомендовано використовувати значення за замовчуванням.

Tip

Установка опції audit_trail.enabled як true змушує додаток генерувати деталізовані повідомлення логів для активності робочого процесу.

З цим робочим процесом під назвою blog_publishing, ви можете отримати допомогу, щоб вирішити, які дії будуть дозволені у пості блогу:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use App\Entity\BlogPost;
use Symfony\Component\Workflow\Exception\LogicException;

$post = new BlogPost();

$workflow = $this->container->get('workflow.blog_publishing');
$workflow->can($post, 'publish'); // False
$workflow->can($post, 'to_review'); // True

// Оновити currentState посту
try {
    $workflow->apply($post, 'to_review');
} catch (LogicException $exception) {
    // ...
}

// Побачити всі доступні переходи для посту у поточному стані
$transitions = $workflow->getEnabledTransitions($post);
// Побачити конкретний доступний перехід для посту у поточному стані
$transition = $workflow->getEnabledTransition($post, 'publish');

Доступ до Workflow у класі

Ви можете використовувати робочий процес всередині класу, використовуючи автомонтування сервісів і camelCased workflow name + Workflow в якості імені параметра. Якщо це тип машини станів, використовуйте camelCased workflow name + StateMachine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Entity\BlogPost;
use Symfony\Component\Workflow\WorkflowInterface;

class MyClass
{
    private $blogPublishingWorkflow;

    // Symfony впровадить робочий процес 'blog_publishing', сконфігурований раніше
    public function __construct(WorkflowInterface $blogPublishingWorkflow)
    {
        $this->blogPublishingWorkflow = $blogPublishingWorkflow;
    }

    public function toReview(BlogPost $post)
    {
        // Оновити currentState посту
        try {
            $this->blogPublishingWorkflow->apply($post, 'to_review');
        } catch (LogicException $exception) {
            // ...
        }
        // ...
    }
}

6.2

Всі робочі процеси та сервіси машин станів тегуються, починаючи з Symfony 6.2.

Tip

Якщо ви хочете отримати всі робочі процеси, заради документації, наприклад, ви можете впровадити всі сервіси з наступним тегом:

  • workflow: всі робочі процеси та машини станів;
  • workflow.workflow: всі робочі процеси;
  • workflow.state_machine: всі машини станів.

Tip

Ви можете знайти список доступних сервісів робочого процесу за допомогою команди php bin/console debug:autowiring workflow.

Використання подій

Щоб зробити ваші робочі процеси гнучкішими, ви можете створити обʼєкт Workflow з EventDispatcher. Тепер ви можете створювати слухачів подій для блокування переходів (тобто, в залежності від даних у пості блогу) і робити додаткові дії, коли відбувається операція робочого процессу (наприклад, відправляти повідомлення).

Кожний крок має три події, які запукаються по порядку:

  • Подія для всіх робочих процесів;
  • Подія для задіяного робочого процесу;
  • Подія для задіяного робочого процесу з конкретним переходом або іменем місця.

Коли ініціюється перехід стану, події запускаються в наступному порядку:

workflow.guard

Валідує, чи блокується перехід (див. події-охоронці і блокування переходів ).

Три події, що запускаються:

  • workflow.guard
  • workflow.[workflow name].guard
  • workflow.[workflow name].guard.[transition name]
workflow.leave

Субʼєкт ось-ось покине місце.

Три події, що запускаються:

  • workflow.leave
  • workflow.[workflow name].leave
  • workflow.[workflow name].leave.[place name]
workflow.transition

Субʼєкт проходить перехід.

Три події, що запускаються:

  • workflow.transition
  • workflow.[workflow name].transition
  • workflow.[workflow name].transition.[transition name]
workflow.enter

Субʼєкт ось-ось зайде у нове місце. Ця подія запускається прямо перед тим як оновклюються місця субʼєкта, що означає, що маркування субʼєкта ще не оновлене відповідно до нових місць.

Три події, що запускаються:

  • workflow.enter
  • workflow.[workflow name].enter
  • workflow.[workflow name].enter.[place name]
workflow.entered

Субʼєкт увійшов у місця та маркування оновилося.

Три події, що запускаються:

  • workflow.entered
  • workflow.[workflow name].entered
  • workflow.[workflow name].entered.[place name]
workflow.completed

Обʼєкт виконав цей перехід.

Три події, що запускаються:

  • workflow.completed
  • workflow.[workflow name].completed
  • workflow.[workflow name].completed.[transition name]
workflow.announce

Запускається для кожного переходу, який тепер доступний субʼєкту.

Три події, що запускаються:

  • workflow.announce
  • workflow.[workflow name].announce
  • workflow.[workflow name].announce.[transition name]

Після застосування переходу, оголошення події тестує всі доступні переходи. Це ще раз викличе всі події-охоронці , що може вплинути на продуктивність, якщо вони містять інтенсивну CPU або навантаження бази даних.

Якщо вам не потрібно оголошувати подію, відключіть її, використовуючи контекст:

1
$workflow->apply($subject, $transitionName, [Workflow::DISABLE_ANNOUNCE_EVENT => true]);

Контекст доступний у всіх подіях, окрім подій workflow.guard:

1
2
3
4
5
6
// $context має бути масивом
$context = ['context_key' => 'context_value'];
$workflow->apply($subject, $transitionName, $context);

// у слухачі подій (події workflow.guard)
$context = $event->getContext(); // повертає ['context']

Note

Виходи та входи у події запускаються навіть для переходів, які залишаються в одному місці.

Note

Якщо ви ініціалізуєте маркування, викликавши $workflow->getMarking($object);, то подія workflow.[workflow_name].entered.[initial_place_name] буде викликана з контекстом за замовчуванням (Workflow::DEFAULT_INITIAL_CONTEXT).

Ось приклад того, як включити логування для кожного разу, коли робочий процес "blog_publishing" йде з місця:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/App/EventSubscriber/WorkflowLoggerSubscriber.php
namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;

class WorkflowLoggerSubscriber implements EventSubscriberInterface
{
    private $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function onLeave(Event $event)
    {
        $this->logger->alert(sprintf(
            'Blog post (id: "%s") performed transition "%s" from "%s" to "%s"',
            $event->getSubject()->getId(),
            $event->getTransition()->getName(),
            implode(', ', array_keys($event->getMarking()->getPlaces())),
            implode(', ', $event->getTransition()->getTos())
        ));
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.blog_publishing.leave' => 'onLeave',
        ];
    }
}

Якщо деякі слухачі оновлюють контекст під час переходу, ви можете вилучити його через маркування:

1
2
3
4
$marking = $workflow->apply($post, 'to_review');

// містить нове значення
$marking->getContext();

Події-охоронці

Існує особливий вид подій під назвою "події-охоронці". Їх слухачі подій викликаються кожний раз, коли виконується виклик до Workflow::can(), Workflow::apply() або Workflow::getEnabledTransitions(). З подіями-охоронцями ви можете додавати користувацьку логіку, щоб вирішити, які переходи варто блокувати. Ось список імен подій-охоронців.

  • workflow.guard
  • workflow.[workflow name].guard
  • workflow.[workflow name].guard.[transition name]

Цей приклад зупиняє будь-який пост блогу від переходу у "reviewed", якщо у нього немає заголовку:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/App/EventSubscriber/BlogPostReviewSubscriber.php
namespace App\EventSubscriber;

use App\Entity\BlogPost;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\GuardEvent;

class BlogPostReviewSubscriber implements EventSubscriberInterface
{
    public function guardReview(GuardEvent $event)
    {
        /** @var BlogPost $post */
        $post = $event->getSubject();
        $title = $post->title;

        if (empty($title)) {
            $event->setBlocked(true, 'This blog post cannot be marked as reviewed because it has no title.');
        }
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.blog_publishing.guard.to_review' => ['guardReview'],
        ];
    }
}

Вибір подій для оголошення

Якщо ви надаєте перевагу тому, щоб контролювати, які події оголошуються при виконанні кожного переходу, використайте опцію конфігурації events_to_dispatch. Ця опція не застосовується до подій-охоронців , які оголошуються завжди:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            # ви можете передати одне або більше імен подій
            events_to_dispatch: ['workflow.leave', 'workflow.completed']

            # передати пустий масив, щоб не запускати ніяких подій
            events_to_dispatch: []

            # ...

Ви також можете відключити конкретну подію від запуску при застосування переходу:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Entity\BlogPost;
use Symfony\Component\Workflow\Exception\LogicException;

$post = new BlogPost();

$workflow = $this->container->get('workflow.blog_publishing');

try {
    $workflow->apply($post, 'to_review', [
        Workflow::DISABLE_ANNOUNCE_EVENT => true,
        Workflow::DISABLE_LEAVE_EVENT => true,
    ]);
} catch (LogicException $exception) {
    // ...
}

Відключення події для конкретного переходу буде головувати над будь-яким вказаним у конфігурації робочого процесу подіями. У прикладі вище, подія workflow.leave не буде оголошена, навіть якщо вона була вказана як подія для запуску для всіх переходів у конфігурації робочого процесу.

Ось всі доступні константи:

  • Workflow::DISABLE_LEAVE_EVENT
  • Workflow::DISABLE_TRANSITION_EVENT
  • Workflow::DISABLE_ENTER_EVENT
  • Workflow::DISABLE_ENTERED_EVENT
  • Workflow::DISABLE_COMPLETED_EVENT

Методи подій

Кожна подія робочого процесу - це екземпляр Event. Що означає, що кожна подія має доступ до наступної інформації:

getMarking()
Повертає Marking робочого процесу.
getSubject()
Повертає обʼєкт, який оголошує подію.
getTransition()
Повертає Transition, який оголошує подію.
getWorkflowName()
Повертає рядок з іменем робочого процесу, який оголосив подію.
getMetadata()
Повертає метадані.

Для подій-охоронців існує розширений клас GuardEvent. Це клас має такі додаткові методи:

isBlocked()
Повертається, якщо перехід заблокований.
setBlocked()
Встановлює заблоковане значення.
getTransitionBlockerList()
Повертає подію TransitionBlockerList. Див. блокування переходів .
addTransitionBlocker()
Додає екземпляр TransitionBlocker.

Блокування переходів

Виконання робочого процесу можна контролювати, викликаючи користувацьку логіку щоб вирішити, чи блокувати поточний перехід перед його застосуванням. Ця функція надана "охоронцями", що може бути використана двома способами.

По-перше, ви можете слухати події-охоронці . Як варіант, ви можете визначити опцію конфігурації guard для переходу. Значення цієї опції - будь-який валідний вираз, створений за допомогою компонента ExpressionLanguage:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            # попередня конфігурація
            transitions:
                to_review:
                    # перехід дозволено лише якщо поточний користувач має роль ROLE_REVIEWER.
                    guard: "is_granted('ROLE_REVIEWER')"
                    from: draft
                    to:   reviewed
                publish:
                    # або "is_anonymous", "is_remember_me", "is_fully_authenticated", "is_granted", "is_valid"
                    guard: "is_authenticated"
                    from: reviewed
                    to:   published
                reject:
                    # або будь-яку валідну мову виразу з "субʼєктом", що посилається на підтримуваний обʼєкт
                    guard: "is_granted('ROLE_ADMIN') and subject.isRejectable()"
                    from: reviewed
                    to:   rejected

Ви також можете використовувати блокувальники переходів для блокування та повернення дружнього повідомлення про помилку, коли ви запобігаєте переходу. У прикладі ми отримуємо це повідомлення з метаданих Event, який дає вам повний контроль управління текстом.

Цей приклад було спрощено; у виробництві вам краще використати компонент Translation, щоб управляти повідомленнями в одному місці:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/App/EventSubscriber/BlogPostPublishSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;

class BlogPostPublishSubscriber implements EventSubscriberInterface
{
    public function guardPublish(GuardEvent $event)
    {
        $eventTransition = $event->getTransition();
        $hourLimit = $event->getMetadata('hour_limit', $eventTransition);

        if (date('H') <= $hourLimit) {
            return;
        }

        // Заблоувати перехід "publish", якщо вже пізніше 8ої вечора
        // з повідомленням для кінцевого користувача
        $explanation = $event->getMetadata('explanation', $eventTransition);
        $event->addTransitionBlocker(new TransitionBlocker($explanation , '0'));
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.blog_publishing.guard.publish' => ['guardPublish'],
        ];
    }
}

Застосування в Twig

Symfony визначає декілька функцій Twig для управління робочими процесами та зменшення потреби у логіці домену у вашому шаблоні:

workflow_can()
Повертає true, якщо заданий обʼєкт може пройти заданий перехід.
workflow_transitions()
Повертає масив з усіма переходами, включеними для заданого обʼєкта.
workflow_transition()
Повертає конкретний перехід, включений для заданого обʼєкта, та імʼя переходу.
workflow_marked_places()
Повертає масив з іменами місць заданого маркування.
workflow_has_marked_place()
Повертає true, якщо маркування заданого обʼєкта має заданий стан.
workflow_transition_blockers()
Повертає TransitionBlockerList для заданого переходу.

Наступний приклад демонструє ці функції в дії:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<h3>Actions on Blog Post</h3>
{% if workflow_can(post, 'publish') %}
    <a href="...">Publish</a>
{% endif %}
{% if workflow_can(post, 'to_review') %}
    <a href="...">Submit to review</a>
{% endif %}
{% if workflow_can(post, 'reject') %}
    <a href="...">Reject</a>
{% endif %}

{# Або закільцювати включені переходи #}
{% for transition in workflow_transitions(post) %}
    <a href="...">{{ transition.name }}</a>
{% else %}
    Дії недоступні.
{% endfor %}

{# Перевірити, чи знаходиться обʼєкт в якомусь конкретному місці #}
{% if workflow_has_marked_place(post, 'reviewed') %}
    <p>This post is ready for review.</p>
{% endif %}

{# Перевірити, чи було якесь місце марковано в обʼєкті #}
{% if 'reviewed' in workflow_marked_places(post) %}
    <span class="label">Reviewed</span>
{% endif %}

{# Закільцювати блокувальники переходів #}
{% for blocker in workflow_transition_blockers(post, 'publish') %}
    <span class="error">{{ blocker.message }}</span>
{% endfor %}

Зберігання метаданих

Якщо вам потрібно, ви можете зберігати довільні метадані у робочих процесах їх місцях та переходах, використовуючи опцію metadata. Ці метадані можуть бути просто заголовком робочого процесу або дуже складними обʼєктами:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            metadata:
                title: 'Робочий процес публікації блогу'
            # ...
            places:
                draft:
                    metadata:
                        max_num_of_words: 500
                # ...
            transitions:
                to_review:
                    from: draft
                    to:   review
                    metadata:
                        priority: 0.5
                publish:
                    from: reviewed
                    to:   published
                    metadata:
                        hour_limit: 20
                        explanation: 'Ви не можете публікувати після 8ої вечора.'

Потім ви можете отримати доступ до цих матадних у вашому контролері наступним чином:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/App/Controller/BlogPostController.php
use App\Entity\BlogPost;
use Symfony\Component\Workflow\WorkflowInterface;
// ...

public function myAction(WorkflowInterface $blogPublishingWorkflow, BlogPost $post)
{
    $title = $blogPublishingWorkflow
        ->getMetadataStore()
        ->getWorkflowMetadata()['title'] ?? 'Default title'
    ;

    $maxNumOfWords = $blogPublishingWorkflow
        ->getMetadataStore()
        ->getPlaceMetadata('draft')['max_num_of_words'] ?? 500
    ;

    $aTransition = $blogPublishingWorkflow->getDefinition()->getTransitions()[0];
    $priority = $blogPublishingWorkflow
        ->getMetadataStore()
        ->getTransitionMetadata($aTransition)['priority'] ?? 0
    ;

    // ...
}

Існує метод getMetadata(), який працює з усіма видами метаданих:

1
2
3
4
5
6
7
8
// отримати "workflow metadata", передаючи ключ з метаданих як аргумент
$title = $workflow->getMetadataStore()->getMetadata('title');

// отримати "place metadata", передаючи ключ метаданих як перший аргумент, а імʼя місця - як другий
$maxNumOfWords = $workflow->getMetadataStore()->getMetadata('max_num_of_words', 'draft');

// отримати "transition metadata", передаючи ключ метаданих як перший аргумент, а обʼєкт Переходу - як другий
$priority = $workflow->getMetadataStore()->getMetadata('priority', $aTransition);

У флеш-повідомленні у вашому контролері:

1
2
3
4
5
// $transition = ...; (an instance of Transition)

// $workflow - це екземпляр Робочого процесу, вилучений з Реєстру або впроваджений напряму (див. выще)
$title = $workflow->getMetadataStore()->getMetadata('title', $transition);
$this->addFlash('info', "Ви успішно застосувалли перехід з заголовком: '$title'");

Доступ до метаданих також можна отримати у слухачі з обʼєкта Event.

У шаблонах Twig метадані доступні через функцію workflow_metadata():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<h2>Metadata of Blog Post</h2>
<p>
    <strong>Workflow</strong>:<br>
    <code>{{ workflow_metadata(blog_post, 'title') }}</code>
</p>
<p>
    <strong>Current place(s)</strong>
    <ul>
        {% for place in workflow_marked_places(blog_post) %}
            <li>
                {{ place }}:
                <code>{{ workflow_metadata(blog_post, 'max_num_of_words', place) ?: 'Unlimited'}}</code>
            </li>
        {% endfor %}
    </ul>
</p>
<p>
    <strong>Enabled transition(s)</strong>
    <ul>
        {% for transition in workflow_transitions(blog_post) %}
            <li>
                {{ transition.name }}:
                <code>{{ workflow_metadata(blog_post, 'priority', transition) ?: 0 }}</code>
            </li>
        {% endfor %}
    </ul>
</p>
<p>
    <strong>to_review Priority</strong>
    <ul>
        <li>
            to_review:
            <code>{{ workflow_metadata(blog_post, 'priority', workflow_transition(blog_post, 'to_review')) }}</code>
        </li>
    </ul>
</p>