В университетские времена я столкнулся с такой математической абстракцией, как конечный автомат (КА). Эта модель была полезна для понимания и создания комбинированной логики. Спустя 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-коде отсутствует системность. Проблемы очевидные:
-
Изменения статусов происходят в произвольных местах, прямым запросом к БД. Сложно найти, кто и по каким причинам его меняет.
-
Логика разрастается, появляются вложенные методы и неочевидные взаимосвязи. Со временем отслеживание логики изменения статусов становится очень сложным. Чтобы распутать логику, нужно много времени. Возрастает вероятность ошибки.
В другом нашем проекте, связанном со статейными ссылками, используется 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. Он представляет собой машину состояний (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:

Бонус: автоматизированная валидация действий
Благодаря тому, что в каждом состоянии системы мы знаем все возможные переходы, мы можем написать валидатор для параметра “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 позволил нам:
-
Структурно описать бизнес-правила для жизненного цикла статуса ссылки.
Нет необходимости писать сопутствующий код в виде ветвлений или табличек. Вся логика находится в одном месте — в определении бизнес-процесса. Теперь у нас есть источник истины. Причём, он никак не связан с кодом.
-
В соответствии с ними валидировать возможные переходы.
Не нужно описывать правила в каждом месте использования. В том числе исчезает дублирование логики, поскольку за нас заботой о переходах занята машина состояний Workflow.
-
Предоставлять список возможных переходов в API.
Список создаётся автоматически, всегда актуален. Вероятность допустить ошибку существенно снижается.
-
Графически представить переходы статусов, исходный и конечные статусы.
Можно передать QA-отделу, менеджерам, техподдержке. Можно проверять корректность визуально: если у места (вершины графа) нет исходящего перехода, это либо конечный конечный статус, либо ошибка. Также легко увидеть “оторванные” вершины —– статусы, из которых нельзя выйти. Графическое представление очень помогает в процессе проектирования и наладки системы.
Теперь мы абсолютно уверены в том, что описанные определением переходы статусов работают верно, а в случае сомнений мы можем передать его в виде графа менеджерам проекта или отделу по контролю качества. Если у пользователей возникают вопросы, то техническая поддержка может быстрее решить их без участия отдела разработки. Благодаря этому разработка стала прозрачнее, снизилась нагрузка на истолкования и пояснения по логике, формализованной в коде.
ссылка на оригинал статьи https://habr.com/ru/post/702078/
Добавить комментарий