Minęły już czasy, gdy skrypty JavaScript służyły wyłącznie do realizowania prostych efektów graficznych w przeglądarce. Obecnie możliwe jest za pomocą tego języka tworzenie całości skomplikowanych aplikacji sieciowych na poziomie serwera i klienta, a na rynku pojawiło się nowe stanowisko pracy: full-stack developer.
Wiodącą platformą umożliwiającą tworzenie części back-end aplikacji w języku JavaScript jest Node.js. Jej właśnie zostało poświęcone comiesięczne, 32. spotkanie z cyklu Uszanowanko Programowanko, organizowanego przez firmę The Software House. Spotkanie odbyło się 10 października w gliwickiej siedzibie firmy.
Spotkanie rozpoczęło się od krótkiego wprowadzenia do samego Node.js, którego udzielił Szymon Piecuch w prezentacji „Kickoff to Node.js”. Opisał on historię projektu, sposób oznaczania wersji stabilnych i eksperymentalnych oraz metodologię wprowadzania nowych rozwiązań i wycofywania przestarzałych. Następnie wymienił możliwe do stosowania w aplikacjach elementy najnowszych wersji języka JavaScript, takie jak słowa kluczowe let
i const
czy wyrażenia lambda (ang. arrow functions).
Dłuższą chwilę prelegent poświęcił kwestii dzielenia aplikacji na moduły, zapisywania ich w formie właściwej dla Node.js oraz dynamicznego ładowania modułów. Z punktu widzenia Node.js modułem może być zarówno skrypt JavaScript, jak i plik tekstowy, obiekt JSON lub plik binarny modułu Node.js. Można też potraktować jako moduł cały katalog, o ile umieści się w nim plik package.json
z opisem modułu. Dynamiczne ładowanie modułów jest realizowane za pomocą CommonJS, jednak najnowsze wersje Node.js będą wykorzystywały już standardowe rozwiązania obecne w ECMAScript 6.
Tworzenie aplikacji w Node.js wymaga myślenia w sposób asynchroniczny. Maszyna wirtualna języka pracuje w ramach jednego wątku, wykonując kolejne fazy przetwarzania realizowanych żądań i jeżeli aplikacja zbyt długo nie oddaje sterowania, jej wydajność spada lub wręcz cała aplikacja ulega zablokowaniu. Dlatego istotne jest, by wszędzie, gdzie jest to możliwe stosować rozwiązania asynchroniczne. Kiedyś w tym celu stosowało się metody setTimeout
oraz setInterval
. W Node.js zastąpiono je rozwiązaniem typu callback, jednak w przypadku zagnieżdżania wielu operacji asynchronicznych powstający tekst źródłowy programu jest nieczytelny i podatny na błędy. Dużo wygodniejszym oraz funkcjonalnym rozwiązaniem jest wzorzec projektowy przyrzeczenia, realizowany za pomocą pary klas Promise
i Future
. Istnieje nawet rozwiązanie nazwane Promisify, które opakowuje istniejące obiekty callback do postaci przyrzeczenia. Nadal jednak zapis zagnieżdżonych lub sekwencyjnych operacji może być nieczytelny i wymaga stosowania wielu oddzielnych bloków try
…catch
. Dlatego w najnowszych wersjach Node.js wprowadzono obsługę słów kluczowych async
i await
, za pomocą których można operacje zagnieżdżone realizować w sposób liniowy, obejmując je tylko jednym, wspólnym blokiem try
…catch
.
Skuteczne stosowanie Node.js, szczególnie na etapie tworzenia i rozwoju aplikacji, warto wspomagać zewnętrznymi narzędziami nadzorującymi takimi jak Nodaemon oraz PM2. Uruchamiają one wiele równoległych instancji maszyny wirtualnej oraz automatycznie ładują aplikację od nowa w momencie wykrycia zmian w jej tekście źródłowym, skracając czas między wprowadzeniem zmiany i jej przetestowaniem.
Node.js jest projektem podlegającym intensywnemu rozwojowi. Najnowsze wersje środowiska wprowadzają nowe rozwiązania w zakresie asynchronicznego dostępu do systemu plików, podziału aplikacji na wątki, obsługi protokołu HTTP/2 oraz dodawania własnych modułów maszynowych pisanych w języku C++.
Kolejną prezentację, zatytułowaną „One tool to rule them all”, przedstawił Kamil Raczyński. Postawił on sobie za cel znalezienie narzędzia, które umożliwiałoby zrealizowanie możliwie szerokiej funkcjonalności aplikacji Node.js bez konieczności samodzielnego włączania zewnętrznych bibliotek. Tego typu narzędziem jest biblioteka Nest, która integruje wiele niezależnych rozwiązań w jedną całość, narzuca strukturę aplikacji i ułatwia zapisywanie kodu przez wskazywanie funkcji i zależności poszczególnych klas za pomocą mechanizmu adnotacji.
Prelegent pokazał możliwości biblioteki Nest tworząc przykładową aplikację przechowującą i aktualizującą kursy walut. Aplikacja miała korzystać z bazy danych, udostępniać interfejs CRUD do pobierania i aktualizowania kursów oraz na bieżąco powiadamiać klientów o zmianie kursu za pomocą mechanizmu WebSocket. Dodatkowo, dostęp do dwóch podstawowych funkcjonalności – pobierania oraz zmiany kursów – miał być ograniczony tylko do konkretnych, uwierzytelnionych użytkowników.
Bibliotekę Nest opisywałem już przy okazji relacjonowania spotkania Gorrion Unplugged, na którym też była ona prezentowana. Tutaj ograniczę się zatem do przedstawienia listy zalet tej biblioteki, wymienionych przez prelegenta:
Pod koniec prezentacji z sali padło pytanie o najpoważniejsze według prelegenta problemy przy korzystaniu z Nest. Wymienił on stosowanie języka TypeScript, do którego muszą przyzwyczaić się osoby, które wcześniej tworzyły aplikacje klienckie wyłącznie w „czystym” języku JavaScript. Drugą wadą jest trudność w diagnozowaniu błędów, gdyż często nawet trywialne usterki aplikacji powodują pojawianie się w konsoli skomplikowanych, wielopoziomowych komunikatów błędów.
Trzecia z prezentacji, zatytułowana „Microservices in Node: patterns and techniques” i wygłoszona przez Mariusza Richtscheida, dotyczyła bardzo modnej obecnie architektury mikroserwisów w aspekcie środowiska aplikacyjnego Node.js. Prelegent zaczął od wymienienia wad aplikacji monolitycznych:
Następnie przedstawił powody, dla których tworzy się aplikacje zbudowane z mikroserwisów lub dokonuje rozbicia istniejących aplikacji monolitycznych:
Stosowanie techniki mikroserwisów oznacza jednak pojawienie się zupełnie nowych wyzwań. Programiści muszą zapewnić sprawną komunikację między usługami składowymi oraz bezpieczeństwo i spójność przechowywanych danych. Na nowo muszą zostać rozwiązane problemy, które w świecie aplikacji monolitycznych zostały opanowane tak dawno, że ich rozwiązania są przyjmowane za oczywistość. Na pomoc przychodzą na szczęście wzorce architektoniczne.
Najważniejszym z nich jest brama API, stanowiąca jedyny punkt wejścia dostępny dla aplikacji zewnętrznych. Brama realizuje uwierzytelnianie oraz wstępną autoryzację dostępu do usług składowych. Odpowiada ona też za szyfrowanie informacji: dla utrzymania prostoty i wydajności cała wewnętrzna komunikacja przebiega nieszyfrowanym protokołem HTTP. Brama ma pośredniczyć w komunikacji z pierwszą usługą składową i nie może implementować logiki biznesowej.
Wewnątrz wydzielonej, zaufanej sieci składowych mikroserwisów konieczne jest uruchomienie skutecznego i automatycznego rejestru usług pozwalającego wykrywać działające instancje (ang. service discovery) oraz weryfikować ich stan (ang. health check). Taką rolę może pełnić oprogramowanie Consul. Uruchamiane usługi automatycznie zgłaszają się do prowadzonego przez niego rejestru, podając przy okazji adres punktu dostępowego REST pozwalającego weryfikować poprawność realizowania danej usługi. Consul jest w stanie na bieżąco informować wszystkie działające usługi składowe o zmianach stanu jednej z nich, pozwalając im przekonfigurować się w sposób pomijający lub uwzględniający niektóre usługi, o ile nie są kluczowe do działania systemu.
Poszczególne usługi mogą komunikować się ze sobą bezpośrednio, jednak w przypadku dużych systemów korzystne może być użycie oprogramowania dystrybutora komunikatów takiego jak Apache Kafka. Oprogramowanie takie może dokonywać wstępnego przetwarzania komunikatów, buforowania ich w sytuacji zwiększonego obciążenia systemu lub wstrzymywania do momentu powtórnego uruchomienia usług, które uległy awarii.
Dane przetwarzane przez system mogą być przechowywane we wspólnej bazie danych, jednak nie jest to polecane. Aby można było łatwo skalować wydajność i pojemność systemu, warto wydzielić odrębną bazę danych dla każdej usługi składowej. Powoduje to jednak pojawienie się problemu ze spójnością danych w przypadku błędu w trakcie realizacji żądania, którego nie można już rozwiązać mechanizmem transakcji. Rozwiązaniem jest nadrzędny system transakcyjny, który tworzy meta-transakcje obejmujące wiele lokalnych transakcji w ramach usług składowych. Jeżeli cały łańcuch usług realizujących żądanie zgłosi poprawne zakończenie jego obsługi, meta-transakcja jest zatwierdzana, co może być traktowane przez mikroserwisy jako zatwierdzenie lokalnej transakcji lub brak operacji. Jeżeli jednak choć jedna z usług składowych zgłosi błąd, wszystkie poprzednie elementy łańcucha są o tym zawiadamiane, dzięki czemu mogą albo anulować lokalne transakcje, albo wycofać już wprowadzone dane.
Niektóre zadania mogą być realizowane przez gotowe moduły implementujące proste wzorce architektoniczne, bez konieczności pisania własnego kodu. Wzorzec architektoniczny złożenia API (ang. API composition) pozwala zrealizować operację poprzez równoległe lub szeregowe wywołanie kilku usług składowych. Z kolei wzorzec bezpiecznika (ang. circuit breaker) pozwala reagować na nadsyłane informacje o awarii niektórych usług przez – o ile jest to możliwe – pomijanie ich lub natychmiastowe zgłaszanie błędu, bez konieczności czekania na przedawnienie (ang. time-out) wywołania.
Kolejnym wyzwaniem, dawno już opanowanym w świecie aplikacji monolitycznych, jest tworzenie dzienników zdarzeń. W systemie, gdzie wiele usług składowych może jednocześnie realizować wiele drobnych operacji składających się na większe działania, zwykłe dzienniki zdarzeń nie zdają egzaminu. Z pomocą przychodzi koncepcja śledzenia rozproszonego (ang. distributed tracing), w której tworzone są dzienniki oddzielnie dla każdego żądania, a każda usługa składowa dopisuje do właściwego dziennika dane wejściowe, informacje o realizacji żądania oraz wynik. Dzięki temu wykrywanie usterek w działaniu poszczególnych usług lub na ich styku staje się dużo prostsze. Ponieważ rozpoczęcie realizacji żądania przez każdą kolejną usługę jest zapisywane ze znacznikiem czasowym, programista może obserwować też wydajność. Mechanizm śledzenia rozproszonego został określony jako standard OpenTracing, którego najpopularniejszą implementacją jest rozwijany przez Uber pakiet Jaeger.
Prezentacja Mariusza Richtscheida zrobiła bardzo duże wrażenie tak na mnie, jak i na innych uczestnikach spotkania. Prelegent zmierzył się ze sporą liczbą pytań dotyczących sposobu działania rozproszonych transakcji, tworzenia bramy API oraz skalowania składowania danych w systemie i poszczególnych usługach. Dyskusja dotknęła też istotnego pytania kiedy wprowadzać architekturę mikroserwisów do projektu i na jakim poziomie rozwoju projektu monolitycznego należy rozważać jego rozdzielenie. Prelegent wyjaśnił, że wprowadzenie bramy API może być doskonałym początkiem procesu rozdzielania aplikacji monolitycznej na mniejsze fragmenty realizowane przez niezależne usługi.
Tematy związane z językiem JavaScript oraz środowiskiem Node.js cieszą się wielkim zainteresowaniem, czego przejawem była frekwencja na spotkaniu. Sala była wypełniona po brzegi, po każdej prezentacji prelegenci odpowiadali na pytania publiczności, a po pizzy podanej na koniec w ciągu paru minut zostały tylko puste pudełka. Tym bardziej należy docenić świetną organizację spotkania, które rozpoczęło się z tylko kilkuminutowym opóźnieniem i skończyło zgodnie z harmonogramem.
Wszystkie prezentacje miały wysoki poziom merytoryczny, przy czym z mojego punktu widzenia najciekawsze tematy prezentowała ostatnia z nich. Choć na temat wyższości architektury mikroserwisów nad monolityczną można kłócić się równie zażarcie, jak w przypadku systemów operacyjnych monolitycznych i z mikrojądrem, trudno zaprzeczyć, że wydzielone usługi mogą być w wielu przypadkach dobrym rozwiązaniem i warto znać najlepsze praktyki związane z ich wdrażaniem, łączeniem i diagnozowaniem.