Введение: зачем нужна стейтмашина
В приложениях часто необходимо ограничивать доступ к тем или иным действиям над объектом. Для этого используют 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
Конечного автомата?