Gdy opisywałem sposoby komunikacji pomiędzy kontrolerami w AngularJS, poniekąd celowo pominąłem pewną alternatywę, która zyskuje ostatnio sporą popularność: Architekturę Flux. Nie wspominałem o Fluksie głównie ze względu na to, że to koncept trochę szerszy niż prosta komunikacja pomiędzy elementami aplikacji o jakiej traktował tamten wpis.
Flux i Redux
Flux to angielskie słowo oznaczające strumień lub przepływ. Jest to też nazwa biblioteki architektury aplikacji zaproponowanej przez Facebooka. Gigant twierdzi zresztą, że używa Fluksa do budowania swoich aplikacji, a sam koncept stał się ostatnio niezwykle popularny. Podstawą Fluksa jest jeden wzorzec projektowy i jedno proste założenie, dlatego można zacząć z niego korzystać z niezwykłą wręcz łatwością, bez konieczności instalowania dodatkowych bibliotek czy frameworków. zapisz się na szkolenie z Flux i Redux.
CQRS
Flux opiera się o stary, dobry wzorzec projektowy CQRS. Skrótowiec rozwija się do Command Query Responsibility Segregation, czyli w luźnym tłumaczeniu rozdzielenie zapytań od rozkazów. Wzorzec ten został pierwszy raz opisany przez Bertranda Meyera i spopularyzowany przez Grega Younga, i zasadniczo opiera się o pomysł, aby rozdzielić od siebie fragmenty modelu odpowiedzialne za pobieranie informacji od tych odpowiedzialnych za ich modyfikację.
Dlaczego ma to sens? Jednym z często powtarzanych przykładów jest różnica w częstotliwości odczytywania i zapisywania czegoś przez użytkowników w aplikacji. Wyobraź sobie portal społecznościowy, na którym każdy może czytać posty innych oraz samemu wrzucać nowe ciekawostki ze swojego życia. Zgodnie z zasadą Pareta, nie pomylisz się bardzo jeśli założysz, że 80% postów pochodzi od 20% użytkowników. Innymi słowy tylko 20% użytkowników coś pisze, a reszta prawie wyłącznie czyta.
Na tej podstawie warto zadać pytanie: Czy sensownie jest, aby fragmenty aplikacji odpowiedzialne za pisanie i za czytanie były połączone i skalowane w tym samym stopniu? Prawdopodobnie nie i prawdopodobnie w analogiczny sposób zasadę Pareta można zastosować z powodzeniem do dowolnej aplikacji internetowej. Jednym z założeń wzorca CQRS jest rozwiązanie tego problemu. Warto też jednak pamiętać, że stosowanie CQRS nie zawsze ma sens – Martin Fowler pisze więcej na ten temat na swoim blogu.
Podstawy Flux i Redux
Architektura Flux przewiduje istnienie trzech głównych części1:
- Dispatcher – odpowiedzialny jest za odbieranie akcji i rozsyłanie ich do odpowiednich Store’ów
- Store – odpowiadają za przechowywanie informacji
- View – widok, źródło akcji
Dodatkowym założeniem obowiązującym we Fluksie jest to, że informacje przepływają tylko w jednym, zawsze tym samym kierunku. Flux rezygnuje z MVC na rzecz jednokierunkowego przepływu danych (unidirectional data flow). Istotna jest tutaj całkowita enkapsulacja Store’ów – z zewnątrz można przy pomocy odpowiedniej metody jedynie odczytać zawartość Store’a, niemożliwa jest jednak jego modyfikacja. Wszelkie zmiany jego zawartości zachodzą poprzez przesłanie odpowiedniej akcji przez Dispatcher. I w ten sposób jasny staje się przepływ informacji2:
Tak to wygląda koncepcyjnie. Więcej na ten temat można poczytać na stronie Fluksa. Jest to tylko architektura, a więc implementacja tego pomysłu może być w zasadzie dowolna (np. Redux czy MobX). Facebook udostępnia co prawda swoją bibliotekę Dispatcher.js, ale nie trzeba z niej wcale korzystać. Implementacji Fluksa jest sporo, ostatni raz gdy sprawdzałem było ich chyba tuzin, niekompletną listę można znaleźć tutaj. O Fluksie szerzej opowiadał na Gdańskim meet.js Łukasz Kużyński. Dalej chciałbym powiedzieć o całkowicie alternatywnej implementacji Fluksa – Redux.
Redux
Redux jest implementacją architektury Flux, do której dodano nieco programowania funkcyjnego i skorzystano ze wzorca
Cały stan aplikacji jest przechowywany w drzewie w jednym storze
To założenie sprawia, że rozumowanie na temat stanu aplikacji staje się jeszcze prostsze. Chcesz przesłać stan z serwera do aplikacji lub w drugą stronę? Nie ma najmniejszego problemu, to tylko jeden obiekt. Chcesz zserializować stan i zapisać np. do JSON? Nic prostszego.
Stan jest tylko do odczytu; wszystkie zmiany zachodzą poprzez akcje
Nic nie modyfikuje stanu bezpośrednio. Zamiast tego, wysyłane są akcje, które reprezentują intencje. Wszystkie akcje przechodzą przez centralny punkt i są analizowane jedna po jednej w określonej kolejności (reducer). Dzięki temu nie tylko eliminowane są wszelkie wyścigi, ale też możliwe jest np. zapisywanie zdarzeń w celu łatwiejszego debugowania. Jest to nic innego niż implementacja wzorca Event Sourcing i dzięki temu trywialna stała się implementacja funkcji, które do tej pory były niezwykle skomplikowane: na przykład „cofnij” i „powtórz” – który zresztą jest sztandarowym przykładem Reduksa.
Aby zdefiniować jak akcja wpływa na stan, należy napisać reducer, który jest pure function3
Reducer to funkcja, które przyjmuje poprzedni stan oraz akcję i zwraca zupełnie nowy stan, nie zmieniając przy okazji obiektu reprezentującego stanu poprzedni (nie mutują go). Najczęściej rozpoczyna się od stworzenia jednego reducera, a później, gdy aplikacja się rozrasta, dodaje się kolejne reducery, zajmujące się konkretnymi fragmentami stanu.
Wydaje się proste? No i jest bardzo proste. To w zasadzie wszystko co trzeba wiedzieć o Reduksie! Trzy zasady wraz z przykładami kody można znaleźć w dokumentacji Reduksa. Przejdźmy teraz do konkretów…
Aplikacja z Redux
Najprostszym i powszechnie powielanym przykładem użycia Redux jest stworzenie aplikacji-licznika, której jedynym zadaniem jest reagowanie na kliknięcia w guziki, które zwiększają i zmniejszają licznik wyświetlany na stronie. Łatwizna! Spójrz na kod źródłowy. To, co Cię interesuje to funkcja reducer oraz stworzenie Store’a:
// pure reducer function
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return ++state;
case 'DECREMENT':
return --state;
}
return state;
}
W pierwszej linijce definiuję, że domyślnie state = 0
, gdy ten argument nie będzie zdefiniowany. Może się tak zdarzyć, jeśli jest to stan początkowy i w takim wypadku to reducer powinien wiedzieć jaki jest domyślny stan aplikacji. Przypisuję więc 0, bo to od tej liczby chciałbym zacząć liczyć. Następnie sprawdzam jaka akcja miała miejsce. Zwyczajowo akcje w Reduksie są zwykłymi stringami, więc prosty switch
wystarczy. Odpowiednio zwiększam lub zmniejszam licznik i zwracam nowy stan.
const store = Redux.createStore(counter);
store.subscribe(render);
render();
Następnie tworzę nowy store oraz wywołuję funkcję store.subscribe(render)
, dzięki której render
zostanie automatycznie wywołany zawsze, gdy store się zmieni. Wewnątrz funkcji render
pobieram zaś zawartość store’a i wyświetlam w najprostszy możliwy sposób:
function render() {
$$('#result').textContent = store.getState();
}
Ostatecznie podpinam pod zdarzenia click
obu przycisków akcje INCREMENT
i DECREMENT
:
store.dispatch({
type: 'INCREMENT'
});
Od teraz po kliknięciu przycisków licznik się zmienia. Zobacz to na własne oczy:
Niemutowalny stan w Redux
Wspomniałem o tym, że stan zwracany przez reducer musi być całkowicie nowym obiektem – nie można mutować poprzedniego stanu. W prostym przykładzie powyżej nie było problemu, ponieważ stanem była liczba, czyli jeden z typów prostych, które w JavaScripcie przekazywane są przez wartość (kopiowane).
Weź jednak bardziej skomplikowany przykład. Przyjmijmy, że stan nie jest liczbą, lecz obiektem state = {counter: 0}
. W takim przypadku musisz zwrócić całkowicie nowy obiekt z powiększonym lub zmniejszonym licznikiem. Z pomocą przychodzi funkcja Object.assign
:
function reducer(state = {counter: 0}, action) {
switch (action.type) {
case 'INCREMENT':
return Object.assign({}, state, {counter: state.counter + 1});
case 'DECREMENT':
return Object.assign({}, state, {counter: state.counter - 1});
}
return state;
}
Object.assign({}, state)
oznacza tyle co „skopiuj pola z obiektu state
do nowego pustego obiektu”. Kolejne argumenty przekazywane do tej funkcji powodują dodanie lub nadpisanie odpowiednich pól w obiekcie. Więcej na ten temat można doczytać w artykule Object.assign na MDN. Jest to niezwykle przydatna funkcja, gdy zależy nam na szybkim płytkim skopiowaniu jakiejś struktury danych. Analogiczne rozwiązania dostępne są też w popularnych bibliotekach takich jak lodash
albo underscore
.
Podsumowanie Flux i Redux
To w zasadzie wszystko, co chciałem dzisiaj napisać. Celowo wydzieliłem posta o Fluksie i Reduksie, bo to architektura bardzo uniwersalna i niezwiązana właściwie z żadnym frameworkiem. Znajomość uniwersalnych wzorców jest zawsze bardziej cenna niż znajomość konkretnych frameworków.
W tym wpisie omówiłem podstawowe założenia architektury Flux oraz jej zalety. Opisałem też jedną przykładową, bardzo popularną implementację o nazwie Redux. Zachęcam do komentowania!
- Tłumaczenie Dispatcher jako dyspozytor wydało mi się komiczne, więc pozostanę przy oryginalnych angielskich nazwach. ↩
- Obrazek z https://facebook.github.io/flux/docs/overview.html#content ↩
- Teoretycznie to określenie funkcjonuje w języku polskim jako „czysta funkcja”, ale nie brzmi to dla mnie dobrze ↩