Wydrukuj

Maszyny stanów wszędzie. Redux nie tylko w technologiach webowych

Od dłuższego czasu pojęcie "maszyny stanów" kojarzy się wielu programistom z pokrytymi kurzem podręcznikami do teorii automatów, z których z fascynacją (i nieco zrezygnowaniem) wykuwaliśmy w szkole lub na studiach.

Jednak świat JavaScriptu – i nie tylko – potrafi udowodnić, że te stare, nudnawe koncepty mogą przybrać postać całkiem zgrabnych i praktycznych narzędzi.

Jednym z takich narzędzi jest Redux, który mimo iż wywodzi się ze świata frontendu, z powodzeniem może być wykorzystywany w znacznie szerszym kontekście.

W tym artykule przyjrzymy się Reduxowi jako maszynie stanów. Przejdziemy przez podstawy, obsługę efektów ubocznych i redux-observable. Potem porównamy podejścia i możliwości w innych technologiach, a na koniec pokażemy prostą implementację w Flutterze, bo czemu nie.

Redux jako maszyna stanów

Zacznijmy od absolutnych podstaw. Redux to nic innego jak zarządzanie stanem aplikacji przy pomocy reduktorów (reducers), akcji (actions) i jednego miejsca przechowywania danych (store). Dzięki temu uzyskujemy jedno źródło prawdy dla całej aplikacji.

W gruncie rzeczy mamy do czynienia z tzw. maszyną stanów skończonych (finite state machine). Każde działanie (akcja) wyprowadza nas ze stanu A do stanu B. Masz initial state, po którym otrzymujesz akcję „Włącz światło”, a Twój stan zmienia się na "włączone światło". Dostajesz akcję „Wyłącz światło – stan zmienia się z powrotem na "wyłączone światło". I tak w kółko. Zero magii, czysta logika przejść między stanami.

Przejdźmy do prostego przykładu, który prezentuje to co opowiedzieliśmy. Zacznijmy sobie od zwizualizowania sobie wszystkiego za pomocą diagramu:

Mamy tutaj dwa stany, lightTurnedOn=false oraz lightTurnedOn=true i dwie akcje TURN_ON oraz TURN_OFF. Zacznijmy w tym miejscu od zdefiniowania tych akcji w kodzie:

Dodatkowo zdefiniowałem typ Action, który wiąże obie akcje. Silne typowanie jest jedną z moich programistycznych dewiz (chociaż dziwnie to brzmi w kontekście nakładki na JavaScript).

Następnie możemy przejść do serca naszej maszyny, czyli reducera, który będzie nasłuchiwał na spływające akcje i aktualizował stan w store.

Na początku zdefiniowałem typ przechowywanego stanu w store, a następnie początkowy stan, którym będzie zainicjalizowany store. W kolejnym kroku umieszczona jest funkcja, która jest właściwym reducerem.

Dla tych co kiedyś napisali choć jedną maszynę stanów, jej wygląd może coś przypominać. Funkcja ta analizuje akcję, jaka przyszła do store, a następnie na tej podstawie zwraca nowy stan stosu, jaki ma zostać umieszczony w store. Należy jednak zaznaczyć, że reducer, musi być tak zwaną czystą funkcją (pure function).

Pure function, w skrócie to taka funkcja, która dla tych samych argumentów zawsze zwraca takie same wartości, oraz nie może powodować żadnych side effectów (modyfikować dane wejściowe, wywoływać dodatkowe zdarzenia itp.).

Teraz zostało nam skofigurować store, aby korzystał z napisanego przez nas reducera:

Możemy nasłuchiwać zmian stanów poprzez subskrybcję i wywołać kilka akcji:

Odpalamy nasz kod i uzyskujemy efekt zmian stanów pod wpływem wywołanych przez nas akcji. Nasza maszyna stanów zaczęła działać. Tyle pracy, żeby pomigać lampkami i to takimi wirtualnymi…

No dobra dwa stany, to każdy potrafi ogarnąć. Można więcej?

Zacytuję tutaj klasyka: „A można jak najbardziej, jeszcze jak!”. Przygotujmy sobie przykład, który będzie miał możliwe cztery stany, które będą nam zarządzać dynamicznie ładowanymi danymi:

Przekujmy zatem to w przykład, zaczynając od akcji:

Akcje, mogą przekazywać dodatkowe argumenty, do reducerów, dzięki temu możemy do tematu podejść trochę bardziej dynamicznie do rzeczy, które zapiszemy w store.

No to w tej chwili przejdźmy do reducera:

Jak możemy, zauważyć stan przechowywany w store zawiera 3 pola:

  • „loading”, który służy do przechowywania informacji o tym, że w danej chwili dane są ładowane.
  • „data” z kontenerem na już załadowane dane - „error” z kontenerem na dane błędu, w przypadku błędu podczas ładowania

Te trzy pola są odpowiednio wypełnianie poprzez reducer, w zależności od tego, jakie akcje przychodzą do reducera. W zasadzie jest to jedna z przykładowych konstrukcji, jakie możemy tutaj zastosować. Ogólnie do store możemy wsadzić dowolne dane, o ile dają się one sensownie zserializować. Zasada wynika z tego, że ułatwia to zapisywanie i ładowanie stanu, z zupełnie innego miejsca – na przykład w celu odtworzeniu stanu, podczas debugowania aplikacji za pomocą time-travelling (techniki pozwalającej skakać po różnych stanach store).

Zatem znów wywołajmy kilka akcji i zobaczmy jak to działa:

No dobra, ale w sumie to my nie mamy ładowania danych, tylko sami ręcznie wywołaliśmy akcje. W tym miejscu rodzą się problemy, ponieważ w reducerze nie możemy  wywołać żadnego API, ze względu na to, że reducer musi być pure function. Musimy jakoś obejść ten problem.

Efekty uboczne – bo przecież coś trzeba kiedyś pobrać z API

Sam Redux sprowadza się do czystych funkcji redukujących stan. Ale co, jeśli chcemy wyjść poza prostą logikę i zrobić coś "brudnego"? Na przykład pobrać dane z API, zapisać coś w lokalnej bazie, albo wysłać event do analityki? To już nie jest takie proste i czyste.

Tu wkraczają "middlewares" i narzędzia do obsługi side-effectów. Najpopularniejszymi na scenie JavaScriptowej są redux-thunk, redux-saga i redux-observable.

redux-thunk: Pozwala pisać asynchroniczne akcje w postaci funkcji zwracających kolejne akcje. Kod staje się nieco mniej deklaratywny, ale za to bardzo prosty do wdrożenia. redux-saga: Wprowadza koncepcję "sag" – generatory, które w reakcji na akcje potrafią asynchronicznie obsługiwać logikę, dispatchować akcje i czekać na kolejne. Bardzo czytelne, gdy przyzwyczaimy się do generatorów. redux-observable: Bazuje na RxJS, czyli reaktywnych strumieniach. Możemy deklaratywnie opisać przepływ danych asynchronicznych za pomocą operatorów.

My skupimy się chwilę na redux-observable

redux-observable jest middlewarem, który nasłuchuje na akcje przepływające przez store. Za pomocą tzw. epików (epics) definiujemy strumień akcji wejściowych i wychodzących. Epic to funkcja, która bierze action$ (strumień akcji) i state$ (strumień stanu), a w zamian produkuje nowy strumień akcji.

A jak to wygląda w kodzie? Otóż musimy dopisać brakujący epik:

Epik ten nasłuchuje na strumień akcji. Przychodzące akcje są filtrowane, tak aby wyłuskać te akcje, które odpowiadając za uruchomienie ładowania danych. Wywołanie takiej akcji, spowoduje wykonanie zapytania do API – w tym przypadku API NBP. Akcja przenosi za sobą, dodatkową informację w postaci nazwy tabeli kursu walut, który możemy przekazać do odpowiedniego serwisu. Akcja ta zwraca nowy strumień akcji, które zostaną zdispatchowane na storze.

W tym przypadku strumień ten może zwrócić akcję informującą o udanym załadowaniu danych (wraz z tymi danymi), jak również akcję o nieudanej próbie pobrania informacji.

Dodatkowo taki middleware musimy zarejstrować w store:

Teraz możemy uruchomić dokładnie jedną akcję odpowiedzialną za inicjowanie ładowania danych i zobaczyć co się stanie

Tu dzieje się magia: epik przy starcie ładowania odpala asynchroniczny request, a po otrzymaniu danych tworzy nową akcję sukcesu lub porażki. Pod spodem jednak wciąż mamy tę samą maszynę stanów – dostajemy strumień akcji, z których wynikają kolejne stany.

Podane wyżej przykłady w całości można obejrzeć (lub po sklonowaniu repozytorium odpalić) tutaj: https://github.com/SzateX/ReduxExamples/tree/master

Redux poza webem

No dobrze, ale Redux to przecież "tylko" dla frontu webowego, Reacta, itp., prawda? Niekoniecznie. Koncepcja architektury fluxowo/reduxowej to coś, co można zaimplementować w dowolnym języku czy środowisku.

W różnych językach mamy biblioteki implementujące Reduxa. W Pythonie jest na przykład „pydux”, w C++ jest „redux-cpp” lub „flowcpp”, dla Javy istnieje „redux4j”, a dla Fluttera „flutter_redux”

Przyjrzyjmy się bliżej flutterowi. Spróbujmy zrobić bardzo prostą aplikację, która ma za zadanie prezentować bardzo proste informacje o jakiejś konferencji: program prelekcji oraz informacje o prelegentach. Od razu zastrzegam, że celem tej aplikacji nie jest aby była ładna i UXowo ogarnięta. Ma po prostu działać.

Sposób postępowania będzie taki sam, zaczniemy od utworzenia akcji:

Dodatkowo zdefiniujmy stan aplikacji:

Dzięki temu możemy zdefiniować nasz reducer:

Jak widać do tej pory praktycznie nie różniło się to niczym w porównaniu do wersji JSowej. Pewnie w tym miejscu zapytacie się, co znowu, z side effectami. Za dużo w tej kwestii się nie zmienia, ponieważ w Flutterze istnieją odpowiedniki middlewarów do obsługi side-effectów w środowisku reduxowym. Dzisiaj skorzystamy z odpowiednika „redux-observable” czyli „rexux_epics”. Utwórzmy zatem epik:

Znów możemy zauważyć, że niezbyt się on różni od tego co możemy zaobserwować w implementacji JSowej. No dobra, w implementacji JSowej napisalibyśmy dwa osobne epiki, jeden dla ładowania harmonogramu, drugi dla ładowania listy prelegentów. Dodatkowo odpowiedzialność za utworzenie obiektu nowej akcji, zrzuciłem na serwis.

Zostaje tylko zapakować konfigurację store:

Oraz cieszyć się możliwością wywoływania akcji np.:

Całość aplikacji można obejrzeć w tym repozytorium: https://github.com/SzateX/flutter_redux_example/tree/master/lib

Jednak uruchomienie będzie wymagać backendu, który znaleźć można wraz z instrukcją uruchomienia tutaj: https://github.com/SzateX/flutter_redux_example_backend

Jak możemy zauważyć, nie obsłużyłem dwóch stanów:

  • ładowanie danych
  • ładowanie nie powiodło się.

Zostawiam to w ramach ćwiczeń dla was czytelników.

Podsumowanie

Redux w ujęciu maszyn stanów to naprawdę nic strasznego. W pewnym sensie za każdym razem, gdy pracujemy z takim fluxowo/reduxowym podejściem, tworzymy sobie własną małą maszynę stanów, która w deterministyczny sposób reaguje na akcje. Gdy do gry wejdą side-effecty, używamy dodatkowych narzędzi, ale core i tak zostaje ten sam.

Największym plusem Reduxa i podobnych architektur jest to, że podejście jest uniwersalne i można je przenosić między technologiami. Dzisiaj React z redux-observable, jutro .NET z własnymi eventami, a pojutrze Flutter z redux_epics i pobieraniem kotków z API. Wszędzie tam maszyny stanów dają nam stabilny fundament do budowania spójnych i przewidywalnych aplikacji.

Kto by pomyślał, że te staroświeckie maszyny stanów wciąż mają w sobie tyle życia – i to nie tylko w świecie webowych interfejsów!

 

 


Jakub Szatkowski

Programista w Pionie Banków Komercyjnych. Miłośnik programowania niskopoziomowego, pogromca wskaźników. Fascynat planszówek i wyścigów samochodowych.


Wydrukuj