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