Введение: зачем нужна стейтмашина
В приложениях часто необходимо ограничивать доступ к тем или иным действиям над объектом. Для этого используют RBAC-модули, которые решают задачу ограничения доступа в зависимости от прав пользователя. Остается нерешенной задача управления действиями в зависимости от состояния объекта. Эта задача хорошо решается с помощью конечного автомата или state machine. Удобная стейтмашина позволяет не только собрать в одном месте все правила переходов между состояниями объекта, но и наводит некоторый порядок в коде отделяя правила переходов, проверки условия и обрабочкики и подчиняя их общим правилам.
Хочу поделиться реализацией стейтмашины под Zend Framework 3 с использованием Doctrine 2
для работы с базой данных. Сам проект можно найти по ссылке.
А здесь я хочу поделиться основными заложенными принципами.
Приступим
Описание графа переходов мы будем хранить в таблице базы данных потому что:
- Это наглядно.
- Позволяет использовать тот же словарь состояний, который используется в интересующем
нас объекте, имеющим состояния. - Позволяет гарантировать целостность базы используя внешние ключи.
Использование недетерминированного конечного автомата повысит гибкость нашего решения.
Граф переходов будем описывать с помощью пары таблиц A и B, связанных между собой отношением один ко многим.
Таблица A:
CREATE TABLE `tr_a` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`src_id` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`action_id` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT 'идентификатор действия',
`condition` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'Валидатор доступности данного действия',
PRIMARY KEY (`id`),
KEY `IDX_96B84B3BFF529AC` (`src_id`),
KEY `IDX_96B84B3B9D32F035` (`action_id`),
CONSTRAINT `FK_96B84B3B9D32F035` FOREIGN KEY (`action_id`) REFERENCES `action` (`id`),
CONSTRAINT `FK_96B84B3BFF529AC` FOREIGN KEY (`src_id`) REFERENCES `state` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
Таблица B:
CREATE TABLE `tr_b` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`transition_a_id` int(11) NOT NULL,
`dst_id` varchar(32) COLLATE utf8_unicode_ci NOT NULL,
`weight` int(11) DEFAULT NULL COMMENT 'задает порядок проверки,больше-раньше проверяется, null-переход по умолчанию',
`condition` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'Валидатор доступности данного перехода',
`pre_functor` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'имя функтора, содержащего действия, выполняемые до перехода',
`post_functor` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'имя функтора, содержащего действия, выполняемые после перехода',
PRIMARY KEY (`id`),
KEY `IDX_E12699CB85F4C374` (`transition_a_id`),
KEY `IDX_E12699CBE1885D19` (`dst_id`),
CONSTRAINT `FK_E12699CB85F4C374` FOREIGN KEY (`transition_a_id`) REFERENCES `tr_a` (`id`),
CONSTRAINT `FK_E12699CBE1885D19` FOREIGN KEY (`dst_id`) REFERENCES `state` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
Определение классов энтити я опущу, посмотреть пример можно здесь:
Здесь все стандартно, включая описание связи между сущностями.
Для использования стейтмашины нам необходимы всего несколько публичных методов
/**
* Выполняется действие над объектом и меняет состояние объекта согласно таблицы переходов
* @param object $objE
* @param string $action
* @param array $data extra data
* @return array
* @throws ExceptionNS\StateMachineException
*/
public function doAction($objE, $action, array $data = [])
/**
* Проверяет возможность выполнения действия над объектом в текущем состоянии
* @param object $objE
* @param string $action
* @param array $data
* @return bool
*/
public function hasAction($objE, $action, $data=[])
Для комфортного использования есть еще несколько публичных методов, но здесь я бы хотел уделить внимания алгоритму работы основного метода doAction()
.
Из объекта получаем его состояние.
Зная его и идентификатор действия легко отыскивается ентити-А в таблице перехода А.
Условие полученное по идентификатору условия, который лежит в condition
из ентити-А, позволяет проверить возможность выполнения действия. В частности в валидаторе условия может быть использован упомянутый вначале статьи RBAC.
Валидатор будет найден по идентификатору из поля condition
через ValidatorPluginManager
и
должен реализовывать \Zend\Validator\ValidatorInterface
. Я предпочитаю использовать наследников от ValidatorChain
. Это позволяет легко менять состав контролируемых условий и переиспользовать простые валидаторы в составе проверочных цепочек.
С переходом определились, условие проверили. Так как у нас недетерминированный
конечный автомат, то результатом действия может быть одно из нескольких состояний.
Такие случаи встречаются не очень часто, но предлагаемый проект это легко реализует.
По связи А --< B получаем коллекцию возможных новых состояний объекта (энтити-B).
Чтобы выбрать единственное состояние проверяем по очереди условия у энтити-B из полученной коллекции, отсортировав их по полю weight
от большего к меньшему. Первая успешная проверка условия дает нам энтити-B, в которой есть новое состояние объекта (смотри поле dst_id
).
Новое состояние определено. Теперь перед сменой состояния стейтмашина выполнит
действия, определенные в префункторе, затем сменит состояние и выполнит действия,
определенные в постфункторе. Стейтмашина достанет функторы на основании имени из поля pre_functor
для префунктора и post_functor
для постфунктора с помощью плагин-менеджера и вызовет метод для полученных объектов метод __invoke().
Менять состояние с помощью функторов не нужно. Это задача стейтмашины. Если нет необходимости выполнять дополнительные действия при смене состояния, то в указанные выше поля ставим null.
Прочие фишки:
- В полях таблиц переходов
condition
,pre_funtor
,post_functor
я использую алиасы, на мой взгляд это удобно. - Для визуального удобства создаем view из таблиц А и Б.
- В качестве первичного ключа в словарях состояний и действий использую строчные идентификаторы. Это не обязательно, но удобно. Словари с числовыми идентификаторам также могут быть использованы.
- Т.к используется недетерминированный конечный автомат, то действие не обязательно должно приводить к смене статуса. Это позволяет описывать такие действия как просмотр, например.
- Кроме методов проверки действия и выполнения действия есть еще ряд публичных методов, которые позволяют получить, например, список действий для данного состояния объекта или список доступных действий для данного состояния объекта с учетом проверок. Зачастую в интерфейсе в гридах у каждой записи нужно показать набор действий. Данные методы стейтмашины помогут получить необходимый список.
- Разумеется внутри функторов могут быть вызваны другие стейтмашины, более того можно вызывать саму себя, но с другим объектом или с тем же объектом, но после смены состояния (т.е. в постфункторе). Это иногда бывает полезным для организации каскадных переходов при изменившихся "вдруг" условиях от заказчика ;)
Заключение
Несмотря на множество задач, идеально подходящих под использование стейтмашин, веб-программисты используют их сравнительно редко. Те же решения, которые я видел, показались мне монстровитыми.
Надеюсь, что предложенное решение поможет кому-нибудь сэкономить время на реализацию.
Комментарии (2)
OnYourLips
10.06.2018 11:09+2Почему не рассматривали подключение готового и прекрасно работающего symfony/workflow пакета?
Описание графа переходов мы будем хранить в таблице базы данных потому что
Причины так-себе, особенно про наглядность. Думаю, что гораздо правильнее было бы хранить это в конфигурации, за исключением случаев, когда настройка самого workflow — это часть интерфейса пользователя (как в Jira).
xztau
Конечного автомата?