Jak sensownie zaimplementować maszynę stanów?

0
somekind napisał(a):

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.

Na początek złożoność maszyny stanów. Jeżeli masz 30 możliwych stanów i 400 możliwych przejść pomiędzy tymi stanami, wynika to z wymagań biznesowych, to tak czy inaczej jakoś tę złożoność musisz zakodować. Możesz to zrobić albo za pomocą 30 klas reprezentujących stany, w których znajdzie się łącznie 400 metod, albo pojedynczej klasy reprezentującej stateful object (np. ten wniosek urlopowy), 30 wartości enuma + 400 definicji [initial state, event, result state]. Kodu mniej - więcej tyle samo.

Maksymalnie prosty diagram stanów jakiegoś wyłącznika:
screenshot-20241220085024.png
Przy tworzeniu klasy per stan miałbyś coś takiego:

open abstract class Switch(val SwitchId){
  // nie wiemy, czy click będzie w każdym możliwym stanie, nie można wyciągnąć ten metody do klasy nadrzędnej
}

class SwitchOn: Switch{
  clickOff(){
    return SwitchOff()
  }
}

class SwitchOff: Switch{
  clickOn(){
    return SwitchOn()
  }
}

Potrzebujesz do tego jakiejś serializacji i deserializacji:

fun saveSwitch(switch: Switch){...}
fun findSwitch(id: SwitchId): Switch {...}

I masz pierwszy problem już na poziomie interface'u bazy danych:

fun userClickedSwith(switchId: SwitchId){
  val switch = findSwitch(switchId)

  if(switch is SwitchOn) switch.clickOff
  if(switch is SwitchOff) switch.clickOff

  saveSwitch(switch)
}

Na warstwie persystencji też nie jest łatwo, bo zakładając tabela na klasę, musisz przeszukać 2 tabele zamiast jednej.

0

@piotrpo: No na poziomie mapowania do bazy trochę zabawy będzie.
Tylko tam @somekind pisał o podejściu TPH, gdzie nie masz oddzielnych tabel na reprezentacje różnych stanów, tylko siedzą one w jednej tabeli z dyskryminatorem typu.

Trochę poruszasz to o czym pisałem wcześniej na przykładzie wniosku urlopowego:

  • wniosek można anulować w stanie Nowy i Przetwarzany (tak jak u ciebie stan przełącznika można zmienić w stanie On i Off)
  • przy akcji anulowania (zmiany stany przełącznika) musisz teraz w taki czy inny sposób pobrać reprezentację odpowiedniego obiektu z bazy
0

Jeżeli patrzymy na to na konkretnym przykładzie wniosku urlopowego w dodatku od d.. strony, czyli bazy danych, to najprościej jest użyć jakiejś dokumentowej bazy danych, w której przechowujesz sobie dane tego obiektu (kto wnioskuje, na kiedy), oraz historię eventów zmieniających stan. Jak potrzebujesz ten stan odczytać, to przepuszczasz eventy przez zdefiniowaną FSM i masz stan wynikowy.

Po stronie logiki niby można mieć osobną klasę dla każdego stanu, tylko struktura danych będzie ta sama, a różnić się będą jedynie dostępnymi metodami określającymi możliwe eventy, zwracające nowy obiekt, w stanie wynikowym.

Moim zdaniem takie podejście robi mocno pod górkę, chociaż oczywiście jest obiektowe. Wady tego rozwiązania, to moim zdaniem:
Konieczność sprawdzania typu obiektu i w zależności od wyniku wywoływanie jakiejś metody.
Odczytywanie stanu (np. złożony, zatwierdzony, anulowany) z typu obiektu. Albo będzie tu jakaś metoda stateName():String, albo maper jadący po refleksji.
Cała definicja FSM musi być w kodzie, a wiadomo, że w przypadku procesu biznesowego będzie się zmieniać. Biznes albo zatrudni jakąś nową Anetkę w HR i trzeba będzie wprowadzić nowy stan, żeby miała co robić, albo chociaż zmieni nazwę jakiegoś stanu, żeby sprawiać wrażenie progresu. Przy mapowaniu procesu biznesowego na FSM idealnie by było wyciągnąć FSM z kodu do konfiguracji aplikacji.
Można zrobić to np. tak:

class State<P>(val payload:P, val fsmDefinition:FSM var state:State){
  fun applyEvent(event Event){
    state = fsmDefinition.stateAfterEvent(state, event)
  }
}

Payload odpowiada za dane, więc mamy czystą implementację FSM, niezależną od danych.

Do tego mamy prostą implementację maszyny stanów:


class Transition(val entryState:State, event: Event, resultState)

class FSM(val transitions:Transition[]){
  applyuEvent(state: State, event: Event):State{
    transitions.filter(it.entryState == state).filter(it.event = event).map(it.resultState)
  }
}

Oczywiście warto zrobić jakąś walidację samej FSM, czy np. nie ma tam niejednoznacznych/sprzecznych definicji.
Zalety takiego podejścia:
Cała definicja diagramu stanów jest w jednym, no może 3 miejscach (lista stanów, lista zdarzeń, lista dopuszczalnych tranzycji)
Jeżeli chcemy, to State, Event nie muszą być wartościami enum'a, tylko np. Stringiem -> możemy przechowywać te 3 zbiory definiujące FSM w konfiguracji aplikacji, czy tam w bazie danych. Wiadomo, ma to swoje minusy, ale w niektórych zastosowaniach, gdzie proces zmienia się często jest to ogromna zaleta.
Możemy bardzo łatwo zastosować event sourcing, w każdym razie w zakresie FSM, bo wiadomo - payload może się też zmieniać.
W kodzie też moim zdaniem czyściej, bo dziedziczące po sobie klasy danych to zło w czystej postaci.
Na poziomie bazy - jeżeli już się upieramy, że muszą być relację, to tak, payload będzie przechowywany w jednej dużej tabeli, w której część pól będzie null.

0
piotrpo napisał(a):

Jeżeli patrzymy na to na konkretnym przykładzie wniosku urlopowego w dodatku od d.. strony, czyli bazy danych, to najprościej jest użyć jakiejś dokumentowej bazy danych, w której przechowujesz sobie dane tego obiektu (kto wnioskuje, na kiedy), oraz historię eventów zmieniających stan. Jak potrzebujesz ten stan odczytać, to przepuszczasz eventy przez zdefiniowaną FSM i masz stan wynikowy.

Czemu od d**y strony?

Cały wątek zanim go reaktywowałem skończył się na tym, że albo definiujemy maszynę stanów jako oddzielne obiekty reprezentujące stany mające tylko dostępne metody, albo jako typ wyliczeniowy ze słownikiem przejść.

Więc jak już mamy ustalone z grubsza możliwości implementacji to się po prostu zastanawiałem jak teraz to przechowywać w bazce w obu podejściach.

A najprościej jak dla mnie to jednak typ wyliczeniowy nawet bez event sourcingu. Jak trzeba historię przechować to też można to ogarnąć prostym wpisem audytowym do bazki przy zmienianiu stanu.
Nie mówię że najlepiej, tylko najprościej.

Moim zdaniem takie podejście robi mocno pod górkę, chociaż oczywiście jest obiektowe. Wady tego rozwiązania, to moim zdaniem:
Konieczność sprawdzania typu obiektu i w zależności od wyniku wywoływanie jakiejś metody.

Ale przecież ze 3 czy 4 komentarze wyżej pośrednio do tego nawiązywałem i doszliśmy, że nie jest to w ogólności potrzebne.
Jak wywołuję cancel to pobieram z jakiegoś repo obiekt DokumentDoAnulowania.
Jak takiego obiektu repo nie zwróci, bo w bazce nie ma rekordu Dokument ze stanem Nowy albo Przetwarzany to znaczy że nie da się anulować. Jak dostałem z repo obiekt DokumentDoAnulowania to znaczy że mogę na nim wywołać akcję Anuluj, która coś tam poweryfikuje i albo anuluje albo nie.

I o ile zgadzam się, że to się też pokomplikuje jak daną akcję można wykonywać w kilku stanach (sama logika wyciągania danych i może później z powrotem zmapowanie tego na model bazodanowy), to wciąż konieczności sprawdzania typu tutaj nie ma.

Odczytywanie stanu (np. złożony, zatwierdzony, anulowany) z typu obiektu. Albo będzie tu jakaś metoda stateName():String, albo maper jadący po refleksji.

Z podejściem opisanym wyżej to chyba nie jest potrzebne?

Cała definicja FSM musi być w kodzie, a wiadomo, że w przypadku procesu biznesowego będzie się zmieniać. Biznes albo zatrudni jakąś nową Anetkę w HR i trzeba będzie wprowadzić nowy stan, żeby miała co robić, albo chociaż zmieni nazwę jakiegoś stanu, żeby sprawiać wrażenie progresu. Przy mapowaniu procesu biznesowego na FSM idealnie by było wyciągnąć FSM z kodu do konfiguracji aplikacji.

Jak ktoś buduje konfigurowalny silnik do obsługi procesów biznesowych to pewnie tak, lepiej to wydzielić do konfiguracji czy zaciągać definicję procesu z zewnątrz. Wtedy podejście z klasą per stan się nie sprawdzi.
Ale nie przesadzałbym że wszędzie wymagane jest budowanie własnego silnika ;)
Czasami chcesz zamodelować kilka procesów na krzyż i zmiana w kodzie w przypadku zmiany procesu nie będzie wcale taka zła.

Zalety takiego podejścia:
Cała definicja diagramu stanów jest w jednym, no może 3 miejscach (lista stanów, lista zdarzeń, lista dopuszczalnych tranzycji)
Jeżeli chcemy, to State, Event nie muszą być wartościami enum'a, tylko np. Stringiem -> możemy przechowywać te 3 zbiory definiujące FSM w konfiguracji aplikacji, czy tam w bazie danych. Wiadomo, ma to swoje minusy, ale w niektórych zastosowaniach, gdzie proces zmienia się często jest to ogromna zaleta.
Możemy bardzo łatwo zastosować event sourcing, w każdym razie w zakresie FSM, bo wiadomo - payload może się też zmieniać.
W kodzie też moim zdaniem czyściej, bo dziedziczące po sobie klasy danych to zło w czystej postaci.
Na poziomie bazy - jeżeli już się upieramy, że muszą być relację, to tak, payload będzie przechowywany w jednej dużej tabeli, w której część pól będzie null.

Spoko, widzę w tym zalety i nie mówię, że zawsze trzeba robić albo tak, albo tak.

Co prawda nie wiem o jakim dziedziczeniu klas danych tutaj mówisz, bo chyba w opisanym podejściu z typem per stan nie jest to wcale potrzebne. Po prostu klasa reprezentująca dany stan udostępnia dane przydatne w tym stanie.

0
Klaun napisał(a):
piotrpo napisał(a):

Jeżeli patrzymy na to na konkretnym przykładzie wniosku urlopowego w dodatku od d.. strony, czyli bazy danych, to najprościej jest użyć jakiejś dokumentowej bazy danych, w której przechowujesz sobie dane tego obiektu (kto wnioskuje, na kiedy), oraz historię eventów zmieniających stan. Jak potrzebujesz ten stan odczytać, to przepuszczasz eventy przez zdefiniowaną FSM i masz stan wynikowy.

Czemu od d**y strony?
Cały wątek zanim go reaktywowałem skończył się na tym, że albo definiujemy maszynę stanów jako oddzielne obiekty reprezentujące stany mające tylko dostępne metody, albo jako typ wyliczeniowy ze słownikiem przejść.

Więc jak już mamy ustalone z grubsza możliwości implementacji to się po prostu zastanawiałem jak teraz to przechowywać w bazce w obu podejściach.

W drugim przypadku jako typ wyliczany (czyli jakiś varchar w RDBMS). Pierwszy sposób z wieloma typami - nie mam pojęcia.

A najprościej jak dla mnie to jednak typ wyliczeniowy nawet bez event sourcingu. Jak trzeba historię przechować to też można to ogarnąć prostym wpisem audytowym do bazki przy zmienianiu stanu.
Nie mówię że najlepiej, tylko najprościej.

Czy najlepiej, to definiuje problem biznesowy. Jak nie potrzebujesz historii, to nie koniecznie jest sens ją trzymać. Chyba, że są względy techniczne (np. asychronicznie wpadające eventy)

Jak wywołuję cancel to pobieram z jakiegoś repo obiekt DokumentDoAnulowania.
Jak takiego obiektu repo nie zwróci, bo w bazce nie ma rekordu Dokument ze stanem Nowy albo Przetwarzany to znaczy że nie da się anulować. Jak dostałem z repo obiekt DokumentDoAnulowania to znaczy że mogę na nim wywołać akcję Anuluj, która coś tam poweryfikuje i albo anuluje albo nie.

Idea jest taka, że na każdym stanie możesz wykonać każdą akcję. Zwyczajnie tam gdzie jest ona nielegalna nic się nie stanie. Jak stare spłuczki w kibelkach. Możesz sobie ciągnąć za sznurek dowoli, ale woda poleci dopiero jak rezerwuar się napełni.

I o ile zgadzam się, że to się też pokomplikuje jak daną akcję można wykonywać w kilku stanach (sama logika wyciągania danych i może później z powrotem zmapowanie tego na model bazodanowy), to wciąż konieczności sprawdzania typu tutaj nie ma.

Pobierasz wszystkie wnioski urlopowe z bazy. Masz kolekcję obiektów różnych typów, Zanim wywołasz na jakimś metodę cancel, musisz wiedzieć, że ta metoda tam jest.

Co prawda nie wiem o jakim dziedziczeniu klas danych tutaj mówisz, bo chyba w opisanym podejściu z typem per stan nie jest to wcale potrzebne. Po prostu klasa reprezentująca dany stan udostępnia dane przydatne w tym stanie.

Jeżeli masz podejście z klasami, to ja widzę je jako:

Wniosek()
WniosekZłożony():Wniosek
WniosekZatwierdzony():Wniosek
...
0

Wy robicie ćwiczenie do szkoły z maszyny stanów, czy próbujecie ją implementować bo się wydaje dobrym rozwiązaniem przy znanych założeniach danego problemu biznesowego?
Bo sorry ale te rozważania wyglądają jakby byście mówili o ćwiczeniu typu sztuka dla sztuki

piotrpo napisał(a):

Jeżeli patrzymy na to na konkretnym przykładzie wniosku urlopowego w dodatku od d.. strony, czyli bazy danych, to najprościej jest użyć jakiejś dokumentowej bazy danych, w której przechowujesz sobie dane tego obiektu (kto wnioskuje, na kiedy), oraz historię eventów zmieniających stan. Jak potrzebujesz ten stan odczytać, to przepuszczasz eventy przez zdefiniowaną FSM i masz stan wynikowy.

proszenie się o kłopoty
w ogóle pytanie czy te eventy są potrzebne zawsze czy w jakimś wyjątkowym przypadku gdy potrzebuje znaleźć historię, a tak normalnie zazwyczaj bierzesz pod uwagę: nagłówek dokumentu, stan

Po stronie logiki niby można mieć osobną klasę dla każdego stanu, tylko struktura danych będzie ta sama, a różnić się będą jedynie dostępnymi metodami określającymi możliwe eventy, zwracające nowy obiekt, w stanie wynikowym.

dziedziczenie. metody czysto wirtualne (mówię o c++).

Moim zdaniem takie podejście robi mocno pod górkę, chociaż oczywiście jest obiektowe. Wady tego rozwiązania, to moim zdaniem:

a obiektowe musi być bo? mówię że robicie se ćwiczenie ;)

Konieczność sprawdzania typu obiektu i w zależności od wyniku wywoływanie jakiejś metody.
Odczytywanie stanu (np. złożony, zatwierdzony, anulowany) z typu obiektu. Albo będzie tu jakaś metoda stateName():String, albo maper jadący po refleksji.
Cała definicja FSM musi być w kodzie, a wiadomo, że w przypadku procesu biznesowego będzie się zmieniać. Biznes albo zatrudni jakąś nową Anetkę w HR i trzeba będzie wprowadzić nowy stan, żeby miała co robić, albo chociaż zmieni nazwę jakiegoś stanu, żeby sprawiać wrażenie progresu. Przy mapowaniu procesu biznesowego na FSM idealnie by było wyciągnąć FSM z kodu do konfiguracji aplikacji.

jprd lepsze niż pomysły dyrektorka z EAV. Spoko Anetka zwaliduje czy nowy diagram przejścia ma sens i przyjmie na klatę to że rozpieprzyła cały proces i działanie systemu przez swój błąd. Co może pójść nie tak?
A istniejące dokumenty skoro będą w historii miały nielegalne przejścia najlepiej wywalić automatycznie (ironia)

0

@Miang Nie eventy nie są potrzebne. Chyba, że implementujesz FSM, to wtedy bez nich ciężko.
Metody wirtualne - jaka to niby ma być metoda wirtualna skoro z założenia klasy dziedziczące implementują różne zestawy metod?
Obiektowe nie musi być. Moim zdaniem nie powinno.
Anetka z HR nie zwaliduje diagramu stanów. Ale zdecydowanie wpadnie na pomysł, że coś tam trzeba jeszcze dodać do procesu.

0
piotrpo napisał(a):

Idea jest taka, że na każdym stanie możesz wykonać każdą akcję. Zwyczajnie tam gdzie jest ona nielegalna nic się nie stanie. Jak stare spłuczki w kibelkach. Możesz sobie ciągnąć za sznurek dowoli, ale woda poleci dopiero jak rezerwuar się napełni.

W sumie to nie wiem czy taka jest idea.
Jak dla mnie koncepcyjnie w danym stanie powinna być możliwa do wykonania tylko akcja na którą stan zezwala.

Czy to zrealizujemy przez system typów czy walidacje na jednym typie to trochę sprawa wtórna.

Pobierasz wszystkie wnioski urlopowe z bazy. Masz kolekcję obiektów różnych typów, Zanim wywołasz na jakimś metodę cancel, musisz wiedzieć, że ta metoda tam jest.

A dlaczego w warstwie dostępu do danych nie pobrać wniosku o danym id do jakiejkolwiek struktury, a później na jej podstawie albo stworzyć DokumentDoAnulowania, jeśli wniosek jest w stanie dozwolonym do anulowania albo zwrócić pusty rezultat (co by oznaczało że nie da się anulować danego wniosku)?

Nie trzeba wtedy sprawdzać typu obiektu.

Jeżeli masz podejście z klasami, to ja widzę je jako:

Wniosek()
WniosekZłożony():Wniosek
WniosekZatwierdzony():Wniosek
...

Tak może być, ale nie musi.
Przecież mogę mieć niezależne definicje typów gdzie każda udostępnia tylko dane mające sens w danym stanie i nie muszę tu mieć żadnego dziedziczenia.

0
Miang napisał(a):

Po stronie logiki niby można mieć osobną klasę dla każdego stanu, tylko struktura danych będzie ta sama, a różnić się będą jedynie dostępnymi metodami określającymi możliwe eventy, zwracające nowy obiekt, w stanie wynikowym.

dziedziczenie. metody czysto wirtualne (mówię o c++).

no dobra, tu się pomyliłam, czysto wirtualne nie, tylko wirtualne z implementacja w klasie podstawowej co wyjątek rzucają, a nadpisane jedynie w tych pochodnych gdzie maja być dostępne

0

W sumie to nie wiem czy taka jest idea.
Jak dla mnie koncepcyjnie w danym stanie powinna być możliwa do wykonania tylko akcja na którą stan zezwala.

Nikt ci nie zabrania zaimplementowania tego na poziomie walidacji. Prawdopodobnie użytkownik będzie zadowolony, że nie klika guzika "zatwierdź", który nic nie zmienia. Koncepcja polega na tym, że masz jakiś obiekt z dyskretnym stanem, jeżeli stanie się coś z zamkniętego katalogu zdarzeń, to ten stan zostanie zmieniony na wskazany. W każdym innym wypadku stan nie ulegnie modyfikacji.

Nie trzeba wtedy sprawdzać typu obiektu.

Masz metodę dostępu do danych:

fun findWniosek(id: Int):Wniosek

Po jej wywołaniu dostaniesz wniosek z jakimś stanem. Funkcja może zwrócić dowolny typ z dziedziczących. Żeby wywołać na nim jakąś metodę (zdarzenie), musisz wiedzieć, że ta metoda tam jest, czyli musisz sprawdzić typ szczegółowy, rzutować na niego to co zwróciła funkcja.
Alternatywą jest, że wewnątrz Wniosek masz jakieś pole określające stan i np. zapakujesz go w jakiś wrapper.
Tak czy inaczej, dostajesz coś, musisz sprawdzić co to jest i dopiero wykonać, lub nie jakąś metodę.

Czyli:

A dlaczego w warstwie dostępu do danych nie pobrać wniosku o danym id do jakiejkolwiek struktury, a później na jej podstawie albo stworzyć DokumentDoAnulowania, jeśli wniosek jest w stanie dozwolonym do anulowania albo zwrócić pusty rezultat (co by oznaczało że nie da się anulować danego wniosku)?

Bo pobierając wniosek o zadanym id nie wiesz w jakim on jest stanie.

1
piotrpo napisał(a):

Nikt ci nie zabrania zaimplementowania tego na poziomie walidacji. Prawdopodobnie użytkownik będzie zadowolony, że nie klika guzika "zatwierdź", który nic nie zmienia. Koncepcja polega na tym, że masz jakiś obiekt z dyskretnym stanem, jeżeli stanie się coś z zamkniętego katalogu zdarzeń, to ten stan zostanie zmieniony na wskazany. W każdym innym wypadku stan nie ulegnie modyfikacji.

No przecież o tym piszę.
Koncepcja jest taka że w danych stanach można wykonać dane akcje, a innych nie
To jak to zostanie zaimplementowane nie ma znaczenia w ogólności. Ma w naszych rozważaniach bo o tym jest temat.

Wcześniej napisałeś Idea jest taka, że na każdym stanie możesz wykonać każdą akcję co zrozumiałem że przedstawiasz to jako ogólną ideę maszyny stanów.
A to tylko jeden z wielu sposobów implementacji.

Nie trzeba wtedy sprawdzać typu obiektu.

Masz metodę dostępu do danych:

fun findWniosek(id: Int):Wniosek

Po jej wywołaniu dostaniesz wniosek z jakimś stanem. Funkcja może zwrócić dowolny typ z dziedziczących. Żeby wywołać na nim jakąś metodę (zdarzenie), musisz wiedzieć, że ta metoda tam jest, czyli musisz sprawdzić typ szczegółowy, rzutować na niego to co zwróciła funkcja.
Alternatywą jest, że wewnątrz Wniosek masz jakieś pole określające stan i np. zapakujesz go w jakiś wrapper.
Tak czy inaczej, dostajesz coś, musisz sprawdzić co to jest i dopiero wykonać, lub nie jakąś metodę.

Czyli:

A dlaczego w warstwie dostępu do danych nie pobrać wniosku o danym id do jakiejkolwiek struktury, a później na jej podstawie albo stworzyć DokumentDoAnulowania, jeśli wniosek jest w stanie dozwolonym do anulowania albo zwrócić pusty rezultat (co by oznaczało że nie da się anulować danego wniosku)?

Bo pobierając wniosek o zadanym id nie wiesz w jakim on jest stanie.

Zakładasz że warstwa aplikacyjna/domenowa bazuje na ogólnym typie Wniosek co wymaga później sprawdzania typów w runtime.

Przecież warstwa dostępu do danych może zwrócić typ specyficzny dla kontekstu operacji.

Wtedy zamiast fun findWniosek(id: Int):Wniosek mogę mieć fun findWniosekDoAnulowania(id: Int):WniosekDoAnulowania.

I jakaś implementacja (pseudokod):

fun findWniosekDoAnulowania(id: Int):WniosekDoAnulowania` {
   val wniosekPersistenceModel = db.wnioski.where(it.id == id).where(it.status==nowy || it.status == przetwarzany).firstOrDefault();
   if (wniosekPersistenceModel is null) return null;

    return new WniosekDoAnulowania(wniosekPersistenceModel);
}

Nie widzę tutaj rzutowania czy innego sprawdzania typu w runtime.
Przy okazji doszliśmy do niezależności modelu domeny od modelu persystencji.

0

albo funkcja ZamianStanu(enum_stan nowy_stan) która wywoła funkcje prywatne zmieniające stan i zwróci true lub zwróci false

1

Zakładasz że warstwa aplikacyjna/domenowa bazuje na ogólnym typie Wniosek co wymaga później sprawdzania typów w runtime.

No prosty use case - manager wyświetla sobie listę wszystkich wniosków podległych mu pracowników. Te wnioski są w różnych stanach. Część jest złożonych, część zatwierdzonych itd. Klika jakiś wniosek do zatwierdzenia. Pobieranie jeszcze raz tego wniosku z bazy, ale tym razem jako klasę specyficzną dla jego stanu jest moim zdaniem mocno bez sensu.
Jeżeli chcesz mapować dane na klasę z metodami odpowiadającymi

Tak systematyzując te nasze poplątane dyskusje.
FSM jest dość dokładnie opisaną koncepcją. Nie ma obowiązku jej używania, nie zawsze warto jej używać, ale czasami się przydaje, bo pozwala formalnie opisać jakiś problem. Im bliżej implementacja będzie tego formalnego opisu, tym lepiej, bo mniej okazji do pomyłek, a jak jakaś się trafi to łatwiej ją znaleźć i poprawić.

Formalnie, FSM jest grafem, w którym węzłami są stany, przejścia są wyzwalane na podstawie akcji. Zupełnie tak jak w pierwszej klasie podstawówki:
screenshot-20241220204231.png

Do zapisania takiego stanu (co było twoim pytaniem) - moim zdaniem wszystko jedno. Można wejść na poziom bazy danych, tego jakie sposoby będą bardziej, a jakie mniej wydajne, ale uważam, że to mało istotne szczegóły.

Po stronie kodu można ten mechanizm implementować rozmaicie. Jednym ze sposobów jest użycie różnych (pod)typów dla rozróżnienia stanu. Jeżeli stan jest przechowywany w programie jako typ obiektu, to żeby go odczytać, trzeba odczytać ten typ.
Tutaj wchodzą moje fobie z Java. Niektóre inne języki mają to rozwiązane lepiej, ale w Java taki kod:

PrivateType object = findObject(.....)
if(object instance of WniosekDoZatwierdzenia){
  WniosekDoZatwierdzenia wniosek = (WniosekDoZatwierdzenia) object;
  wniosek.zatwierdz();
}

Podpali kompilator w linii z rzutowaniem i zacznie latać uchnecked cast warning. Może nie tragedia, ale nie lubię.

Załóżmy, że chcesz dodać menu kontekstowe z możliwymi akcjami. W jaki sposób to zrobisz? Refleksja i sprawdzanie czy jakaś metoda istnieje? Można, ale sporo energii psu w d....ę.

Teraz "mój" sposób, czyli tworzymy sobie listy stanów, zdarzeń, tranzycji. Można to zakodować 1:1 zgodnie z diagramem w parę minut. Tworzymy na tej podstawie jakąś instancję FSM. Można ją walidować (czy nie ma osieroconych stanów, stanów bez wyjścia, niejednoznacznych tranzycji itd.). Rzeczywisty problem tutaj, to walidacja w runtime a nie compile time. Zgadzam się, traktuję to jako cenę. Z drugiej strony linijka testu wywołująca tę walidację rozwiązuje problem runtime, czyli nie jest tak tragicznie.

Natomiast później można sobie w tej instancji FSM zaimplementować takie cuda:
fsm.getLegalActionsForState(State state) i wiedzieć które guziki na UI zapalić
fsm.getResultState(initialState, action) - jak działąnie jest zdefiniowane, to dostajesz wynik, jak nie jest zdefiniowane, to go nie dostajesz.

Masz, a w każdym razie możesz mieć prawie całkowitą separację danych od całego tego cyrku z FSM. Prawie, bo jednak wartość stanu gdzieś trzeba przechować.
Możesz jeden "silnik" stosować z różnymi definicjami FSM. Czyli kilkadziesiąt linijek kodu za pierwszym razem, za drugim składasz 3 listy/enumy.

1

No i spoko.
Ja tylko pisałem że można mieć typ per stan i to w samo w sobie nie oznacza jeszcze konieczności rzutowania czy refleksji.

Co w jakim scenariuszu będzie lepsze zależy od sytuacji i nie ma co popadać w paranoję.

No prosty use case - manager wyświetla sobie listę wszystkich wniosków podległych mu pracowników. Te wnioski są w różnych stanach. Część jest złożonych, część zatwierdzonych itd. Klika jakiś wniosek do zatwierdzenia. Pobieranie jeszcze raz tego wniosku z bazy, ale tym razem jako klasę specyficzną dla jego stanu jest moim zdaniem mocno bez sensu.

W apce webowej to dokładnie tak bym zrobił.
Do wyświetlania by poleciał view model odczytany jakimś na tyle na ile to możliwe prostym SQLem i zmapowany bezpośrednio pomijając potencjalną złożoność modelu do zapisu.
Przy zapisie bym to przepychał przez modele domenowe/agregaty. Tak to się chyba z grubsza robi w CQRS?

0

CQRS to znowu zupełnie inny temat (albo nie potrafię znaleźć związku). Zakładając, że użycie tego wzorca jest zasadne, robi się to najczęściej po to, żeby odseparować manipulację danymi od ich odczytu (truizm), w celu nieograniczonego skalowania odczytu.
Oczywiście tak jak wspominałem wyżej, można te eventy strumieniować i rozgłaszać do iluś tam innych mikroserwisów, które na ich podstawie będą sobie lokalnie aktualizować stan. W praktyce dochodzi do tego jeszcze piminięta w tym wątku część danych (kto złożył wniosek, kto coś w nim uzupełnił), która też musiałaby być rozgłaszana.

0

A co wy budujecie? Jakiś taki praktyczny diagram by się przydał, jakie problemy ta maszyna stanów miałaby rozwiązać.
Np. ten DokumentAnulowany DokumentZatwierdzony itp. z czym to się łączy później? Czy to jest lokalny stan dokumentu, czy ma wpływ na resztę?
Ja szczerze mówiąc nie bardzo rozumiem już, czego ta dyskusja dotyczy (pod kątem jakiegoś przykładu/potrzeby).

Na początek złożoność maszyny stanów. Jeżeli masz 30 możliwych stanów i 400 możliwych przejść pomiędzy tymi stanami, wynika to z wymagań biznesowych

Albo nie wynika z wymagań biznesowych, a ze zwykłej duplikacji kodu czy ze złej abstrakcji.

0
piotrpo napisał(a):

Zakładasz że warstwa aplikacyjna/domenowa bazuje na ogólnym typie Wniosek co wymaga później sprawdzania typów w runtime.

No prosty use case - manager wyświetla sobie listę wszystkich wniosków podległych mu pracowników. Te wnioski są w różnych stanach. Część jest złożonych, część zatwierdzonych itd. Klika jakiś wniosek do zatwierdzenia. Pobieranie jeszcze raz tego wniosku z bazy, ale tym razem jako klasę specyficzną dla jego stanu jest moim zdaniem mocno bez sensu.

do pokazania listy powinna na front lecieć lista , taka tradycyjna select nawa stan from tabela a nie obiekty

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.