Конечные автоматы на практике: Symfony Workflow

В университетские времена я столкнулся с такой математической абстракцией, как конечный автомат (КА). Эта модель была полезна для понимания и создания комбинированной логики. Спустя 15 лет КА вернулся в мою жизнь в виде компонента Symfony Workflow. В этой статье я расскажу, как наша команда при помощи Symfony Workflow улучшила код продукта Links.Sape, переводя его с legacy.

Теория: конечный автомат / машина состояний

Итак, в университете мы создавали системы на базе логических элементов И/ИЛИ, которые меняют состояние по входным сигналам, такие как АЛУ — арифметико-логическое устройство:

Схема работы арифметико-логического устройстваСхема работы арифметико-логического устройства

По сути, это — аппаратная логика процессора, выполняющего математические операции, такие как сложение. Скажем, наш АЛУ умеет лишь складывать два пришедших операнда. Тогда систему можно определить таблицей истинности, перечислив все возможные операнды и результаты.

Такая система представляет собой КА — машину с конечными числом состояний (finite state machine, FSM):

Коне́чный автома́т (КА) в теории алгоритмов — математическая абстракция, модель дискретного устройства, имеющего один вход, один выход и в каждый момент времени находящегося в одном состоянии из множества возможных.

Конечный автомат мы можем описать различными способами. Давайте разберём их.

Способ описания: диаграмма состояний конечного автомата

Графический способ представления. Систему можно изобразить как размеченный ориентированный граф, вершины которого представляют собой состояния КА, дуги — переходы между состояниями, а метки этих дух называют символами, по которым осуществляется переход из одного состояния в другое. Символы ещё можно назвать сигналами, по которым меняется система. В случае АЛУ на схеме выше символами будут приходящие на вход операнды.

Пример из Wikipedia:

Граф переходов КАГраф переходов КА

Мы видим, что изначально наша система приходит в состояние p0, после чего по символу (сигналу) a переходит в состояние p1, и так далее. Из состояния p2 есть два возможных перехода: в состояние p3 и p4, в зависимости от символа. Обратим также внимание на другие случаи: возможен циклический переход из состояния p3 в p5 и наоборот, а также сохранение состояния p4 по символу b.

Способ описания: таблица переходов конечного автомата

Это табличный способ описания системы. Здесь мы перечисляем все возможные варианты состояния системы. Например, так:

Таблица истинности КАТаблица истинности КА

Каждая строка соответствует одному состоянию, а столбец — один допустимый входной символ. В ячейке на пересечении строки и столбца записывается состояние, в которое должен перейти автомат, если в данном состоянии он считал данный входной символ.

Проблемы legacy-кода, решаемые при помощи абстракции КА

В предметной области нашей биржи ссылок есть сущность ссылки. Она покупается рекламодателем и размещается исполнителем, проходя различные этапы, такие как подтверждение с той или иной стороны, «засыпание» в случае неоплаты или ручного замораживания, удаление и т.п. Эти состояния определяются статусами. Ссылку с параметром её статуса можно назвать конечным автоматом. Действительно, по сигналу — некоторому действию пользователя или событию системы — она меняет статус, приходя в новое состояние. 

Однако в реализации работы со ссылкой в legacy-коде отсутствует системность. Проблемы очевидные:

  1. Изменения статусов происходят в произвольных местах, прямым запросом к БД. Сложно найти, кто и по каким причинам его меняет.

  2. Логика разрастается, появляются вложенные методы и неочевидные взаимосвязи. Со временем отслеживание логики изменения статусов становится очень сложным. Чтобы распутать логику, нужно много времени. Возрастает вероятность ошибки.

В другом нашем проекте, связанном со статейными ссылками, используется ORM Doctrine 1. Добавляется ещё одна проблема: часть изменений вносится на уровне хуков на запись (магический метод save (), который вызывается при сохранении данных сущности в БД), что влечёт за собой неявное поведение. Например, бизнес-логика какого-либо сервиса выставляет статус, вызывает сохранение в БД, а метод save () самостоятельно меняет статус на другой. Обнаружить в таком случае ошибку может быть очень непросто.

В целом подход с ручной установкой статуса страдает общей проблемой: смена статуса (грубо говоря, SQL-команда UPDATE) происходит безусловно, без учёта того, какой был исходный статус. Безусловно, можно попробовать описать эту логику в коде, обложившись ветвлениями, или даже составив массив возможных переходов, но это — велосипед, потому что существует абстракция более высокого уровня, которую мы можем использовать.

Знакомимся: Symfony Workflow

Однажды я изучал список доступных компонентов Symfony и заметил Symfony Workflow. Если люди потратили время и подготовили библиотеку, они увидели важность в каком-то обобщении. За каждым компонентом стоит своя задача, решение которой можно обобщить и переиспользовать. Интересно углубиться и понять, в чём эта задача и насколько хорошо решение. Workflow оказался реализацией КА с целым рядом полезностей.

Компонент Workflow предоставляет вам объектно-ориентированный способ для определения процесса или жизненного цикла, через который проходит ваш объект. Каждый шаг или этап в процессе называется местом. Вы также определяете переходы, которые описывают действие для перемещения из одного места в другое. Набор мест и переходов создаёт определение.

The Fast Track, «Управление состоянием с помощью Workflow».

(Я выделил термины, чтобы показать связь с терминологией КА.)

Перерабатывая наш legacy-код, я решил использовать Workflow для описания статуса ссылок в нашем новом приложении.

Описание статуса ссылок через Workflow

Workflow предлагает описание определений в том числе через YAML. В таком случае оно должно располагаться в файле config/packages/workflow.yaml.

Посмотрите фрагмент определения арендных ссылок нашего нового приложения:

Определение бизнес-процесса placement_rentОпределение бизнес-процесса placement_rent

Бизнес-процесс называется placement_rent. Он представляет собой машину состояний (type: state_machine). Параметр marking_store определяет, каким образом Workflow может получить состояние системы. Мы описали, что это можно сделать при помощи метода (type: 'method'), а именно, getStatusConstantName () (property: 'statusConstantName'). Бизнес-процесс применим к сущности App\Entity\Mysql\Placement Doctrine (описана в свойстве supports). Исходное состояние — статус ссылки STATUS_PHANTOM (определили в initial_marking).

Статус STATUS_PHANTOM мы используем как технический. В таком статусе ссылка никогда не должна быть показана в UI. Он существует на случай, если в процессе создания ссылки произошёл критический сбой. Но это уже особенность бизнес-логики.

Далее описываются места (places) и определения (transitions). Places — те статусы, в которых может оказаться ссылка. Transitions — названия возможных переходов. From — из какого статуса, to — в какой. В поле metadata можно записать любую сопроводительную информацию. Мы используем его для человекочитаемого представления перехода. Например, его можно отображать в UI.

В нашем случае в качестве актора выступает как рекламодатель, так и исполнитель, и для них доступны различные действия. Мы используем постфиксы _seo / _wm для ограничения переходов по ролям пользователя.

Метод getStatusConstantName () преобразует ID статуса ссылки в переход Workflow. Этот метод — связующее звено между двумя системами: Workflow и Doctrine. Благодаря ему мы приводим в соответствие терминологию этих двух систем. Реализация у него такая:

   /**
     * Получить имя константы статуса.
     *
     * @return string
     * @throws Exception
     */
    public function getStatusConstantName(): string
    {
        $constantName = null;
        $reflectionClass = new ReflectionClass($this);
        foreach ($reflectionClass->getConstants() as $constantsName => $constantValue) {
            if (!is_array($constantValue)) {
                if ($constantValue === $this->status) {
                    $constantName = $constantsName;
                    break;
                }
            }
        }

        return $constantName;
    }

Теперь посмотрим, как наше определение используется на практике.

API-метод получения информации о ссылке

В нашем API есть метод Placements.viewPlacement (я использую тег-интерфейс OpenAPI). Он предоставляет как базовую информацию для отображения пользователю, так и список доступных действий со ссылкой. Описание в OpenAPI (в сокращении):

   "/rest/Placement/{placementId}": {
      "get": {
        "tags": [
          "Placements"
        ],
        "summary": "Получение информации о ссылке",
        "operationId": "viewPlacement",
        "parameters": [
          {
            "$ref": "#/components/parameters/placementId"
          }
        ],
        "responses": {
          "200": {
            "description": "Информация о ссылке",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "format": "int32",
                      "title": "ID ссылки",
                      "minimum": 1
                    },
                    "placementUrl": {
                      "type": "string",
                      "format": "uri",
                      "title": "URL размещения"
                    },
                    "placementActions": {
                      "type": "array",
                      "title": "Список доступных действий над ссылкой",
                      "items": {
                        "$ref": "#/components/schemas/PlacementAction"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ResponseBadParameters"
          },
          "401": {
            "$ref": "#/components/responses/ResponseUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ResponsePermissionDenied"
          },
          "404": {
            "$ref": "#/components/responses/ResponseNotFound"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },

placementActions — это список доступных действий. Каждое из действий определено так (#/components/schemas/PlacementAction):

"PlacementAction": {
  "type": "string",
  "title": "Действие над ссылкой",
  "enum": [
    "create_unapproved_seo", "create_unapproved_wm", "create_approved_seo", "approve_wm", "sleep_manual_seo", "unsleep_manual_seo", "sleep_billing_robot", "unsleep_robot", "approve_autobuyer_seo", "guarantee_wm", "cancel_seo", "cancel_rework_not_news_robot", "cancel_rework_news_robot", "cancel_termination_seo", "terminate_seo", "terminate_wm", "cancel_wm", "cancel_robot", "restore_rejected_unapproved_robot", "restore_rejected_approved_robot", "clarificate_wm", "arbitrate_seo", "approve_seo", "return_to_improve_seo", "place_wm", "error_to_robot", "error_from_robot", "terminate_robot"
  ]
}

В поле placementActions этого API-метода мы предоставляем автоматически генерируемый список доступных для ссылки действий (переходов Symfony Workflow).

Фрагмент реализации получения списка доступных для ссылки действий:

   /**
     * Получить названия доступных переходов для ссылки.
     *
     * @param Placement $placement
     * @param string $userRole
     * @return string[]
     */
    public function getTransitionsNamesForPlacement(Placement $placement, string $userRole): array
    {
        $stateMachine = $this->placementRentStateMachine;
        $transitionsEnabled = $stateMachine->getEnabledTransitions($placement);

        $transitionsEnabledNames = [];
        foreach ($transitionsEnabled as $transition) {
            if (!$this->canRoleAccessTransition($userRole, $transition->getName())) {
                continue;
            }

            $transitionsEnabledNames[] = $transition->getName();
        }

        return $transitionsEnabledNames;
    }

Получаем определение для переданного типа ссылок, затем получаем список переходов машины состояний Symfony Workflow (getEnabledTransitions). Затем добавляем бизнес-логику поверх машины состояний — отфильтровываем доступные действия по роли пользователя (то, что в определении мы организовали при помощи постфиксов _seo / _wm).

API-метод выполнения действия над ссылкой

Другой пример API-метода — Placements.executePlacementsAction. Он выполняет действие над ссылкой.

В OpenAPI он выглядит так (в сокращении):

   "/rest/Placements/action": {
      "post": {
        "tags": [
          "Placements"
        ],
        "summary": "Выполнить действие над ссылками",
        "operationId": "executePlacementsAction",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "$ref": "#/components/schemas/PlacementAction"
                  },
                  "placementIds": {
                    "type": "array",
                    "title": "Массив ID ссылок для выполнения действия",
                    "items": {
                      "type": "integer",
                      "format": "int32",
                      "title": "ID ссылки",
                      "minimum": 1
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
…

Мы видим уже знакомую схему PlacementAction. Эта схема снова используется в этом методе.

Фрагмент реализации установки статуса (упущена валидация):

$availableStatuses = $this->workflowService->getTransitionStatusesByName($request->action);
if (!empty($availableStatuses)) {
    $endStatusName = $availableStatuses->getTos()[0];
    $placement->setStatus(constant(Placement::class . '::' . $endStatusName));
    $this->placementModelService->savePlacement($placement);
    $response->placementIds[] = $placement->getId();
} else {
    $error = new PlacementsExecutePlacementsActionErrors();
    $error->placementId = $placement->getId();
    $error->error = $this->translator->trans('Неизвестный конечный статус перехода');
    $response->errors[] = $error;
}

Благодаря Workflow мы обобщили действия над ссылками. Нет необходимости давать отдельные определения для подтверждения, отмены или чего-либо ещё. Возможное действие через API доступно в поле placementActions у метода Placements.viewPlacement и оно в неизменном виде может быть передано на вход Placements.executePlacementsAction. Например, это очень удобно для UI, который прокидывает в компонент действия всё, что получит из API:

Фрагмент UI Links.SapeФрагмент UI Links.Sape

Бонус: автоматизированная валидация действий

Благодаря тому, что в каждом состоянии системы мы знаем все возможные переходы, мы можем написать валидатор для параметра «PlacementAction»:

   /**
     * Проверить валидность действия над ссылками
     *
     * @param string|null $action
     * @param ExecutionContextInterface $context
     */
    public function validatePlacementActionAvailable(?string $action, ExecutionContextInterface $context): void
    {
        if (is_null($action)) {
            return;
        }

        $transitions = $this->workflowService->getAllTransitionsListByUserRole($this->userModelService->getRole());

        $isActionAvailable = false;
        if (!empty($transitions)) {
            foreach ($transitions as $transition) {
                if ($transition->getName() === $action) {
                    $isActionAvailable = true;
                    break;
                }
            }
        }

        if (!$isActionAvailable) {
            $context
                ->buildViolation($this->translator->trans('Некорректное действие'))
                ->atPath('placementId')
                ->addViolation();
        }
    }

В getAllTransitionsListByUserRole () находится получение доступных переходов из Workflow (встроенный метод getTransitions ()), а также добавлена логика учёта роли пользователя.

Граф переходов статусов ссылок ссылок 

Удобно, но и это ещё не всё. Symfony Workflow умеет автоматически генерировать граф переходов, который очень напоминает то, что мы видели в теоретической части этой статьи:

4abb67fe35e8e951145a1993ba51ddcf.png

Граф переходов арендных ссылок сгенерирован автоматически встроенными средствами Symfony Workflow. Он создаётся командой вида

php bin/console workflow:dump placement_rent | dot -Tpng -o placement_rent_workflow.png

Теперь можно встроить в CI/CD-систему автоматическое обновление документации, чтобы этот граф генерировался при каждом изменении определения бизнес-процесса.

Что мы получили в итоге?

Workflow позволил нам:

  1. Структурно описать бизнес-правила для жизненного цикла статуса ссылки.

Нет необходимости писать сопутствующий код в виде ветвлений или табличек. Вся логика находится в одном месте — в определении бизнес-процесса. Теперь у нас есть источник истины. Причём, он никак не связан с кодом.

  1. В соответствии с ними валидировать возможные переходы.

Не нужно описывать правила в каждом месте использования. В том числе исчезает дублирование логики, поскольку за нас заботой о переходах занята машина состояний Workflow.

  1. Предоставлять список возможных переходов в API.

Список создаётся автоматически, всегда актуален. Вероятность допустить ошибку существенно снижается.

  1. Графически представить переходы статусов, исходный и конечные статусы. 

Можно передать QA-отделу, менеджерам, техподдержке. Можно проверять корректность визуально: если у места (вершины графа) нет исходящего перехода, это либо конечный конечный статус, либо ошибка. Также легко увидеть «оторванные» вершины —– статусы, из которых нельзя выйти. Графическое представление очень помогает в процессе проектирования и наладки системы.

Теперь мы абсолютно уверены в том, что описанные определением переходы статусов работают верно, а в случае сомнений мы можем передать его в виде графа менеджерам проекта или отделу по контролю качества. Если у пользователей возникают вопросы, то техническая поддержка может быстрее решить их без участия отдела разработки. Благодаря этому разработка стала прозрачнее, снизилась нагрузка на истолкования и пояснения по логике, формализованной в коде.

© Habrahabr.ru