Введение: зачем нужна стейтмашина


В приложениях часто необходимо ограничивать доступ к тем или иным действиям над объектом. Для этого используют RBAC-модули, которые решают задачу ограничения доступа в зависимости от прав пользователя. Остается нерешенной задача управления действиями в зависимости от состояния объекта. Эта задача хорошо решается с помощью конечного автомата или state machine. Удобная стейтмашина позволяет не только собрать в одном месте все правила переходов между состояниями объекта, но и наводит некоторый порядок в коде отделяя правила переходов, проверки условия и обрабочкики и подчиняя их общим правилам.


Хочу поделиться реализацией стейтмашины под Zend Framework 3 с использованием Doctrine 2
для работы с базой данных. Сам проект можно найти по ссылке.


А здесь я хочу поделиться основными заложенными принципами.


Приступим




Описание графа переходов мы будем хранить в таблице базы данных потому что:


  1. Это наглядно.
  2. Позволяет использовать тот же словарь состояний, который используется в интересующем
    нас объекте, имеющим состояния.
  3. Позволяет гарантировать целостность базы используя внешние ключи.

Использование недетерминированного конечного автомата повысит гибкость нашего решения.


Граф переходов будем описывать с помощью пары таблиц 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

Определение классов энтити я опущу, посмотреть пример можно здесь:


  1. таблица А
  2. таблица B

Здесь все стандартно, включая описание связи между сущностями.


Для использования стейтмашины нам необходимы всего несколько публичных методов


/**
 * Выполняется действие над объектом и меняет состояние объекта согласно таблицы переходов
 * @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.


Прочие фишки:


  1. В полях таблиц переходов condition, pre_funtor, post_functor я использую алиасы, на мой взгляд это удобно.
  2. Для визуального удобства создаем view из таблиц А и Б.
  3. В качестве первичного ключа в словарях состояний и действий использую строчные идентификаторы. Это не обязательно, но удобно. Словари с числовыми идентификаторам также могут быть использованы.
  4. Т.к используется недетерминированный конечный автомат, то действие не обязательно должно приводить к смене статуса. Это позволяет описывать такие действия как просмотр, например.
  5. Кроме методов проверки действия и выполнения действия есть еще ряд публичных методов, которые позволяют получить, например, список действий для данного состояния объекта или список доступных действий для данного состояния объекта с учетом проверок. Зачастую в интерфейсе в гридах у каждой записи нужно показать набор действий. Данные методы стейтмашины помогут получить необходимый список.
  6. Разумеется внутри функторов могут быть вызваны другие стейтмашины, более того можно вызывать саму себя, но с другим объектом или с тем же объектом, но после смены состояния (т.е. в постфункторе). Это иногда бывает полезным для организации каскадных переходов при изменившихся "вдруг" условиях от заказчика ;)

Заключение


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


Надеюсь, что предложенное решение поможет кому-нибудь сэкономить время на реализацию.

Комментарии (2)


  1. xztau
    10.06.2018 08:46

    стейтмашины

    Конечного автомата?


  1. OnYourLips
    10.06.2018 11:09
    +2

    Почему не рассматривали подключение готового и прекрасно работающего symfony/workflow пакета?

    Описание графа переходов мы будем хранить в таблице базы данных потому что
    Причины так-себе, особенно про наглядность. Думаю, что гораздо правильнее было бы хранить это в конфигурации, за исключением случаев, когда настройка самого workflow — это часть интерфейса пользователя (как в Jira).