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' # or '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');

Доступ к рабочему процессу в классе

Вы можете использовать рабочий процесс внутри класс, используя автомонтирование сервисов и 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]

После применения перехода, анонсированное событие тестирует все доступные переходы. Это еще раз вызовет все события-охранники events , что может повлиять на производительность, если они содержат интенсивный 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(); // returns ['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: 'Blog Publishing Workflow'
            # ...
            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: 'You can not publish after 8 PM.'

Затем вы можете получить доступ к этим метаданным в вашем контроллере следующим образом:

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', "You have successfully applied the transition with title: '$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>