Реализация стейтмашины на Zend Framework3+Doctine2

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


В приложениях часто необходимо ограничивать доступ к тем или иным действиям над объектом. Для этого используют 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. Разумеется внутри функторов могут быть вызваны другие стейтмашины, более того можно вызывать саму себя, но с другим объектом или с тем же объектом, но после смены состояния (т.е. в постфункторе). Это иногда бывает полезным для организации каскадных переходов при изменившихся "вдруг" условиях от заказчика ;)

Заключение


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


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

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 14
  • 0
    стейтмашины

    Конечного автомата?
    • 0
      да конечно. Просто почему-то в моем окружении обычно используют англоязычное заимствование.
    • +2
      Почему не рассматривали подключение готового и прекрасно работающего symfony/workflow пакета?

      Описание графа переходов мы будем хранить в таблице базы данных потому что
      Причины так-себе, особенно про наглядность. Думаю, что гораздо правильнее было бы хранить это в конфигурации, за исключением случаев, когда настройка самого workflow — это часть интерфейса пользователя (как в Jira).
      • 0
        Не соглашусь на счет правильности хранения в конфигурации. Один из аргументов Вы сами высказали — возможность модификации через интерфейс.
        И хранение в БД гарантирует использование того же словаря состояний в переходах, что и в ентити объекта с помощью внешних ключей. А как Вы обеспечите это, если конфигурация хранится в хмл/php/ini файле?

        Мне пришлось работать с конечным автоматом с конфигурацией в хмл. Даже при небольшом количестве переходов листать длинный конфиг неудобно, там много лишнего на мой взгляд было описано.

        Почему не рассматривал symfony/workflow — не знал о нем. Поинтересуюсь, спасибо. Со своей стороны могу сказать, что данный проект в той или иной модификации опробован на нескольких проектах под ЗФ2/3 и отлично себя зарекомендовал.
        • 0
          Один из аргументов Вы сами высказали — возможность модификации через интерфейс.
          Это единственный аргумент, и он нужен только в ПО, где это ключевой функционал, это лишь совсем небольшая часть случаев.

          И хранение в БД гарантирует использование того же словаря состояний в переходах, что и в ентити объекта с помощью внешних ключей. А как Вы обеспечите это, если конфигурация хранится в хмл/php/ini файле?
          С гарантиями в конфигурации или коде все проще: оно в приложении константно (для определенной версии) и гарантировано по определению.

          Со своей стороны могу сказать, что данный проект в той или иной модификации опробован на нескольких проектах под ЗФ2/3 и отлично себя зарекомендовал.
          Не вижу причин в проекте со скелетом от одного фреймворка не использовать компоненты других, если вы это имели ввиду. PSR для этого и появились.

          зы. даже файловые конфиги можно править из интерфейса

          oxidmod вынужден не согласиться про файловые конфиги, которые можно править из интерфейса. В этом случае конфиги перестают быть частью приложения и становятся данными. И нет принципиальной разницы, где их хранить: в файловом хранилище ли в реляционной СУБД. Вот только второй вариант из этих двух удобнее.
          • 0
            С гарантиями в конфигурации или коде все проще: оно в приложении константно (для определенной версии) и гарантировано по определению.

            Нельзя гарантировать, что упомянутое в конфигурации состояние будет в словаре состояний в БД. Для этого Вы и говорите об константах (если я Вас правильно понимаю), которые кто-то определил и сверил со словарем. Но зачем поддерживать 2 источника синхронными, если можно иметь один? Это же удобней.

            • 0
              Не вижу причин в проекте со скелетом от одного фреймворка не использовать компоненты других, если вы это имели ввиду

              Нет, в данном случае я просто подчеркнул, что это ПО тоже вполне хорошо себя зарекомендовало, не более того.

              Против переиспользования компонентов в других фреймворках ничего не имею. Используем же мы доктрину например ;)

              • 0
                вынужден не согласиться про файловые конфиги


                Это было лишь примечание, что и их можно править, если сильно нужно.
          • 0
            А как Вы обеспечите это, если конфигурация хранится в хмл/php/ini файле


            VO. Он не соберется с кривого значения и вы получите ожидаемое исключение о кривой конфигурации

            зы. даже файловые конфиги можно править из интерфейса
            • 0
              VO. Он не соберется с кривого значения и вы получите ожидаемое исключение о кривой конфигурации
              =====
              Если Вы будете контролировать «кривые» значения при считывании конфигурации. БД уже это делает.
              • 0
                Все не так просто с БД. Есть 2 более-менее нормальных варианта поддержания целостности для «состояния».
                Первый — это ENUM. У нас есть перечисление возможных состояний и нельзя вставить в бд кривое значение. ENUM нельзя редактировать с интерфейса. ENUM вообще менять может быть довольно проблемно на больших таблицах.
                Второй — отдельная таблица-словарь состояний. В основной таблице идентификатор и внешний ключ. Можно редактировать с интерфейса, но не очень удобно работать с приложения. Теперь, чтобы приложение могло добавить новую запись с определенным состоянием, нужно сначала с БД получить идентификатор этого состояния. Сама таблица-словарь состояний в общем виде определяется двумя колонками: собственно идентификатор и какой-то алиас или тайтл. С приложения искать придется по этой второй колонке, и снова нет гарантии, что между приложением и БД не возникнет рассинхрон при очередном изменении кода или БД.

                В последнее время я начал использовать VO для таких вещей. В БД просто примитив (чаще всего SMALLINT), без ограничений и ключей, но при гидрации в объект, мы получим эксепшен о кривом значении. Пока мне кажется, что этот подход самый гибкий и простой в реализации. Любые изменения в бизнес логике состояний требуют лишь правок кода, но не изменения схемы.
                • 0
                  ENUM вообще менять может быть довольно проблемно на больших таблицах.

                  согласен.
                  Сама таблица-словарь состояний в общем виде определяется двумя колонками: собственно идентификатор и какой-то алиас или тайтл. С приложения искать придется по этой второй колонке, и снова нет гарантии, что между приложением и БД не возникнет рассинхрон при очередном изменении кода или БД.

                  Идентификатор в статическом словаре делается строчным и смысл в алиасе тогда пропадает.
                  При динамическом словаре удобство хранения в БД по сравнению с файлом описал выше OnYourLips
                  • 0
                    Даже если это одна строчная колонка, приложение фактически должно знать, какое это значение. Иначе оно будет падать при попытке вставить какое-то другое. У вас все равно есть необходимость держать копию этого словаря в приложении.
                    По большому счету какая разница, ошибка вылетит с уровня бд о нарушении констрейнта или с самого приложения, о невозможности собрать VO.
                    Более того, вы экономите один запрос в бд и упадете до его выполнения.
                    • 0
                      Иначе оно будет падать при попытке вставить какое-то другое.

                      Вам все равно прийдется это отслеживать, потому что действие может передаваться параметром запроса в ряде приложений.
                      Стейтмашина просто сгенерирует в этом случае исключение, которое должно быть перехвачено выше по стеку и обработано.
                      А поскольку все равно будет такая обработка, то после этого использовать константы в коде или использовать фактически те же константы из БД значения особого имееть не будет.
                      Если в приложении не предусмотрена передача действия как параметра, тогда Вы вольны добавить константы. Этой возможности никто не отнимает. Если передается, то смысл исчезает.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое