Jak sensownie zaimplementować maszynę stanów?

1

Wydaje mi się, że FSM, to koncepcja z jednej strony szeroko nauczana, mająca dużo przypadków realnego zastosowania, z trzeciej strony bardzo rzadko używana w czystej formie ze względu na upierdliwość implementacji (zdefiniowanie zbiorów stanów, przejść, zdarzeń) dość rzadko wykorzystywana w czystej formie, co prowadzi w linii prostej do drabinki if'ów, której nikt nie jest w stanie ogarnąć.
Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

3

Poll-based async (tak jak zaimplementowane np. w Ruście :-)), ew. generatory znane w innych językach (np. JSowe czy Pythonowe yield).

1

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.
Chodzi mi o use case np. wniosku urlopowego i zdefiniowanie (czytelnie) możliwych stanów (nowy, zaakceptowany, do poprawy, odrzucony, zaakceptowany, anulowany), reguł typu "jak już zaakceptowany, to nie można odrzucić, ale można anulować" i "jak odrzucony, to już nie można zaakceptować".

0

Trochę nie wiem, co ma wspólnego async z koncepcją FSM.

Poll-based async jest modelowane właśnie jako maszyna stanów; choć widzę, że rozbiegamy się trochę w definicji i/lub use-case'ach, ponieważ Ty myślisz o takiej "biznesowej" - z racji braku lepszego słowa - wysoko-poziomowej FSM, podczas gdy ja podszedłem do tematu z trochę niższej warstwy.

W przypadku Twojego use-case'a sprawę rozwiązują - wydaje mi się - języki z ekspresyjnym systemem typów, w stylu (pseudo-rust, ale niewiele trzeba zmienić by zadziałało):

struct Invoice<Status> {
    _status: PhantomData<Status>,
}

struct AwaitingConfirmation;
struct AwaitingPayment;
struct Paid;
struct Canceled;

impl Invoice<AwaitingConfirmation> {
    fn confirm() -> Invoice<AwaitingPayment> {
        Self { /* ... */ }
    }

    fn cancel() -> Invoice<Canceled> {
        Self { /* ... */ }
    }
}

impl Invoice<AwaitingPayment> {
    fn pay() -> Invoice<Paid> {
        Self { /* ... */ }
    }

    fn cancel() -> Invoice<Canceled> {
        Self { /* ... */ }
    }
}

impl Invoice<Canceled> {
    fn recreate() -> Invoice<AwaitingPayment> {
        Self { /* ... */ }
    }
}

Przy takim układzie nie da się oznaczyć anulowanej faktury jako zapłaconej, tudzież nie da się wrócić z zapłaconej z powrotem do AwaitingConfirmation (bez przejścia przez .recreate()) albo anulować faktury podwójnie.

Edit: zgaduję, że w sumie w niemal każdym języku dałoby się coś takiego zrobić - choć w tych mniej ekspresyjnych trzeba by prawdopodobnie utworzyć osobne pełnoprawne typy na każdy rodzaj stanu faktury (ew. wykorzystać dziedziczenie).

5

Hm, nie powiedziałeś czy w stylu OOP czy FP. Jak OOP to nie wiem. Jak FP to są dwa wyjścia:

  1. Jak każdy typ stanu jest trzymany w innej struktorze to można stworzyć i zaimplementować type classe MyStateMachine z jedną metodą nextState:: warunek -> aktualnyStan -> nowyStan. W zasadzie podobnie można zrobić dla OOP tylko zamiast struktury mamy klasę a zamiast type classy mamy interfejs/trait. Zamiast jednego dużego if/else mamy kilka mniejszych if/else. Tyle ile mamy typów stanów
  2. Jak każdy typ stanu jest trzymany w takiej samej strukturze to mamy jedną prostą (lecz gigantyczną) funkcję nextState:: warunek -> aktualnyStan -> nowyStan. I teraz pytanie na ile nasz język programowania wspiera takie funkcje. Jak mamy tego z 10/20 i mamy w jezyku ładne match to może da się z tym zyć? Jak nie można z tym żyć to widzę dwa wyjścia
    1. Możnaby zbudować łańcuch odpowiedzialności. Zaleta -> łańcuch odpowiedzialności można dynamicznie zmieniać
    2. Rozwiązanie radykalne -> silnik regułowy i wyciągnąć to całkiem na zewnątrz

PS popisałbym sobie coś z jakimś silnikiem regułowym. Eh

7

Bez drabinki ifów. I moim zdaniem bardzo łatwe do ogarnięcia
Proszę:

sealed class WniosekUropowy {

    class Nowy(): WniosekUropowy() {
        fun akceptuj() : Zaakceptowany = TODO()

        fun doPoprawy() : Błędny= TODO()

        fun odrzuć() : Odrzucony = TODO()
    }

    class Zaakceptowany internal constructor() : WniosekUropowy() {
        fun anuluj () : Anulowany = TODO()
    }

    class Błędny internal constructor(): WniosekUropowy() {
        fun popraw() : Nowy = TODO()
    }

    class Odrzucony internal constructor() : WniosekUropowy()

    class Anulowany internal constructor() : WniosekUropowy()
}
2
piotrpo napisał(a):

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.
Chodzi mi o use case np. wniosku urlopowego i zdefiniowanie (czytelnie) możliwych stanów (nowy, zaakceptowany, do poprawy, odrzucony, zaakceptowany, anulowany), reguł typu "jak już zaakceptowany, to nie można odrzucić, ale można anulować" i "jak odrzucony, to już nie można zaakceptować".

Dla systemów "obdarzonych" bazą danych (ERP, Kadry) maszyna stanowa nie będzie taka elegancka jak w uniwersyteckiej teorii

KamilAdam napisał(a):

Hm, nie powiedziałeś czy w stylu OOP czy FP. Jak OOP to nie wiem. Jak FP to są dwa wyjścia:

j/w

W konkluzji, w trosce o dobro pierwszego wdrożenia i modyfikacji typowymi środkami jakie już tam są, bym nie wykluczal dobrej biblioteki stanowej, ale może wydudkał coś, co będzie czytelniejsze dla lokalnych sił, jak "my programiści" już odjedziemy (dajmy na to: wybrana maszyna stanowa to obszerne konfiguracje XML-i, których nikt do tej pory nie kumał)

a) Np wdrażając maszynę "wniosek urlopowy" można polec za rok-dwa, jak Najmiłościwiej Nam Panujący zmienią zasady urlopów, i system dostanie daleko idący upgrade
b) szerszy system może mieć już jakąś obiektowo-relacyjną koncepcję anulowania, "bufora", danych historycznych itd... warto w niej pozostać

0

@ZrobieDobrze: Podałem wniosek urlopowy jako przykład. W systemie kadrowym, pewnie byłby podłączony jakiś zewnętrzny system od reguł biznesowych, żeby nie wbijać do kodu na sztywno "pani Zosia musi klepnąć".

@jarekr000000: Podoba mi się podejście, nie podoba mi się konieczność korzystania z refleksji i rzutowania WniosekUrlopowy na Błędny przed wywołaniem popraw().

Kiedyś zdarzyło mi się napisać coś w tym stylu:

enum States
{
   ON, OFF
}
   
enum Events
{
   SWITCH
}

StateMachine<States, Events, DefaultContext<States>> fsm;
       fsm = new StateMachine<>();

fsm
   .addTransition(ON, OFF, SWITCH)
   .addTransition(OFF, ON, SWITCH);

DefaultContext<States> context = new DefaultContext<>();
context.setCurrentState(States.OFF);
fsm.setContext(context);




fsm.onEvent(SWITCH);
fsm.getContext().getCurrentState();

Miało tę zaletę, że można było dość łatwo przeklepać sobie stany, zdarzenia i tranzycje z diagramu stanów.

3
piotrpo napisał(a):

Wydaje mi się, że FSM, to koncepcja z jednej strony szeroko nauczana, mająca dużo przypadków realnego zastosowania, z trzeciej strony bardzo rzadko używana w czystej formie ze względu na upierdliwość implementacji (zdefiniowanie zbiorów stanów, przejść, zdarzeń) dość rzadko wykorzystywana w czystej formie, co prowadzi w linii prostej do drabinki if'ów, której nikt nie jest w stanie ogarnąć.
Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

Myślę, że trzymanie się konkretnie FSM (i robienie na siłę np. tablicy przejść) to coś bez sensu, cargo cult. Tzn. inaczej, jak komuś się to sprawdza, to spoko, natomiast trzeba sobie zdawać sprawę, że taki książkowy FSM z tablicą przejść to tylko jeden z modeli. Wszystko dla ludzi, ale trzeba wiedzieć, kiedy w którym momencie czego użyć.

Piszę o tym, bo np. widziałem w JS biblioteki, które na siły próbowały odtwarzać FSM tylko po to, żeby zaimplementować pewien wzorzec, nawet jeśli było prościej zrobić to inaczej.

piotrpo napisał(a):

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.

to zobacz sobie przykładowy diagram FSM:
https://en.wikipedia.org/wiki/Finite-state_machine#/media/File:Turnstile_state_machine_colored.svg

Przecież to można napisać w JS używając async/await:

async function StateMachine() {
   while(true) {
       let state = 'locked'   
       await Events.Coin;
       state = 'unlocked';
       await Events.Push;      
   }
}

Ogólnie async/await to czekanie na jakieś zdarzenie, czyli to samo, co się dzieje w FSM, gdzie masz jeden stan, pojawia się zdarzenie, i przechodzisz w drugi.
Idąc dalej - programowanie oparte o zdarzenia (event driven) również można zapisać w postaci async/await.
bawiłem się jakiś czas temu w robienie swojej biblioteki, która opakowuje przeglądarkowe eventy w promisy i można robić coś takiego (mniej więcej)

async function dragNDrop(onDrag) {
    await on('mousedown'); 
    await loopUntil(on('mouseup'), () => {
       const mouseEvent = await on('mousemove');
       onDrag(mouseEvent);
    });
}

czyli w kilka linijek mogłem napisać coś, co by normalnie trzeba było napisać w kilkanaście, w których łapałbym każdy event i ustawiał flagę isDown, potem sprawdzał ifem itp. Plus da się to komponować. Mógłbym później użyć tej rutyny dragNDrop w jakiejś większej interakcji, gdzie dragNDrop to tylko część interakcji z użytkownikiem.

Czyli await to taki cukier składniowy na coś, co ma być, a czego jeszcze nie ma. Czyli można tego użyć do oczekiwania pewnego zdarzenia, które zmienia nam stan.

Jeszcze mamy yield, które działa podobnie (mówię teraz o tym, jak to działa w JS), chociaż trochę inaczej, bardziej niskopoziomowo (co też ma swoje zalety, bo ma się większą kontrolę).

0

@LukeJL: Ja rozumiem await ale to jedynie otoczka samego problemu. Problem polega na tym, że masz jakiś stan aktualny, typ zdarzenia (niekoniecznie zdarzenia "systemowego") i stan wyjściowy. W gruncie rzeczy chodzi o sensowny zapis ciała funkcji newState(currentState, event):State. Sposób przekazania event to z mojego punktu widzenia mało znaczący szczegół. 2 stanów jest to banalne, dla 20 stanów (czyli taki średnio złożony workflow), zaczyna to być już być dość złożone i trudne do zweryfikowania, czy to co napisałeś, jest tym co ktoś zaprojektował w wymaganiach. Prosty stan drzwi:

StatechartDiagram1.png

1

Być może w takim przypadku można jednak faktycznie napisać tablicę przejść.

W języku, który wspiera pattern matching (np. Rust) takie coś się pisze dość straightforward.

enum State {
    Locked, Closed, Opened,
}

enum Event {
    Lock, Unlock, Open, Close
}

fn newState(currentState: State, event: Event) -> State {
    match (&currentState, &event) {
    	(State::Locked,  Event::Unlock) => State::Closed,
        (State::Closed,  Event::Lock) => State::Locked,	
	    (State::Closed,  Event::Open) => State::Opened,
	    (State::Opened,  Event::Close) => State::Closed,
	    _ => currentState
    }
}

czyli masz na poziomie języka rozumienie tablicy przejść, jeśli uznasz, że ci potrzebna.

3
piotrpo napisał(a):

@jarekr000000: Podoba mi się podejście, nie podoba mi się konieczność korzystania z refleksji i rzutowania WniosekUrlopowy na Błędny przed wywołaniem popraw().

A coż to za pomysł, żeby korzystać z refleksji czy rzutowania? Nie ma absolutnie takiej potrzeby.

2

@jarekr000000: Skąd będziesz wiedzieć, ze Wniosek ma metodę anuluj()?

1

Zależy od wymagań, czasem wystarczy switch statement z zamkniętym enumem aby była compile-time-safety (jeśli język na to pozwala)

public List<Element> Parse()
{
	...
	
	do
	{
		if (char.IsWhiteSpace(_Current))
			_State = LexingState.WhiteCharacter;
		else if (char.IsLetter(_Current))
			_State = LexingState.Word;
		else if (char.IsNumber(_Current))
			_State = LexingState.NumericalValue;
		else if (_Current == '"')
			_State = LexingState.String;
		else if (LexingFacts.OtherTokens.Contains(_Current))
			_State = LexingState.Character;
		else
			_State = LexingState.Unknown;

		Handle(_State);
	} while (MoveNext());

	...
}

private void Handle(LexingState state)
{
	switch(state)
	{
		case LexingState.Word:
			HandleWord();
			break;
		case LexingState.String:
			HandleString();
			break;
		case LexingState.NumericalValue:
			HandleNumericalValue();
			break;
		case LexingState.Character:
			HandleOtherCharacter();
			break;
		case LexingState.WhiteCharacter:
			HandleWhiteCharacter();
			break;
		case LexingState.Unknown:
			throw new Exception("Unrecognized token");
	}
}
3

Może z innym nastawieniem weszliśmy w watek, ale może ja jestem jak ten krasnoludek z innej bajki.

Czymś totalnie innym jest maszyna stanów (w dowolnym paradygmacie kodowanie) zaszyta w kodzie - czymś zupełnie innym wyprowadzona do konfiguracji (jak szczęśliwie czy nie podany przykład Wniosek Urlopowy)
Stany zaszyte (szczególnie w propozycjach funkcyjnych) w hierarchii dziedziczenia są w pewnym widzeniu bardzo ok - w innym są katastrofą, czekanie na ingerencję producenta, testy, release itd...

Dla mnie, który tutaj nosze pewne specyficznie swoje spojrzenie - wiele pomysłów jest skrajnie akademickich, ale nie musze mieć racji - zależy od uszczegółowienia potrzeb.

0

@ZrobieDobrze: Częściowo masz, zdarza się, że użytkownik ma mieć swoje możliwości konfiguracji tych stanów i nie da się zaszyć tego w kodzie. Ale możesz też mieć jakąś tam logikę stałą dla wszystkich, np. stan questa w przygodówce, masz małpę, wywołujesz na niej banana i robi się najedzona małpa itd...
Z drugiej strony, można przechowywać stany w ten sposób:

List<State> states;
Map<<Couple<State, Action>>, State> transitions; //initial state, action, final state

Czyli z grubsza to co zaproponował @LukeJL w twardej implementacji Rust.

Jeżeli masz to zrobione w ten sposób, to nie ma przeszkód, żeby te kolekcje utrwalać sobie w bazie i ładować, co w sumie jest prostą operacją a przetwarzanie można robić prosto, jednym odczytem z mapy, a jak nic nie znajdziesz, to zwracasz jako final state stan początkowy.

To jest akademicka dyskusja, ale naturą takich koncepcji jak FSM jest ich uniwersalność i możliwość zastosowania takiej cegiełki niezależnie od szczegółowych potrzeb.

2
piotrpo napisał(a):

@jarekr000000: Skąd będziesz wiedzieć, ze Wniosek ma metodę anuluj()?

Ech - niepotrzebnie dałem to dziewiczenie.
Teraz, będzie łatwiej:

    class Nowy() {
        fun akceptuj() : Zaakceptowany = TODO()

        fun doPoprawy() : Błędny= TODO()

        fun odrzuć() : Odrzucony = TODO()
    }

    class Zaakceptowany internal constructor() {
        fun anuluj () : Anulowany = TODO()
    }

    class Błędny internal constructor() {
        fun popraw() : Nowy = TODO()
    }

    class Odrzucony internal constructor()

    class Anulowany internal constructor()

0

Chyba najlepiej matematycznie to zrobić, transition matrix i jakąś zmienna przetrzymująca aktualny stan.
A macierz może być ściśnięta dla zaoszczędzenia miejsca, dodatkowo może być też opisana prawdopodobieństwem przejścia do innego stanu i rozwiązane nieskończone rekurencje za pomocą absorbing markov chain.

Na macierzach się łatwo operuje, każdy wiersz to inny stan, a kolumny oznaczają do jakich stanów jest przejście lub z jakim prawdopodobieństwem, można też w wersji ściśniętej gdzie po prostu są numery ideksów.

Jak ładnie w klasę opakujesz, to ładnie będzie wyglądać.

3
Szalony Programista2 napisał(a):> Chyba najlepiej matematycznie to zrobić, transition matrix i jakąś zmienna przetrzymująca aktualny stan.

A macierz może być ściśnięta dla zaoszczędzenia miejsca, dodatkowo może być też opisana prawdopodobieństwem przejścia do innego stanu i rozwiązane nieskończone rekurencje za pomocą absorbing markov chain.

To wprost cudownie. Ale po co?

4
piotrpo napisał(a):

Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

Są dwie opcje:

  1. ze stanem w postaci wartości wyliczeniowej, ze słownikiem przejść i sporą liczbą walidacji (czy przejście ze stanu A do B jest możliwe, czy na stanie A można wywoływać metodę DoX())
  2. w oparciu o system typów (jeden stan - jeden typ);

Rozwiązanie pierwsze jest bardziej skomplikowane i błędogenne, ale paradoksalnie prostsze do wdrożenia, bo tłumaczenie rozwiązania nr 2 przeciętnym programistom proceduralnym (zwącymi się dla niepoznaki seniorarmi OOP) jest niemożliwe ("ale jak to, tyle klas, kto tyle kodu będzie pisał i utrzymywał").

Ale ja na Twoim miejscu bym po prostu kupił Raspberry PI z kartą pamięci 16GB i tam trzymał ten stan. :)

0

A jak macie tą maszynkę stanów w oparciu o system typów to jak to trzymacie w bazce? Bo jak rozumiem to tutaj chodzi o taką biznesowa maszynę stanów, a jak biznes to i baza musi być 😄

W przypadku stanu w postaci wyliczeniowej to oczywiste bo to zwykłe mapowanie.

Jak mamy stan jako typy to przy persystencji po prostu i tak to mapujecie na jakiś typ wyliczeniowy i przy odczycie mapowanie w drugą stronę na konkretny typ?

somekind napisał(a):
  1. ze stanem w postaci wartości wyliczeniowej, ze słownikiem przejść i sporą liczbą walidacji (czy przejście ze stanu A do B jest możliwe, czy na stanie A można wywoływać metodę DoX())

No ale od tej walidacji czy w danym stanie można zrobić DoX() to chyba i tak na którymś etapie nie uciekniesz nawet w przypadku zamodelowania jako typy?
Gdzieś wypadałoby zwalidować że w stanie Anulowany nie można Zaakceptować i zwrócić jakiś komunikat.

Chyba że każda operacja na stanie zwróci jakiś Result z informacją czy się udało czy nie.

0

ja bym te stany zrobiła jako typ wyliczeniowy, i jakąś funkcję do sprawdzania czy ze stanu A można przejść do B i tyle, no ale ja truskawki cukrem

0
Klaun napisał(a):

Jak mamy stan jako typy to przy persystencji po prostu i tak to mapujecie na jakiś typ wyliczeniowy i przy odczycie mapowanie w drugą stronę na konkretny typ?

To się niespecjalnie różni od mapowania dowolnej hierarchii klas, czyli masz trzy opcje:

  1. Table per Hierarchy - jedna tabela na wszystkie klasy;
  2. Table per Class - każda klasa ma swoją własną tabelę;
  3. Table per Type - jedna tabela na wspólne wartości (te z klasy bazowej) + oddzielne tabele dla każdej konkretnej klasy.

Jak widać, typ wyliczeniowy jest potrzebny tylko przy pierwszym podejściu. I ja bym to podejście wybrał, bo to, że jakieś dane były potrzebne na poprzednim etapie przetwarzania, nie oznacza, że nie przydadzą się w przyszłości. Do tego łatwiej też o zapis danych po zmianie stanu - będzie to prosty update jednego rekordu, zamiast przerzucania danych z tabeli "poprzedniej" do "następnej". Do tego jestem przyzwyczajony do baz dokumentowych.
Ale nie wykluczam, że istnieją sytuacje, w których lepiej sprawdzą się pozostałe opcje.

No ale od tej walidacji czy w danym stanie można zrobić DoX() to chyba i tak na którymś etapie nie uciekniesz nawet w przypadku zamodelowania jako typy?
Gdzieś wypadałoby zwalidować że w stanie Anulowany nie można Zaakceptować i zwrócić jakiś komunikat.

Nie, po prostu klasa DokumentAnulowany nie będzie miała metody Akceptuj. Jak ktoś spróbuje wywołać metodę, której nie ma, to kompilator mu powie, żeby się ogarnął. Kompilator waliduje najlepiej.

Chyba że każda operacja na stanie zwróci jakiś Result z informacją czy się udało czy nie.

No to nie zaszkodzi niezależnie od sposobu modelowania maszyny stanów.

Miang napisał(a):

ja bym te stany zrobiła jako typ wyliczeniowy, i jakąś funkcję do sprawdzania czy ze stanu A można przejść do B i tyle, no ale ja truskawki cukrem

No i dla trzydziestu stanów potrzebujemy:

  • 31 linijek definicji enuma;
  • 1 na pole w klasie;
  • 436 na funkcję walidującą;
  • no i zakładając testy, które mają po 5 linijek * dajmy na to 5 metod obiektu do przetestowania (raczej będzie tego znacznie więcej) * 435 kombinacji par stanów, które dostarczamy w parametrach, to daje nam 10875 linijek testów.

I tym sposobem w nastu tysiącach linijek kodu nie osiągamy zupełnie niczego poza marną namiastką tego, co może zrobić kompilator - za to w pełen błędów sposób.
No, jeśli ktoś ma płacone od linijki i chce mieć gwarancję zatrudnienia do emerytury, to nieironicznie jest to dobry plan.

0

Jeżeli mowa o stanie w rozumieniu FSM, to możliwości są 2 jak napisał @somekind

  1. Zapisać wprost state==on gdzie on przechowujemy jedynie stan aktualny.
  2. Event sourcing czyli currentState == beginState.applyEvents(events[]) gdzie lista events jest zapisana w jakiejś tabelce.

Co do złożoności kodu, przejścia pomiędzy stanami są zdefiniowane. Właściwie główną zaletą FSM jest to, że można się poruszać jedynie wcześniej zdefiniowanymi ścieżkami grafu. Czyli faktycznie trzeba będzie mieć wartość enum dla każdego ze stanów, ale przy 30 różnych stanach zwykle nie potrzeba 435 linii kodu , a kilkadziesiąt opisujących jedynie przejścia możliwe biznesowo. Jeżeli po grafie stanów można się poruszać z każdego stanu do każdego innego, to stosowanie FSM nie ma uzasadnienia. Testy - wiadomo trzeba napisać, ale nie bardzo widzę nadmiarowość. Jeżeli np. jakiś proces biznesowy definiuje 30 stanów z 10 możliwymi eventami i 60 możliwymi przejściami, to tak czy inaczej tych testów będzie trochę ze względu na złożoność biznesową.

0
somekind napisał(a):

No ale od tej walidacji czy w danym stanie można zrobić DoX() to chyba i tak na którymś etapie nie uciekniesz nawet w przypadku zamodelowania jako typy?
Gdzieś wypadałoby zwalidować że w stanie Anulowany nie można Zaakceptować i zwrócić jakiś komunikat.

Nie, po prostu klasa DokumentAnulowany nie będzie miała metody Akceptuj. Jak ktoś spróbuje wywołać metodę, której nie ma, to kompilator mu powie, żeby się ogarnął. Kompilator waliduje najlepiej.

Ok, chyba wiem do czego dążysz.
Tylko w takim podejściu jak rozumiem już od poziomu endpointu HTTP wiesz że działasz w kontekście dokumentu anulowanego (czyli operacje na API zamodelowane są trochę bardziej specyficznie niż po prostu /api/documents/cancel).
Odczytujesz przez jakieś repo ten DokumentAnulowany i albo on nie istnieje, albo istnieje i masz na nim możliwe do wykonania tylko konkretne akcje zdefiniowane w obrębie reprezentacji tego stanu.

Ja zastanawiając się nad teoretyczną implementacją wpadłem wcześniej na taki artykuł -> https://nurkiewicz.com/2009/09/state-pattern-introducing-domain-driven.html
Gdzie masz encję Reservation, a ona w środku trzyma ReservationStatus i to implementacje tego stanu (statusu) (New, Accepted, Canceled) walidują czy można wykonać konkretną akcję.

1
piotrpo napisał(a):

Jeżeli mowa o stanie w rozumieniu FSM, to możliwości są 2 jak napisał @somekind

  1. Zapisać wprost state==on gdzie on przechowujemy jedynie stan aktualny.
  2. Event sourcing czyli currentState == beginState.applyEvents(events[]) gdzie lista events jest zapisana w jakiejś tabelce.

No Event sourcing to jest 3 możliwość, ale znacznie bardziej skomplikowana, więc nawet jej tu nie sugerowałem.

przy 30 różnych stanach zwykle nie potrzeba 435 linii kodu , a kilkadziesiąt opisujących jedynie przejścia możliwe biznesowo.

No ok - przesadziłem z pesymistyczną estymacją.

Testy - wiadomo trzeba napisać, ale nie bardzo widzę nadmiarowość. Jeżeli np. jakiś proces biznesowy definiuje 30 stanów z 10 możliwymi eventami i 60 możliwymi przejściami, to tak czy inaczej tych testów będzie trochę ze względu na złożoność biznesową.

Ale o to chodzi, żeby mieć testy do procesów biznesowych, a nie infrastruktury, którą można zastąpić kompilatorem.

Klaun napisał(a):

Ok, chyba wiem do czego dążysz.
Tylko w takim podejściu jak rozumiem już od poziomu endpointu HTTP wiesz że działasz w kontekście dokumentu anulowanego (czyli operacje na API zamodelowane są trochę bardziej specyficznie niż po prostu /api/documents/cancel).

Niby czemu? Tam oczywiście brakuje id, ale to wszystko, co trzeba przekazać do endpointu.

Odczytujesz przez jakieś repo ten DokumentAnulowany i albo on nie istnieje, albo istnieje i masz na nim możliwe do wykonania tylko konkretne akcje zdefiniowane w obrębie reprezentacji tego stanu.

Jeśli chcę anulować, to odczytuję DokumentPrzetwarzany. Anulowanego nie ma po co znowu anulować. ;)

Ja zastanawiając się nad teoretyczną implementacją wpadłem wcześniej na taki artykuł -> https://nurkiewicz.com/2009/09/state-pattern-introducing-domain-driven.html
Gdzie masz encję Reservation, a ona w środku trzyma ReservationStatus i to implementacje tego stanu (statusu) (New, Accepted, Canceled) walidują czy można wykonać konkretną akcję.

No ja się nie znam na tych javowych enumach na sterydach. Piszę w C#, więc stosuję znane mi rozwiązania.

0
somekind napisał(a):

Niby czemu? Tam oczywiście brakuje id, ale to wszystko, co trzeba przekazać do endpointu.

Jeśli chcę anulować, to odczytuję DokumentPrzetwarzany. Anulowanego nie ma po co znowu anulować. ;)

W ogólności masz rację.
Ale skąd wiesz jaki typ odczytać jeśli anulować można w kilku stanach? No załóżmy że autor dokumentu może go anulować zanim wyśle do przetwarzania (NowyDokument), ale i w trakcie przetwarzania (DokumentPrzetwarzany) 😉

No ja się nie znam na tych javowych enumach na sterydach. Piszę w C#, więc stosuję znane mi rozwiązania.

Ale to że tam są enumy to sprawa wtórna, bo równie dobrze enuma możesz tam zamienić na klasę.

Tam po prostu jest zaprezentowane inne podejście gdzie to nie cały typ obiektu określa jego stan, tylko obiekt typu Dokument miałby w sobie obiekt reprezentujący aktualny stan i wykonujący przejścia pomiędzy stanami i związane z tym akcje.

To w sumie wydaje się implementacją patternu State zaprezentowaną przez Gang of Four:

screenshot-20241219083724.png

Przy czym w praktyce wydaje mi się wygodniejsze podejście o którym ty piszesz.

0

Kompletnie mi się nie spina pomysł z klasa per state. Jeżeli mam dokument, to jest on samodzielną porcją informacji. Jeżeli tym dokumentem jest wniosek urlopowy, to przecież przy zmianie stanu nie zmienia się nic poza stanem. Dane, czyli kto składa, w jakim terminie, z jakiego powodu pozostają takie same. Zmianie podlega jedynie wartość jednego pola pomiędzy złożony, zatwierdzony, odrzucony, anulowany.
Jeżeli przechowywanie historii zmian nie jest istotne, to wystarczy jedno pole. Jeżeli historia zmian stanu ma znaczenie, to nie przechowujemy stanu, a akcje.

Kiedyś popełniłem coś takiego:
https://github.com/piotrpo/SimpleFsm

Nie bijcie za darmo, młody byłem.

0
piotrpo napisał(a):

Kompletnie mi się nie spina pomysł z klasa per state. Jeżeli mam dokument, to jest on samodzielną porcją informacji. Jeżeli tym dokumentem jest wniosek urlopowy, to przecież przy zmianie stanu

No mi się nie spina po przemyśleniu pomysł ze klasa ma być dokładnym odwzorowaniem jakiegoś abstrakcyjnego bytu o nazwie dokument zamiast odwzorowaniem kawałka tegoż bytu ułatwiającym jego przetwarzanie w systemie. Przecież rekord w bazie danych dla wszystkich stanów zostanie ten sam, i tam jest miejsce na oznaczenie stanu i dopisanie dodatkowych kawałków w tabelach powiązanych po kluczu.
A w programie w pamięci operujemy akurat tym i tylko tym kawałkiem który będzie potrzebny do przetwarzania. Wiec równie dobrze mogą być klasy oznaczające "dokument identyfikujący się jako zatwierdzony" mające inne właściwości i inne możliwe do wykonania funkcje.

1
Klaun napisał(a):

Ale skąd wiesz jaki typ odczytać jeśli anulować można w kilku stanach? No załóżmy że autor dokumentu może go anulować zanim wyśle do przetwarzania (NowyDokument), ale i w trakcie przetwarzania (DokumentPrzetwarzany) 😉

To w takim razie po wywołaniu endpointu /api/documents/cancel/{id} wczytam obiekt klasy DokumentDoAnulowania. W warstwie danych nie będzie on miał bezpośredniego odpowiednika, ale będzie się go dało utworzyć zarówno z danych prawidłowych dla NowyDokument, jak i DokumentPrzetwarzany. I będzie miał dostępną jedną metodę - Anuluj.

Przy czym w praktyce wydaje mi się wygodniejsze podejście o którym ty piszesz.

No bo u mnie, nie możesz wywołać metody, która nie ma sensu w danym punkcie czasu, bo jej po prostu nie ma.
W przypadku wzorca stan, zawsze masz wszystkie metody - po prostu część z nich nie zadziała, bo będzie pusta.

piotrpo napisał(a):

Kompletnie mi się nie spina pomysł z klasa per state. Jeżeli mam dokument, to jest on samodzielną porcją informacji. Jeżeli tym dokumentem jest wniosek urlopowy, to przecież przy zmianie stanu nie zmienia się nic poza stanem. Dane, czyli kto składa, w jakim terminie, z jakiego powodu pozostają takie same. Zmianie podlega jedynie wartość jednego pola pomiędzy złożony, zatwierdzony, odrzucony, anulowany.
Jeżeli przechowywanie historii zmian nie jest istotne, to wystarczy jedno pole. Jeżeli historia zmian stanu ma znaczenie, to nie przechowujemy stanu, a akcje.

No i w przypadku wniosku urlopowego, który ma tylko 4 stany, to pewnie prawda, ale są przypadki, w których zakończenie kolejnego etapu powoduje pojawienie się nowych danych.
Poza tym, to problemem na ogół nie są dane, tylko zarządzanie przechodzeniem po grafie stanów oraz weryfikacja legalności operacji dla danego stanu.
Co do komplikacji, to liczba kombinacji rośnie mocno nieliniowo, dla 20 stanów masz ich 20 razy więcej niż dla 4.

Miang napisał(a):

No mi się nie spina po przemyśleniu pomysł ze klasa ma być dokładnym odwzorowaniem jakiegoś abstrakcyjnego bytu o nazwie dokument zamiast odwzorowaniem kawałka tegoż bytu ułatwiającym jego przetwarzanie w systemie. Przecież rekord w bazie danych dla wszystkich stanów zostanie ten sam, i tam jest miejsce na oznaczenie stanu i dopisanie dodatkowych kawałków w tabelach powiązanych po kluczu.
A w programie w pamięci operujemy akurat tym i tylko tym kawałkiem który będzie potrzebny do przetwarzania. Wiec równie dobrze mogą być klasy oznaczające "dokument identyfikujący się jako zatwierdzony" mające inne właściwości i inne możliwe do wykonania funkcje.

Dokładnie o to chodzi. Nikt nie mówi, ze klasa ma reprezentować fizyczną kartkę papieru, równie dobrze klasa może reprezentować etap jakiegoś procesu.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.