Flux i Redux

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. Jednak praktycznie na codzień sam korzystam teraz z implementacji architektury Flux razem z AngularJS 1.5, więc chciałbym o tym koncepcie napisać coś więcej. Dodatkowo opiszę też konkretną implementację Fluksa – czyli Redux.

Flux

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ąc z niego korzystać z niezwykłą wręcz łatwością, bez konieczności instalowania dodatkowych bibliotek czy frameworków.

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źmy 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 pomylimy się bardzo jeśli założymy, ż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

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. 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:

Architektura Flux

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. 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.

Korzystam z Fluksa w AngularJS 1.5. Akcje odbywają się często poza angularowym digest-cycle, co ma swoje zalety (na przykład wydajnościowe), ale przez to w kontrolerze konieczne jest wywołanie $scope.$apply, aby zmiany zostały zaaplikowane do widoku i stały się namacalne w przeglądarce.

Redux

Redux jest implementacją architektury Flux, do której dodano nieco programowania funkcyjnego i skorzystano ze wzorca Event Sourcing. Na stronie Reduksa podane są trzy główne zasady, o której opiera się cała filozofia tej biblioteki:

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. Chcemy przesłać stan z serwera do aplikacji lub w drugą stronę? Nie ma najmniejszego problemu, to tylko jeden obiekt. Chcemy 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. 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ć pure function3

Konkretnie są to funkcje, które przyjmują poprzedni stan oraz akcję i zwracają zupełnie nowy stan, nie zmieniając przy okazji obiektu reprezentującego stanu poprzedni (nie mutują go). Funkcje te nazywa się zwyczajowo reducer. Najczęściej rozpoczyna się od stworzenia jednego reducera, a później, gdy aplikacja się rozrasta, dodaje się kolejne reducery, znajmują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ójrzmy na kod źródłowy. To, co nas 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 taki 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, gdzy store się zmieni. Wewnątrz funkcji render pobieram zaś zawartość store’a i wyświetlam w najprotszy 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. Zobaczmy to na własne oczy:

Zobacz Pen xOZWYy – Michał Miszczyszyn (@mmiszy) na CodePen.

Niemutowalny stan

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). Rozpatrzmy jednak bardziej skomplikowany przykład. Załóżmy, że stan nie jest liczbą, lecz obiektem state = {counter: 0}. W takim przypadku musimy 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.

Zobacz Pen dXGmax – Michał Miszczyszyn (@mmiszy) na CodePen.

Podsumowanie

To w zasadzie wszystko, co chciałem dzisiaj napisać. Traktuję to jako wstęp do kolejnej części kursu Angular 2, ale celowo wydzieliłem posta o Fluksie, 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!

  1. Tłumaczenie Dispatcher jako dyspozytor wydało mi się komiczne, więc pozostanę przy oryginalnych angielskich nazwach.

  2. Obrazek z https://facebook.github.io/flux/docs/overview.html#content

  3. Teoretycznie to określenie funkcjonuje w języku polskim jako „czysta funkcja”, ale nie brzmi to dla mnie dobrze

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!