Конечные автоматы на практике: 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 умеет автоматически генерировать граф переходов, который очень напоминает то, что мы видели в теоретической части этой статьи:

Граф переходов арендных ссылок сгенерирован автоматически встроенными средствами 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-отделу, менеджерам, техподдержке. Можно проверять корректность визуально: если у места (вершины графа) нет исходящего перехода, это либо конечный конечный статус, либо ошибка. Также легко увидеть “оторванные” вершины —– статусы, из которых нельзя выйти. Графическое представление очень помогает в процессе проектирования и наладки системы.

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


ссылка на оригинал статьи https://habr.com/ru/post/702078/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *