Quarkus – luźne przemyślenia po 500h developmentu (cz. 3 – praca z kodem) 0 (0)

W tym wpisie będą subiektywne odczucia odnośnie komfortu pracy z kodem. Dokładniej – skupię się na wsparciu dla reaktywności oraz porównaniu ze Spring Bootem.

Reaktywność

O ile główna część projektu nad którym pracuję jest blokującą i niereaktywna, o tyle poeksperymentowałem ze wsparciem Quarkusa dla operacji nieblokujących w tej części kodu, która ostatecznie i tak zostanie usunięta.

Quarkus z założenia pod spodem jest napisany reaktywnie, jednak pozwala zarówno na programowanie reaktywne, jak i imperatywne. W przypadku programowania reaktywnego Quarkus korzysta z biblioteki Mutiny, który jest odpowiednikiem Reactora lub RxJava. Interfejs tej biblioteki jest wspierany zasadniczo w znacznej części pluginów – jeśli jakaś biblioteka nie wspiera sama z siebie Mutiny (choć przykładowo Hibernate Reactive wspiera), to pluginy zapewniają adaptacje do Mutiny tworząc spójny jednolity reaktywny interfejs. Jeśli jakaś biblioteka nie wspiera Mutiny, to istnieje jeszcze możliwość samodzielnego implementowania adaptera do Mutiny, z czego jednak nie korzystałem.

Nieco teorii

Zasadniczo w aplikacjach nieblokujacych stosowana jest koncepcja Event Loop. Wiele mądrych wpisów napisano na ten temat, acz tu jest po polsku 😉 choć wpis jest już nieco stary, bo trzyletni.

W standardowym blokującym podejściu każdy request gdy trafia do serwera, dostaje jeden wątek na przetworzenie i odesłanie odpowiedzi. Takie podejście nazywane jest „thread-per-request”. Jeśli musimy pobrać jakieś dane (wczytać z pliku, wykonać zapytanie do bazy danych, odpytać inny serwis) to musimy wątek zablokować do czasu uzyskania tych danych. A są to cenne milisekundy, ich dziesiątki lub nawet setki. Jeśli takich operacji wejścia/wyjścia mamy w każdym żądaniu dużo, lub oczekiwanie na dane trwa długo, to takie blokujące wątki podejście może być mało wydajne.

Pewnym rozwiązaniem jest podejście nieblokujące. Otrzymując żądanie dostajemy wątek z puli, jednak ten wątek nie jest przypisany do niego do końca przetwarzania. Gdy tylko trafi na operację wejścia/wyjścia przetwarzanie zadania jest wstrzymywane do czasu uzyskania odpowiedzi. Wątek jednak nie jest blokowany, lecz zajmuje się innymi zadaniami (nowe żądania, kontynuacja przetwarzania innych wątków, dla których operacje I/O się zakończyły i dane są dostępne itd.).

W takim podejściu stosuje się znacznie mniejszą pulę wątków (roboczo nazwijmy te pule wątków Event Loop Thread Pool). Takie operacje powinny trwać krótko (absolutny max, to kilka sekund). Jednak jeśli mamy do wykonania długotrwałe operacje, to nie powinniśmy wykonać jej na puli Event Loopy – potrzeba do tego osobnej puli wątków. Do takich operacji jest przeznaczona pula wątków Worker Thread Pool.

Oczywiście nic nie stoi na przeszkodzie, byśmy tych pul wątków mieli więcej – w zależności od potrzeb.Choć prócz tego operacje Vert.x’owe mają własne pule wątków Event Loopy i workerowe…

Ale w czym problem?

Quarkus (a w zasadzie to Vert.x) bardzo dba, by pula wątków Event Loopy nie zajmowała się długo trwającymi zadaniami. Jeśli jakaś czynność trwa zbyt długo (kilka sekund), wyświetla w logach warning z jej aktualnym stacktracem. Jest to fajny feature dev-friendly zapobiegający zagłodzeniu Event Loopy.

Jednak samo dbanie, jakie operacje powinny się wykonywać na jakiej Thread Pooli jest dodatkowym kosztem. Do tego dochodzi dodatkowa nauka obsługi kodu reaktywnego. Propagowanie kontekstów (CDI, reaktywne transakcje bazodanowe lub choćby przekazywanie spanId/traceId) również wymaga konfiguracji oraz zrozumienia jak to działa. Ostatecznie zdarza się też, że niektóre operacje są wykonywane na jednej konkretnej puli wątków i trzeba wymuszać, bt przetwarzanie było kontynuowane na tej puli, na której chcemy.

Chociaż możliwe, że znów dotykałem tych niestandardowych 20% przypadków, których wsparcie jest ograniczone 😉

Quarkus i Spring

Twórcy Quarkusa są świadomi rzeczywistości, w której dotychczas królował Spring. Chcąc ułatwić migracje do Quarkusa istniejących projektów dodali wsparcie dla najbardziej powszechnych części Springa – Spring MVC, Spring Data, Spring Security oraz kilku innych. Wykaz wspieranych funkcjonalności Springa dostępne pod tym linkiem (między innymi, bo w zasadzie jest tam cały Cheat Sheet dla Quarkusa)

Jeśli chodzi o porównanie Spring Boota z Quarkusem, to różnic jest bardzo wiele.

Pod kątem wydajnościowym (zasoby, wydajność itp.) polecam zerknąć na porównanie na stronie Baeldung. Jakkolwiek trudno wyłonić jednoznacznie zwyciężcę tego porównania.

Jeśli chodzi o wsparcie społeczności, to Spring Boot zdecydowanie wygrywa. Łatwo znaleźć w internecie rozwiązania problemów, choć nie zawsze są to rozwiązania aktualne. Czasem zdarza się znaleźć rozwiązania problemów w starych wersjach frameworku, a szukając czegoś w dokumentacji trzeba zwracać uwagę, czy dokumentacja dotyczy naszej wersji. W przypadku Quarkusa – o ile trudno znaleźć rozwiązanie problemu, o tyle rzadko zdarza się trafić na przedawnione rozwiązanie.

Quarkus i Spring – technologie

Różnice między tymi dwoma frameworkami są również stricte techniczne. Wybierając Spring Boota należy określić, czy chcemy korzystać ze starego dobrego Spring WebMVC (thread-per-request), czy Spring Webflux (Event Loop). Są to wykluczające się technologie. Ciekawostką jest, że w WebMVC również istnieje możliwość asynchronicznego zwracania rezultatu (w innym wątku, niż obsługujący cały request).

W Quarkusie teoretycznie nie ma wyboru co do modelu thread-per-request/Event Loop – pomimo możliwości wyboru między programowaniem reaktywnym, a imperatywnym wszystko działa pod spodem na Event Loopie.

Spring Boot pozwala również na wybór serwera obsługującego żądania HTTP – może to być Tomcat, Jetty, Undertow lub dla stosu reaktywnego Netty, lecz także Tomcat, Jetty, Undertow. Quarkus korzysta z Undertow lub Netty i nie daje takiego wyboru jak Spring Boot.

Wydaje się, że Quarkus stara się dostarczyć spójne współdziałające technologie, ale niekoniecznie zależy na różnorodności technologii. Taka różnorodność mogłaby zwiększać ilość kodu, liczbę abstrakcji, a przez to zwiększać zajętość pamięci oraz spowalniać działanie aplikacji, co jest priorytetem dla Quarkusa. Spring Boot dostarcza integracje dla większej liczby technologii dając większy wybór być może kosztem wydajności.

Swego czasu największą przewagą Quarkusa nad Springiem była możliwość kompilacji do kodu natywnego. Tę przewagę Quarkus utracił wraz z końcem listopada 2022, kiedy wyszedł Spring Boot 3 ze wsparciem dla kompilacji AOT.

Aktualnie jedyną przewagą Quarkusa nad Springiem wydaje się zorientowanie na tzw. Dev Experience. Quarkus oprócz wspomnianej wcześniej konsoli z możliwością przeładowania kodu, uruchomienia wszystkich testów, zmiany poziomu logowania udostępnia również tzw. DevServices, czyli prekonfigurowane TestContainers w czasie developmentu. W skrócie polega to na tym, że zamiast instalować wszystkie niezbędne dla projektu zewnętrzne serwisy (bazy danych, cache, message’ing) można zdefiniować takie serwisy w ramach Quarkusa z preinicjalizowanymi danymi, a przy uruchomieniu konsoli deweloperskiej wszystkie serwisy zostaną wystartowane.

Podsumowanie…

… zostanie udostępnione w następnym wpisie, a tymczasem:

Pax et Bonum 😉

Spring Boot Native, GraalVM i JHipster 0 (0)

Spring Boot na GraalVMie? Brzmi ciekawie, ale czy to jest możliwe?

Według wpisu sprzed 2 miesięcy prosty projekt można postawić na GraalVM Native Image. Jednak prosty CRUD, to nie system produkcyjny, który wymaga różnych bibliotek. Pytanie, jak to wygląda z nieco bardziej skomplikowanym stackiem technologicznym?

JHipster

Nie będę oczywiście pisał wszystkiego od zera. W zeszłym roku napisałem szybką aplikację (i wpis o niej). W niej wykorzystałem generator kodu JHipster. Taki generator to jest dobry dla szybkich, krótkich projektów, gdyż oprócz wygenerowanego modelu dostarcza wielu standardowych wymagań takich jak uwierzytelnienie, autoryzacja, metryki, obsługa logowania, a nawet stworzenie obrazu Dockerowego. Warto spojrzeć na ten projekt oraz co nim się da wygenerować również po to, żeby poznać technologie, zaczerpnąć ewentualnie patternów zaimplementowanych w takim projekcie.

Posiadając już apkę z Spring Bootem w wersji 2.2.7 postanowiłem zastosować w nim Spring Boot Native. Pierwszym wymogiem jest podbicie wersji Spring Boota do 2.4.5. Oczywiście podbicie wersji Spring Boota pociąga za sobą podbicie innych wersji bibliotek używanych przez Springa. Wchodząc do tego piekła ciężko z niego wyjść. Zatem od razu sobie odpuściłem manualne podbijanie, gdy w kolejnej wersji JHipstera (w wersji 7.0) podbicie Spring Boota do wersji 2.4.x jest zrobione out-of-the-box.

Zatem przeszedłem do ponownego wygenerowania na podstawie metadanych JHipstera. Rzecz jasna po tym należało zmerge’ować wygenerowany kod z moimi zmianami. Rzecz jasna to też nie było przyjemnie, ale wiązało się z mniejszym ryzykiem, niż manualne podbicie wersji.

Spring Boot + Spring Boot Native

Po podbiciu Spring Boota można było wprowadzić zmiany wymagane przez Spring Boot Native. Po uaktualnieniu pom.xml można było spróbować pierwszy raz uruchomić proces budowania. Pierwszy raz budowanie zakończyło się porażką na etapie budowania Mavena z komunikatem podobnym do Nie udało się, bo się nie wysypało. Pierwszym rozwiązaniem okazało się dodanie do parametrów kompliacji Mavena paramteru <forceJavacCompilerUse>true</forceJavacCompilerUse> . Po drugiej próbie kompilacji komunikat już był precyzyjny. Tym razem problem był po mojej stronie – źle zmerge’owałem źródła.

Trzecia kompilacja zakończyła się wielkim sukcesem – po 15 minutach mielenia na moim starym lapku projekt skompilował się! Uruchomienie przyniosło jednak szybkie rozczarowanie – logback nie może dostać się do pliku z konfiguracją. Po kilku próbach (czyt. kilkukrotnym czekaniu po 15 minut) i przeczesaniu internetu na temat tego błędu, zmigrowałem konfigurację na taką, która bazuje na przekazanych propertiesach.

Czwarta (15 minut) kompilacja zakończyła się porażką – uruchomienie aplikacji natywnej zakończyła się problemem z Audytowaniem uwierzytelniania. Nie jest to kluczowa funkcjonalność tego projektu, zatem szybkie zakomentowanie @EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware") rozwiązało problem.

Piąta (15 minut) kompilacja zakończyła się kolejną porażkę. Tym razem okazało się, że Swagger to zbyt duże wyzwanie dla Spring Native. Rozwiązanie – zakomentowanie uruchamiania profilu słagerowego.

Szósta (15 minut) kompilacja zakończyła się znów porażką. Tym razem kontekst Springa nie wstał. Po prostu. Bez informacji, co nie działa, gdzie i o co chodzi. Tutaj się poddałem.

Teoretycznie jest jakiś ticket na dostosowanie JHipstera do Spring Native, a nawet można dostać 500$ za dostarczenie tego feature’u. Można podejrzewać, że dostosowanie do Spring Native ostatecznie zostanie wykonane. Ale to póki co, to pieśń przyszłości.

Inne rozwiązania

JHipster jest już dość dojrzałym projektem – na frontendzie oferuje nie tylko Angulara, ale również Reacta i Vue.js, za to na backednzie wsparcie obejmuje Spring Boota, a także Micronauta i Quarkusa. Co ciekawe język programowania wymienić na Kotlina (ze Spring Bootem), C# (.Net), czy Javascript (NestJS).

W przypadku Micronauta niestety wsparcie nie obejmuje kompliacji do Native Image. Szkoda, bo Micronaut ma API bardzo podobne do Spring Boota.

Został JHipster z Quarkusem. Tutorial tłumaczący, jak stworzyć wygenerować taką aplikację jest dosadnie prosty. Wsparcie obejmuje również Native Image. Zatem do dzieła.

JHipster + Quarkus

Ponownie musiałem przegenerować na podstawie metadanych JHipstera. Niestety API Quarkusa różni się zdecydowanie od Spring Boota.

Począwszy od bazy danych i JPA różni się podejście do „owrapowania JPA„. W Spring Boot mamy bardzo przyjazne Spring Data Quarkus oferuje nam za to Panache, który jest frameworkiem dedykowanym dla Quarkusem. W nim dominującym podejściem do pobierania i zapisywania krotek jest ActiveRecord, choć da się również stworzyć klasy Repository. Jest wsparcie dla paginacji, customowych projekcji, zapytań w JPQL. Jedyne, czego brakuje, to odpowiednik uproszczenia JPA Criteria Query.

Jak mówiłem, podejściem dominującym jest Active Record, to w modelowaniu klas – zamiast klas POJO z getterami i setterami – dominuje klasa z publicznymi polami bez getterów i setterów dziedzicząca po PanacheEntity. Tutaj również musiałem dostosować swoje customowe zmiany.

Wsparcie dla cache’ów jest, ale w moim przypadku nie było potrzebne (i nie działało ;)), zatem je wyłączyłem.

Wsparcie dla security contextu jest, jednak dostęp nie jest przez ThreadLocala zwanego SecurityContextHolderem, a przez dodatkowy parametr wywołania endpointa.

Dodatkowo Quarkus wymusza poznawanie innych frameworków (czyt. RedHatowych, Eclipse’owych). I tak zamiast Jacksona mamy JSONB, zamiast Springa MVC mamy Jax-Rs. Wstrzykiwanie zależności wykonywane jest przez @Inject, choć Spring również wspiera tę adnotację.

Ostatnie, o czym trzeba pamiętać, to żeby do DTOsów i innych obiektów obrabianych refleksją (encje bazodanowe, projekcje) dodać adnotację @RegisterForReflection.

Wynik

Quarkus jest stworzony dla GraalVM Native Image, więc w tym przypadku nie było żadnego problemu z kompilacją do kodu natywnego. Sam czas kompilacji na mojej maszynie, to około 10 minut, czyli o 1/3 szybciej, niż kompilacja Spring Boot Native.

Normalnie na OpenJDK aplikacja wstaje lokalnie w około 5 sekund. Po skompilowaniu do kodu natywnego, uruchomienie aplikacji trwa do 100 milisekund.

Drugą kwestią jest, że taką aplikację można postawić niewielkim Heapie – dałem -Dxmx=64M, co łącznie z OffHeapem łącznie daje 200 MB (tyle przynajmniej pokazuje htop). Obstawiam, że gdyby zrezygnować z wielu JHipsterowych feature’ów, to dało by się zejść z zużyciem pamięci jeszcze niżej.
Spring Boota to z mniejszym Heapem niż 128 to bym nawet nie próbował startować, a jak wiadomo OffHeap też swoje waży. Jakkolwiek istnieją ludzie, którzy ze Spring Bootem potrafią zejść poniżej 80MB mierząc wszystko…

Podsumowanie

Należy pamiętać, że Spring Native jest jeszcze w fazie beta, zatem nie wszystko może tam działać. Możliwe, że tworzenie serwera od początku do końca było by lepszą strategią pozwalającą na znalezienie wszystkich przyczyn błędów.

Jednak każdorazowa rekompilacja trwająca 15 minut skutecznie odrzuca. Jako programista Java jestem przyzwyczajony do kompilacji klasy do bytecodu mierzonej w maksymalnie sekundach. Nawet kompilacja z użyciem Mavena takiego projektu trwa maksymalnie minutę.

Trzeba poczekać na pierwszy release, wtedy będzie można cokolwiek powiedzieć. Póki co jest zbyt wcześnie…