Wraz z rozwojem IT, coraz więcej danych jest wymienianych pomiędzy aplikacjami, a dodatkowo coraz większa część tych danych jest przesyłana bez udziału użytkowników. Urządzenia komunikują się między sobą wtedy, kiedy same zdiagnozują taką potrzebę – nie czekając na interakcję z użytkownikiem. Taki sposób korzystania z infrastruktury komputerowej rzuca nowe wyzwania dla twórców oprogramowania. Po pierwsze, ilość danych w skali świata jest coraz większa, a po drugie koszt związany z ich przetwarzaniem powinien być jak najmniejszy.
Jednym ze sposobów radzenia sobie z przetwarzaniem danych w efektywny sposób jest programowanie reaktywne. Termin ten nie jest młody (co ciekawe, jak większość koncepcji IT!) i powstał w latach 60. XX wieku, ale na popularności zaczął zyskiwać dopiero kilka lat temu. Najprostsza definicja programowania reaktywnego mówi, że jest to paradygmat programowania wykorzystujący asynchroniczne strumienie danych. Ta definicja, choć jest naprawdę dobra, nie mówi do końca jednak podstawowej rzeczy: czym właściwie się to różni od „zwykłego” programowania z użyciem strumieni. Oczywiście słowo „asynchroniczne” sugeruje już tę różnicę, ale wciąż nie oddaje innego podejścia.
Wyzwanie z realnego projektu
Wyobraźmy sobie następujący przykład bazujący na faktycznym problemie w jednym z projektów realizowanych w Capgemini dla branży biotechnologicznej:
tworzymy oprogramowanie obsługujące urządzenia medyczne; urządzenia te przesyłają duże ilości danych w celu ich dalszej obróbki oraz późniejszego udostępnienia ich personelowi medycznemu; dane te są przesyłane nieregularnie oraz zdarza się, że przesyłane są w dużej ilości na raz, po czym transmisja ustaje na dłuższy czas.
Wyzwaniem dla twórców oprogramowania jest zatem to, że:
1. chcemy, aby interakcja z urządzeniem była jak najefektywniejsza,
2. chcemy, aby ostateczne wyniki (czyli te przetworzone przez nasze oprogramowanie) były dostępne tak szybko, jak to możliwe.
Zacznijmy od drugiego punktu: wiemy, że użytkownik będzie chciał zobaczyć całość wyników, jak tylko urządzenie prześle kompletną serię wyników oraz że przetwarzanie każdego z elementów serii jest procesem czasochłonnym. Decydujemy się, więc na przetwarzanie danych z urządzenia bezpośrednio po ich otrzymaniu. Tutaj wpadamy w pułapkę braku efektywności komunikacji z urządzeniem, bo nie chcemy, aby urządzenie czekało na zakończenie długotrwałego procesu obróbki danych.
Jak rozwiązać to wyzwanie?
Tak opisane wyzwanie jest możliwe do obsłużenia na różne sposoby. Programowanie reaktywne jest jednym z nich i idealnie się do tego nadaje. Po pierwsze, możemy zdefiniować nasz proces jako przetwarzanie strumienia danych, po drugie wykorzystamy inne cechy tego paradygmatu programowania:
1. Przetwarzanie jest asynchroniczne i wywoływane jako reakcja na pewne zdarzenie. Takie zdarzenia mogą być generowane jako kolejne w naszej sekwencji „kroków”.
2. Całe nasze działanie ma (a przynajmniej powinno mieć) nieblokującą naturę. Oznacza to, że nie chcemy blokować żadnego zasobu tylko na potrzeby jednego wątku przetwarzania. Co więcej, żadne z żądań przetworzenia danych nie będzie miało wątku na swoją wyłączność.
3. Możemy wykorzystać mechanizm ciśnienia zwrotnego (ang. back pressure). Nazwa tego mechanizmu odnosi się do zjawiska fizycznego i oznacza, w przypadku naszej technologii, próbę oddziaływania na nadawcę komunikatów, aby zwolnił tempo nadawania, gdy nie jesteśmy w stanie ich obsłużyć w ramach dostępnych zasobów.
Patrząc na powyższe możemy zadać sobie pytanie, jaka jest korzyść wynikająca z jego zastosowania? Czy systemowi uda się wykonać zaplanowane zadania szybciej? Raczej nie. Korzyścią będzie bardziej efektywne wykorzystanie zasobów (mniej wątków będzie potrzebne na realizację żądań, jako że nie będą one na wyłączność). Dodatkowo będziemy starać się wpływać na klienta i płynnie regulować komunikację.
Rozwiązania dostępne w Javie
Przyjrzyjmy się zatem dostępnym rozwiązaniom w Javie. Do najpopularniejszych należą Reactor oraz RxJava. Należy pamiętać, że ponieważ obie te biblioteki bazują na podobnych założeniach, zasady działania są podobne, a co więcej można używać ich jednocześnie, gdyż dostępne są biblioteki służące jako adaptery między nimi.
Biblioteka Reactor implementuje Reactive Streams, które to można w dużym skrócie opisać jako cztery interfejsy: Publisher, Subscriber, Subscription, oraz Processor. Publisher wysyła dane, a Subscriber je odbiera przy użyciu Subscription. Processor to jednocześnie Publisher i Subscriber, czyli odbiera dane i wysyła je dalej. Typy danych, jakimi operujemy to Mono i Flux. Różnica pomiędzy nimi jest niewielka, ale znacząca: Mono może przenosić co najwyżej jedną wartość, a Flux dowolnie dużo (w tym nieskończenie wiele) wartości.
Tworzenie strumieni na potrzeby programowania reaktywnego jest semantycznie bardzo proste:
Powyższy przykład pokazuje tworzenie typu Flux jako zdefiniowane po prostu wartości. Inne sposoby obejmują między innymi wygenerowanie strumienia z kolekcji bądź też z „normalnego” strumienia. Kolejnym sposobem otrzymania strumienia jest wygenerowanie go jako wyniku działania na innym strumieniu bądź strumieniach. Strumienie można bowiem filtrować, przemapowywać w nich wartości czy też łączyć je ze sobą. Należy jedynie pamiętać, że myślimy tu o strukturach danych umożliwiających obsługę potencjalnie nieskończonej liczby danych, więc ostateczny wynik operacji może być nieznany i z góry przewidywalny. W szczególności dotyczy to łączenia strumieni, gdy dane w łączonych strumieniach pojawiają się szybko i wynik (kolejność) elementów w powstałym w efekcie połączenia strumieniu jest nieprzewidywalny.
Tworząc strumień mamy dodatkowo możliwość wpływania na interwał dla emisji kolejnych elementów, czy też opóźnienie w rozpoczęciu emitowania elementów. Ten aspekt jest bardzo istotny dla zrozumienia działania strumieni. Nie można z nich korzystać w tradycyjny, imperatywny sposób – kolejne instrukcje mogą bowiem zostać wykonane zanim pojawią się dane, których oczekiwaliśmy. Można więc powiedzieć, że reaktywne typy danych odzwierciedlają prawdziwy przepływ danych pomiędzy systemami, czy też komponentami systemów.
Jeśli już mamy strumień z danymi, dobrze jest umieć odbierać z niego wartości:
W najprostszym przykładzie, wykorzystując lambdę, wyraźnie widać, że subskrypcja polega na możliwości odczytania kolejnych, pojawiających się wartości.
Jeśli chodzi o szczegóły operacji na strumieniach, polecam zapoznanie się z dokumentacją projektu Reactor – osoba zaznajomiona ze strumieniami z Javy 8 od razu zauważy podobieństwa składniowe i w intuicyjny sposób będzie mogła prawidłowo z nich korzystać.
Tworzenie serwisów webowych przy użyciu programowania reaktywnego
Przejdźmy teraz do sposobu tworzenia serwisów webowych przy użyciu programowania reaktywnego. Jednym z najbardziej popularnych (a przez niektórych uważany za standard) jest Spring MVC. Możemy użyć Spring MVC do zwracania reaktywnych typów danych, jednak nie osiągniemy w ten sposób reaktywnej aplikacji. Dalej żądania będą obsługiwane poprzez wielowątkowy mechanizm z blokowaniem. Twórcy Springa doszli do wniosku, że nie jest możliwe w prosty sposób użycie tych mechanizmów w celu realizacji paradygmatu programowania reaktywnego i w ten sposób powstał Spring WebFlux.
Powyższy fragment kodu jest napisany przy użyciu Spring WebFlux. Ze względu jednak na to, że WebFlux dzieli adnotacje ze Spring MVC, równie dobrze mógłby być napisany w tym drugim rozwiązaniu. Dzięki temu, nie wchodząc w szczegóły techniczne obu z tych podejść, jesteśmy w łatwy sposób w stanie przejść z jednego do drugiego.
Jedyne na, co należy zwracać uwagę to to, że budując stos wywołań (od kontrolera począwszy, poprzez serwisy, a na zapisach do bazy danych skończywszy) każdy z nich powinien być napisany reaktywnie. Obecnie największy problem jest związany z relacyjnymi bazami danych – o ile bazy NoSQL pozwalają na taki sposób korzystania, to JDBC ma blokującą naturę i jako taki stanowi wyzwanie do obsłużenia. W związku z zapotrzebowaniem na nieblokujący odpowiednik, powstało kilka alternatywnych rozwiązań (takich jak na przykład R2DBC czy rxjava-jdbc), jednak są one w stosunkowo wczesnej fazie rozwoju i decyzję, czy można je stosować w wymagającym projekcie każdy powinien podejmować samodzielnie.