Maszyna stanów w PHP

diagram maszyny stanów

Maszyna stanów (State Machine) to koncept, który spotkać można również poza samym programowaniem. W założeniach jest on dość prosty – opisuje możliwe stany i przejścia pomiędzy nimi w obrębie danego obiektu. Problem, który rozwiązuje, okazuje się występować zaskakująco często, dlatego warto wiedzieć jak zaimplementować maszynę stanów w PHP.

Zanim przejdę do kodu, przydałoby się trochę teorii. Pozwoli to zrozumieć podstawy tego podejścia, a w efekcie samodzielnie zaimplementować komponent realizujący tę funkcjonalność. Jednak tym razem skorzystam z biblioteki, która dostarcza gotową implementacje i spełnia potrzebne założenia. Będzie to winzou/state-machine, ale istnieje ich znacznie więcej. Polecam też przyjrzeć się: symfony/workflow i yohang/finite.

Zacznę od tego że każdy obiekt może mieć stan i założę, że jest on określony polem status – brzmi znajomo, co? Naturalnie sam stan (state) może się zmieniać i nazywa się to po prostu przejściem (transition). Najprostszy z możliwych przypadków to taki, gdzie z każdego stanu można dowolnie przejść do innego. W praktyce jednak jest to dość rzadka sytuacja. Zdecydowanie częściej okaże się, że istnieją dodatkowe warunki, kiedy obiekt może znaleźć się w konkretnym stanie. Niezależnie od tego jakie są założenia, diagram możliwych stanów i przejść między nimi definiuje pewien przepływ (workflow).

Jak widzicie, nie ma tutaj nic skomplikowanego, a pewnie każdy z Was spotkał się z taką sytuacją. Standardowo można ją rozwiązać na kilka sposobów, ale najczęściej po prostu zdefiniowane zostaną metody pozwalające modyfikować owy stan. Wówczas to obiekt sam pilnuje swoich reguł biznesowych i wie, kiedy w którym stanie może się znaleźć i czy żądana zmiana jest możliwa. Zadba także o to, by nie pozwolić na wprowadzenie samego siebie w stan niepoprawny. Enkapsulacja w najpiękniejszej postaci.

Maszyna stanów – kiedy?

Od razu trzeba zaznaczyć, że cały przepływ dla niektórych obiektów może być bardzo skomplikowany. Nie każde pole status sprowadza się do flag aktywny/nieaktywny. Rzecz jasna dla takiego przykładu maszyna stanów to zbyt duży i niepotrzebny narzut. Jednak dla bardziej skomplikowanych może okazać się pomocna. Kiedy? Najlepiej wtedy, gdy stanem danego obiektu powinien zarządzać inny obiekt, a same przejścia określane są raczej na zasadzie „z którego do którego” bez wielu dodatkowych założeń i blokad. Oczywiście znane muszą być też wszystkie możliwe stany i przejścia, czyli cały workflow.

Implementacja Machine State w PHP

Tak jak wspomniałem we wstępie, przygotowałem prostą implementację przy użyciu gotowej biblioteki. Na potrzeby tego przykładu bardzo łatwo byłoby samodzielnie napisać taką maszynę stanów, ale biblioteki zazwyczaj potrafią trochę więcej. Wspominam o tym, bo może w tym przykładzie tego nie widać, ale warto zajrzeć do dokumentacji. Na pewno ciekawą opcją są trzy callbacki za pomocą których można wykonać akcje przy zmianie stanu. Służą do tego before , który wykona się przed przejściem, guard wykonujący się w trakcie testowania przejścia i after, który wykona się tuż po zmianie stanu. To dobre miejsce na przykład na sprawdzenie dodatkowych warunków.

W poniższym przykładzie stworzyłem klasę Meetup, która ma pole $status. Jako stałe zdefiniowałem 5 możliwych stanów i transakcji. Wykorzystam je w samej konfiguracji maszyny stanów, ale też przy jej dalszym używaniu w kodzie.

<?php

declare(strict_types=1);

namespace StateMachine;

class Meetup
{
    public const STATUS_DRAFTED = 0;
    public const STATUS_PUBLISHED = 1;
    public const STATUS_SUSPENDED = 2;
    public const STATUS_CANCELED = 3;
    public const STATUS_CLOSED = 4;

    public const TRANSITION_PUBLISH = 'publishMeetup';
    public const TRANSITION_SUSPEND = 'suspendMeetup';
    public const TRANSITION_CANCEL = 'cancelMeetup';
    public const TRANSITION_RESTORE = 'restoreMeetup';
    public const TRANSITION_CLOSE = 'closeMeetup';

    private int $status;

    public function __construct()
    {
        $this->status = self::STATUS_DRAFTED;
    }
}

Kolejnym plikiem jest sama konfiguracja. Mamy w niej nazwę będącą pewnym identyfikatorem (graph), nazwę pola modyfikowanego podczas zmiany stanu (property_path), możliwe stany (states) i możliwe przejścia (transitions). Cała konfiguracja jest tak naprawdę odwzorowaniem przepływu zaprezentowanego na tytułowej grafice.

<?php

declare(strict_types=1);

namespace StateMachine;

return [
    'graph' => 'meetupGraph',
    'property_path' => 'status',
    'states' => [
        Meetup::STATUS_DRAFTED,
        Meetup::STATUS_PUBLISHED,
        Meetup::STATUS_SUSPENDED,
        Meetup::STATUS_CANCELED,
        Meetup::STATUS_CLOSED
    ],
    'transitions' => [
        Meetup::TRANSITION_PUBLISH => [
            'from' => [
                Meetup::STATUS_DRAFTED,
                Meetup::STATUS_SUSPENDED
            ],
            'to' => Meetup::STATUS_PUBLISHED
        ],
        Meetup::TRANSITION_SUSPEND => [
            'from' => [
                Meetup::STATUS_PUBLISHED
            ],
            'to' => Meetup::STATUS_SUSPENDED
        ],
        Meetup::TRANSITION_CANCEL => [
            'from' => [
                Meetup::STATUS_DRAFTED,
                Meetup::STATUS_PUBLISHED,
                Meetup::STATUS_SUSPENDED
            ],
            'to' => Meetup::STATUS_CANCELED
        ],
        Meetup::TRANSITION_RESTORE => [
            'from' => [
                Meetup::STATUS_CANCELED
            ],
            'to' => Meetup::STATUS_DRAFTED
        ],
        Meetup::TRANSITION_CLOSE => [
            'from' => [
                Meetup::STATUS_PUBLISHED
            ],
            'to' => Meetup::STATUS_CLOSED
        ]
    ]
];

Ostatni kod to przykładowe użycie maszyny stanów. Oczywiście samo tworzenie maszyny i dostarczanie konfiguracji można zaimplementować na różny sposób. Nie chciałem komplikować, dlatego zrobiłem to w najprostszy możliwy. Trzy akcje, które może zrealizować organizator to publikacja wydarzenia, sprawdzenie czy dane wydarzenie może zostać zakończone oraz czy nie jest zostało ono anulowane.

<?php

declare(strict_types=1);

namespace StateMachine;

use SM\StateMachine\StateMachine;

class MeetupOrganizer
{
    private array $meetupWorkflow;

    public function __construct(array $meetupWorkflow)
    {
        $this->meetupWorkflow = $meetupWorkflow;
    }

    public function publish(Meetup $meetup): void
    {
        $stateMachine = new StateMachine($meetup, $this->meetupWorkflow);
        if (!$stateMachine->apply(Meetup::TRANSITION_PUBLISH, true)) {
            throw new \DomainException('Meetup cannot be published.');
        }
    }

    public function canClose(Meetup $meetup): bool
    {
        $stateMachine = new StateMachine($meetup, $this->meetupWorkflow);
        return $stateMachine->can(Meetup::TRANSITION_CLOSE);
    }

    public function isCanceled(Meetup $meetup): bool
    {
        $stateMachine = new StateMachine($meetup, $this->meetupWorkflow);
        return intval($stateMachine->getState()) === Meetup::STATUS_CANCELED;
    }
}

Czy warto używać SM?

Problem z maszyną stanów jest taki, że obiekt sam nie pilnuje własnego stanu, a jeśli pilnuje to znaczy, że powielono logikę, więc wprowadzenie State Machine nie ma sensu. Świetnie, jeśli wszędzie wykonując operacje na danym obiekcie jego stanem zarządzać będzie właśnie maszyna. Nie ma jednak możliwości wymuszenia tego i przez to mechanizm może okazać się wadliwy, bo obiekt może znaleźć się w niepoprawnym stanie. Pewnie wielu z Was powie, że przecież każdy w projekcie będzie to wiedział i nie skorzysta z obiektu bezpośrednio. Od razu Wam powiem – skorzysta. Tak samo jak nie wymusicie obligatoryjnych pól klasy za pomocą konstruktora – prędzej, czy później ktoś ich nie ustawi i wprowadzi obiekt w niepoprawny stan.

Nie oznacza to jednak, że nie należy korzystać z tego rodzaju mechanizmu. Wszystko ma swoje miejsce i odpowiednio użyte może dać więcej korzyści, niż strat. Jakie więc są zalety płynące z użycia maszyny stanów w PHP:

  • Jeden serwis, który może obsłużyć wiele przypadków w projekcie.
  • Prosta konfiguracja dla skomplikowanych przepływów.
  • Łatwe wprowadzanie nowych stanów.
  • Logika w jednym miejscu.
  • Redukcja ifologii.
  • Proces biznesowy opisany w zrozumiały sposób nawet dla osoby nietechnicznej.

Moim zdaniem, na pewno warto znać ten koncept i po kalkulacji zysków oraz strat może okazać się, że świetnie pasuje do danego problemu. Ostatecznie, pytanie czy należy stosować to podejście zostawię bez odpowiedzi. Liczę jednak, że ten materiał pozwoli Wam dokonać właściwego wyboru podczas napotkania kolejnego takiego przypadku.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.