EnumSet vs Set.of() 5 (2)

Niegdyś musiałem stworzyć zbiór Enumów i umieścić go w polu static final. Pełen optymizmu skorzystałem z Set.of() z JDK9. Zdziwiłem się jednak, gdy SonarQube zgłosił, że jeśli używam zbiór Enumów, to powinienem użyć EnumSet. Wewnętrzne wzburzenie nie dawało mi spokoju, bo przecież Set.of() jest niemutowalne i korzysta ze @Stable wewnątrz, więc lepsze powinno być preferowane w tym względzie…

Po ponad 1,5 roku postanowiłem zweryfikować, co o moim przekonaniu mówią benchmarki.

EnumSet

Najpierw warto zajrzeć w szczegóły implementacji klasy EnumSet.

Zacznijmy od tego, że jest to klasa mutowalna, której wartości muszą dziedziczyć po klasie Enum.

Każda wartość wyliczeniowy posiada przyporządkowaną wartość ordinal(), która jest intem. Zatem zbiór Enumów może być reprezentowany poprzez pojedyncza liczbę, której poszczególne bity będą zapalone, gdy dany Enum (z odpowiednim ordinal()) będzie w zbiorze.

Zatem, aby przechować Enum o max 64 wartościach, wystarczy liczba typowana longiem. Jednak jak przechowywać Enumy o większej dziedzinie? Wówczas zamiast jednej liczby można użyć tablicy liczb.

Powyższe stwierdzenia przekładają się na implementację – EnumSet jest tak na prawdę klasa abstrakcyjną (udostępniającą metody fabryczne) i posiada dwie podklasy – RegularEnumSet reprezentujący jednym longiem dla Enumów o max 64 wartościach oraz JumboEnumSet ze stanem trzymanym w tablicy longów.

Set.of()

Set.of() zwraca zawsze klasę niemutowalną. Jeśliby zajrzeć w implementację metody Set.of(), to można zobaczyć, że zwracana klasa zależy od liczby elementów. Dla pustej listy zwracana jest instancja SetN, dla jednego i dwóch elementów – Set12, a dla trzech elementów wzwyż – ponownie SetN.

Set12 jest implementacją obsługująca wyłącznie jednoelementowe i dwuelementowe zbiory. Wewnątrz klasy są zawarte dwa pola, w których umieszczany jest pierwszy i opcjonalnie drugi element. Dzięki temu, że korzystamy z pól, a nie z tablicy oszczędzamy jeden odczyt z pamięci. Dodatkowo implementację metod w przypadku max dwóch elementów są trywialne – nie trzeba nawet iterować po tablicy (wszak jej nie ma ;)).

Implementację zwracane przez Set.of() są niezmienne, zatem można pokusić się o optymalizację odczytów. Takie pole, które na pewno jest niezmienne można oprócz oznaczenia finalem (a które niewiele daje), można oznaczyć adnotacją @Stable. Ta adnotacją dostępna dla modułów JDK upewnia kompilator, że wartość oznaczona NA PEWNO nie zostanie zmieniona. Użycie tej adnotacji jest wykorzystywane co najmniej w przypadku pól static final.

Zarówno na polach z elementami (dla Set12), jak i tablicy z elementami (SetN) jest użyte @Stable.

Zajętość pamięci

Pierwszym aspektem jest zmierzenie ile pojedynczy zbiór Enumów zajmuje pamięci. Zmierzone zostały zarówno same obiekty zbioru, jak i tworzone przez nie obiekty. Nie zostały jednak wzięte pod uwagę obiekty współdzielone między wiele instancji (np.EnumSet.universe lub dane klasy czy refleksji).

Wielkości są badane dla zbioru pełnego danych Enumów t.j. zbiór Enumów o N wartościach zawierający wszystkie możliwe wartości tego Enuma.

Otóż o ile dla jednego i dwóch elementów mniej waży Set.of(), o tyle w pozostałych przypadku lepiej wypada EnumSet, który rośnie tylko o 8 bajtów na każde 64 elementów dziedziny. W przypadku Set.of() każdy dodatkowy element w zbiorze zajmuje 8 bajtów.

Benchmark

Z braku czasu nieco poszedłem na skróty i badałem jednocześnie zarówno wpływ wielkości domeny (liczba dostępnych wartości danego Enuma), jak i wielkość zbioru.

Wielkość domeny powinna zmniejszać wydajność EnumSeta co każde 64 dostępnych wartości, niezależnie od zawartości zbioru.

Z drugiej strony wielkość zbioru powinna zmniejszać bardzo szybko wydajność Set.of() oraz znacznie wolniej wydajność EnumSeta.

Set.contains()

W tym przypadku badany był przypadek, gdzie sprawdzana była obecność ostatniego elementu domeny Enuma. Wielkość zbioru jest równocześnie wielkością dziedziny. Weryfikowane były dwa warianty – gdy trzymamy taki zbiór w polu statycznym finalnym (s.f.) i po prostu w finalnym.

Wyniki przedstawiane są poniżej (liczba operacji na sekundę – im więcej tym lepiej ;))

W przypadku pola static final operacja .contains() działa szybciej dla Set.of() zawierający 0, 1 lub 2 elementy (o 10%-50%). W pozostałych przypadkach EnumSet działa szybciej.

Co ciekawe, acz logiczne dla EnumSet dla dziedziny do 64 Enumów działa tak samo szybko niezależnie od zawartości zbioru (operacje na bitach są… szybkie ;)).

Zastanawiającą rzeczą do dalszego rozważania we własnym zakresie zostawiam „losowość” wydajności Set.of() dla większych niż 2. Dla pól static final wydajność „skacze”.

W przypadku pól finalnych przewaga Set.of() jest widoczna tylko dla pustego i jednoelementowego zbioru. Dla większych zbiorów EnumSet wygrywa.

Porównywalna byłaby wydajność dla 2 elementowego zbioru o dziedzinie większej niż 64 elementy, zatem zawsze przy wyborze EnumSet vs Set.of() należy brać pod uwagę dwa czynniki – dziedzinę i wielkość zbioru.

Set.containsAll()

W drugim badanym benchmarku brałem pod uwagę operacje .containsAll() w argumencie podając zbiór pełny. Tutaj również brałem pod uwagę dwa przypadki: gdy argumentem jest zbiór tego samego typu i gdy zbiór jest innego typu.

Wyniki przedstawiane są poniżej (liczba operacji na sekundę – im więcej tym lepiej ;))

W przypadku .containsAll() użycie Set.of() jest uzasadnione w zasadzie tylko w przypadku pustego zbioru. Wyniki również jasno pokazują, że w przypadku korzystania z EnumSeta, warto zadbać o to, by argumentem .containsAll() był również był EnumSet – wówczas dla dowolnej zawartości zbioru wydajność jest taka sama i zależyna wyłącznie od dziedziny.

Podsumowanie

Mając do dyspozycji Set.of() i EnumSet wybór należy uzależnić od dziedziny Enuma oraz wielkości zbioru. Dla 0,1,2 elementów lepiej wypada Set.of(), szczególnie, gdy dziedzina ma więcej niż 64 elementy. W pozostałych przypadkach lepiej wybrać EnumSeta.

Zasadniczo, mając do dyspozycji jedno i drugie, to warto rozważyć trzecie 😉 Jest kilka bibliotek, które implementują interfejsów standardowych Javowych kolekcji – chociażby Eclipse Collections, Apache Commons Collections lub choćby stara dobra Guava. Z ciekawostek to istnieje implementacja Mapy trzymająca elementy poza heapem – Chronicle Map.

Wszelkie źródła, benchmarki itd pod tym adresem.

Jednak ten SonarQube miał rację… 😉

Pax et bonum!

P. S. Ten tekst powstał w połowie w komunikacji miejskiej i pociągu. Dziękuję MPK i PKP 😉

Pattern matching w Javie – niskopoziomowo 0 (0)

Pierwsza propozycja Pattern Matchingu dla instrukcji switch w Javie pojawiła się w JDK 17. Po kilku iteracjach usprawnień w najświeższym LTS’ie – JDK 21 (19 września 2023)- wyszła w wersji ostatecznej. Warto zatem spojrzeć pod maskę jak to wygląda pod maską i zweryfikować pod kątem wydajności.

Czym jest pattern matching?

Zasadniczo ten blog nie dostarcza informacji o definicjach, a o niskopoziomowych działaniach, zatem po opis Pattern Matchingu zapraszam choćby tu. Jedno co mogę napisać, to że to znany koncept w wielu językach programowania od wielu lat.

W Javie pierwsza wersja preview Pattern Matchingu dla instrukcji pojawiła się w JDK 17. O ile w międzyczasie została zmieniona składnia tego feature, ostatecznie w JDK 21 przykład jego wykorzystania może wyglądać tak:

private Object iterator;

public void patternMatching(Blackhole bh) {
    switch (iterator) {
        case Boolean b when b -> {
            bh.consume(3);
            iterator = false;
        }
        case String s -> {
            bh.consume(s);
        }
        case Boolean b -> {
            bh.consume(5);
            iterator = true;
        }
        default -> throw new IllegalStateException("Unexpected value: " + iterator);
    }
}

W przeciwieństwie do standardowego switcha na enumie lub liczbach, w Pattern Matchingu ważna jest kolejność. W skrajnym przypadku gdy będzie wiele pasujących wyrażeń, zostanie dopasowane to prawdziwe wyrażenie, które występuje jako pierwsze. Wszystkie te dylematy są opisane w JEP-441.

Pattern matching – wgląd w bytecode

Jeślibyśmy skompilowali powyższy fragment, a następnie go zdekompilowali do czytelnej formy z użyciem dekompilatora udostępnionego przez Jetbrains w IntelliJ rezultat (nieco okrojony) wyglądałby tak:

public void patternMatching(Blackhole bh) {
    Object var10000 = this.iterator;
    Objects.requireNonNull(var10000);
    Object var2 = var10000;
    byte var3 = 0;

    while(true) {
        switch (((Class)var2).typeSwitch<invokedynamic>(var2, var3)) {
            case 0:
                Boolean b = (Boolean)var2;
                if (!b) {
                    var3 = 1;
                    continue;
                }

                bh.consume(3);
                this.iterator = false;
                break;
            case 1:
                String s = (String)var2;
                bh.consume(s);
                break;
            case 2:
                Boolean b = (Boolean)var2;
                bh.consume(5);
                this.iterator = true;
                break;
            default:
                throw new IllegalStateException("Unexpected value: " + String.valueOf(this.iterator));
        }

        return;
    }
}

Powyższy listing obrazuje działanie dopasowywania wzorca. Dopasowanie bazuje na mechanizmie invokedynamic, który twórcy JDK używają również w lambdach bądź w konkatenacji Stringów. Pokrótce daje to możliwość wprowadzenia usprawnień bez wymogu rekompilacji źródeł, gdyż wykonywany kod jest generowany w runtime przy pierwszym odwołaniu do klasy zawierającej go.

Jak widać dopasowanie odbywa się na podstawie dwóch argumentów – dopasowywanego obiektu oraz pomocniczej zmiennej pozwalającej określić, jakie było poprzednie dopasowanie, które się nie udało. Dopasowywanie odbywa się w pętli – jeśli warunek (guard) nie zostanie spełniony, wartość zmiennej tymczasowej jest zmieniona, a następnie cała pętla jest powtarzana. Dzięki zmianie tej zmiennej trafimy do innego case’a niż poprzednio.

Przy okazji widać również, że w powyższym przypadku tzw. Switch Expression jest tłumaczone na standardowego switcha.

Jednak podglądając ten bytecode można mieć wątpliwości, czy lepiej pod kątem wydajności będzie użyć Pattern Matchingu, czy zwykłego ciągu If-Else’ów. Warto to sprawdzić benchmarkiem JMH. Wszak jeśli mamy do czynienia z pętlą while (true) (czyt. skok bezwarunkowy), to mogą istnieć różnice.

Benchmark

Zasadniczo porównania dotyczą powyższej sytuacji, jednak najpierw w wersji okrojonej do samego Booleana z guardem, Booleana oraz domyślnej ścieżki. Zatem kod testujący wyglądał następująco:

public class PatternMatchingBenchmark {
(...)
    private Object iterator;
    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void ifElse(Blackhole bh) {
        Objects.requireNonNull(iterator);
        if (iterator instanceof Boolean b) {
            if  (b) {
                bh.consume(3);
                iterator = false;
            } else {
                bh.consume(5);
                iterator = true;
            }
        }
    }

    @Benchmark
//    @CompilerControl(CompilerControl.Mode.EXCLUDE)
    public void patternMatching(Blackhole bh) {
        switch (iterator) {
            case Boolean b when b -> {
                bh.consume(3);
                iterator = false;
            }
            case Boolean b -> {
                bh.consume(5);
                iterator = true;
            }
            default -> throw new IllegalStateException("Unexpected value: " + iterator);
        }
    }
}

W pierwszej wersji, gdzie sprawdzałem tylko tryb interpretowany wyniki były następujące (na MacBooki Pro z 2018 roku z procesorem 2,2 GHz 6-Core Intel Core i7). Dystrybucja to OpenJDK (build Oracle’owski – build 21+35-2513):

Benchmark                                  Mode  Cnt         Score       Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  15506516.529 ± 54518.117  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10   4125353.810 ± 24199.732  ops/s

Od razu widać, że PatternMatching ma pewien dodatkowy narzut sprawiający, że w trybie interpretowanym działa prawie 4 razy wolniej niż stary if. Jaka jednak jest wydajność dopasowywania wzorca z użyciem JITa (C2)?

Benchmark                                  Mode  Cnt          Score         Error  Units
PatternMatchingBenchmark.ifElse           thrpt   10  555514980.934 ± 2758608.077  ops/s
PatternMatchingBenchmark.patternMatching  thrpt   10  554135386.372 ± 2915232.272  ops/s

Jak widać tym razem ani dodatkowa pętla, ani dodatkowe zmienne nie stanowiły problemu dla kompilatora C2 – rozwijanie pętli i inline’ing to jedne z najprostszych optymalizacji.

Gdzie PatternMatching może być bardziej wydajny?

Wydaje się, że analogicznie do switcha na Enumach pewien zysk mógłby się pojawić, gdybyśmy chcieli wykonać skok bezpośrednio do case odpowiadającego dopasowanej klasie. Względnie łatwiejsze mogłoby być uszeregowanie bloków case według prawdopodobieństwa wystąpienia, żeby skoki do odpowiedniego bloku były najmniejsze, dzięki czemu możliwe, że blok kodu będzie w cache’u. Chociaż, możliwe, że bloki if-else’ow również mogą taka optymalizację uczynić po dokładniejszej analizie kodu. Aczkolwiek to optymalizacje w skali tak małej, że nie są warte pracy programistów.

Jest jedna różnica, gdzie ciąg if-else’ow mógłby być mniej wydajny – jeśli robilibyśmy switcha na polu klasy. W przypadku if-else’ow za każdym razem zczytywalibysmy wartość ze sterty, a w switchu wartość byłaby zczytana ze sterty jednokrotnie. Oczywiście przypisanie wartości pola do zmiennej lokalnej wystarczyłoby, aby tę przewagę switcha zniwelować…

Podsumowanie

Patttern Matching jest mechanizmem, który ma za zadanie zwiększyć czytelność, zmniejszyć szum informacyjny wokół kodu, a także zwiększyć ekspresywność języka.

Potencjalne zyski z korzystania z tego mechanizmu o ile byłyby możliwe, to są raczej wątpliwe – nie taki był cel wprowadzenia tego mechanizmu.

Jeśli szukacie dobrego dokładnego niskopoziomowego opisu pattern matchingu zapraszam na bloga Natalii Dziubenko.

Źródła wykorzystane przy pisaniu tego wpisu znajdują się na moim Githubie – dokładnie w podprojekcie pattern-matching.

Pax & Bonum

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 😉

Quarkus – luźne przemyślenia po 500h developmentu (cz. 2 – GraalVM – Javascript i Native Image) 0 (0)

W poprzednim wpisie podzieliłem się ogólnymi uwagami dot Quarkusa. W tym chciałbym opowiedzieć o technikaliach współpracy Quarkusa z GraalVMem i Javascriptem.

Javascript

Teoretycznie w dokumentacji nie znajdziemy informacji, że Quarkus wspiera Javascript. GraalVM wspiera JSa, to tak, ale Quarkus o tym nie wspomina.

Jeszcze w zeszłym roku instalując GraalVMa otrzymaliśmy wsparcie dla Javascriptu out-of-the-box. Aktualnie, aby skorzystać z dobrodziejstwa JSa należy go doinstalować – podobnie jak wcześniej było ze wsparciem dla Pythona, R czy Ruby’ego. Wydzielenie Javascriptu z bazowej paczki (stało się to w wersji 22.2) było spowodowane chęcią jej odchudzenia.

To odłączenie powoduje dwa problemy – jeden z Dockerfilem dla wersji wykorzystującej JVMa, drugi – w wersji skompilowanej do kodu natywnego.

Aby to wyjaśnić najpierw muszę cofnąć się i dopowiedzieć nieco o budowaniu projektów z Quarkusem.

Proces budowania obrazu z JVM

Budowanie projektu do postaci JVMowej to nic innego jak zbudowanie go Mavenem/Gradlem. Jeśli chcemy skorzystać z Dockera, to musimy uruchomić budowanie obrazu z Dockerfile’a.

Jednak bazowy obraz, z którego korzystamy dockeryzujac nasza apke, to nie standardowy GraalVM CE dostarczona przez Oracle’a, a maksymalnie odchudzona wersja OpenJDK -dostarczane przez RedHata. Zatem jeśli chcemy korzystać z GraalVMa- musimy zmienić obraz bazowy na GraalVMowy (a istnieją obrazy dostarczane przez Oracle’a). Musimy się jednak pogodzić, że nasz obraz będzie cięższy i może nieco wolniejszy.

Jeśli chcemy skorzystać z Javascriptu, to musimy go doinstalować do naszego obrazu bazowego, gdyż preinstalowanych obrazów z Jsem nie ma. Wystarczy jednak dodać kilka linijek w Dockerfile’u, zatem bez tragedii…

Proces budowania obrazu z programem natywnym

Budowanie projektu do postaci natywnej jest już bardziej skompikowane. Zamiast standardowego kompilatora potrzebujemy GraalVM z dodatkiem „Native Image”. Instalacja dodatku wygląda analogicznie do dodania wsparcia dla Javascriptu.

Budowanie kodu natywnego ma pewien mankament – jest on natywny, czyli specyficzny dla danej platformy. Innymi słowy – na Windowsie nie zbuduje się źródeł dla Linuxa. Problem ten da się rozwiązać poprzez wymuszenie kompilacji do kodu natywnego z użyciem obrazu dockerowego, co też Quarkus udostępnia. Standardowo wykorzystywany do tego celu jest obraz z Mandrillem – forkiem GraalVMa stworzonym na potrzeby Quarkusa.

Jak można się spodziewać Mandrill nie wspiera Javascriptu. Można jednak podmienić obraz budujący wykorzystywany na GraalVMowy, uprzednio wzbogacając go o wsparcie dla JSa. Jednak tak zmodyfikowany obraz trzeba trzymać w jakimś docker registry.

Podsumowując – wszystko się da, jednak musimy się pożegnać z różnego rodzaju optymalizacjami, ułatwieniami i poświęcić nieco czasu w znajdywanie różnych rozwiązań niestandardowych problemów. Wymaga to też pewnej kreatywności – jak robić, by zrobić, ale się nie narobić 😉

Użycie niewspieranej biblioteki w trybie Native

Jak już wspominałem w poprzednim wpisie, jest bardzo dużo bibliotek posiadających własne Pluginy do Quarkusa. Wówczas użycie takiej biblioteki nie sprawia żadnych problemów.

Użycie niewspieranej biblioteki w trybie JVMowym również nie jest zbytnim wyzwaniem. Prawdopodobnie wystarczy zarejestrować odpowiednie klasy jako beany z użyciem CDI ewentualnie inicjalizując.

Problemy zaczynają się, gdy taka niewspieraną bibliotekę próbujemy użyć w trybie natywnym. Problemów dostarcza zarówno Quarkus, jaki i sam GraalVM.

GraalVM umożliwia inicjalizację pól statycznych oraz uruchomienie bloków statycznych na etapie budowania zamiast w runtime (więcej o tym tu). Pozwala to ograniczyć czas inicjalizacji aplikacji. Quarkus w ramach optymalizacji deklaruje, że wszystkie możliwe pola statyczne finalne mają być zainicjalizowane. Jednak nie wszystkie pola możemy tak zainicjalizować. Chociażby generator licz losowych nie powinien być zainicjalizowany na etapie budowania, gdyż spowodowałby to generowanie tych samych liczb w tej samej kolejności przy kolejnych uruchomieniach aplikacji. Jeśli zatem chcemy zainicjalizować klasę w runtime’ie, musimy to jawnie zadeklarować, co wymaga dodatkowej pracy.

Za to powszechnym wymaganiem wynikacjących z użycia GraalVM Native Image jest wymienienie wszystkich klas, które muszą posiadać wsparcie dla refleksji, proxowania, serializacji, JNI lub które są resource’ami i które trzeba zawrzeć w aplikacji natywnej. Można próbować samemu stworzyć wymagane pliki json, jednak jest to karkołomne zadanie.

Twórcy GraalVMa stworzyli agenta, który sam spisuje za nas dla jakich klas używamy refleksji, jakie proxy tworzymy itd. Należy jednak zadbać o to, aby po uruchomieniu „wywołać” te miejsca (użycia refleksji itd.), aby agent mógł je zarejestrować. Innymi słowy trzeba przetestować całą aplikacje wzdłuż i wszerz… Takie testy regresyjne należałoby przeprowadzać przynajmniej przy dodawaniu nowej biblioteki lub zmiany konfiguracji. Zatem to rozwiązanie jest mocno uwierające.

Oprócz tego dość niedawno pojawiło się ogólnodostępne repozytorium z metadanymi dla najbardziej popularnych bibliotek. Nie miałem jednak okazji skorzystać z tego dobrodziejstwa, jednak wydaje mi się, że nie rozwiąże to problemów dla niszowych bibliotek…

Czas to pieniądz…

W kontekście kompilacji do kodu natywnego to powiedzenie można rozumieć wielorako.

Kod natywny praktycznie się nie musi rozgrzewać, inicjalizacja aplikacji to dosłownie milisekundy. W zasadzie jest od razu gotowy do użycia. Jeśli przewaga naszego produktu polega na szybszym uruchamianiu, skalowaniu, to kompilacja do kodu natywnego ma sens. Zatem widziałbym sens takiego rozwiązania w serverless, gdzie czas inicjalizacji ma znaczenie, lub narzędziach uruchamianych na desktopach (mvnd, czyli maven na sterydach – polecam). Sensowne byłoby też wykorzystanie programu natywnego na sprzęcie o małych dostępnych zasobach – aplikacja natywna minimalizuje zużycie pamięci oraz wielkość aplikacji. Można by również rozważyć w krótkich Jobach uruchamianych raz dziennie, aby minimalizować ilość używanej pamięci, lub aplikacjach o małym natężeniu ruchu, które JVM nie zdąży rozgrzać w czasie między kolejnymi deployami.

W przypadku długożyjących aplikacji wybór Native Image jest nieuzasadniony, (o ile nie błedny). Kompilacja AOT co prawda działa szybciej na początku, to ostatecznie jest wolniejsza, gdyż nie ma dostępu do informacji o działaniu aplikacji w runtime (chociaż w GraalVM Enterprise Edition wydaje się, że można dostarczyć jakieś dane z działania aplikacji, jednak wciąż nie może dorównać to JITowi).

Należy jednak brać również pod uwagę dodatkowy czas kompilacji do kodu natywnego – zawsze jest to przynajmniej kilka minut. Można optymistycznie założyć, że kompilacja będzie wykonywana w ramach CI/CD (czyli nie przez dewelopera), ale jeśli coś się wysypie, to i tak deweloper musi to poprawić. Dodatkowo jeszcze wspomniany czas na testy regresyjne w celu poinformowania agenta GraalVMa o klasach rejestrowanych do użycia refleksji.

Liczby

Z własnego doświadczenia mogę podzielić się pewnymi danymi. Nie są to jednak dokładne benchmarki wykonywane na czystym środowisku. Jednak dają pogląd na rząd wielkości i przyspieszenie/spowolnienie względem trybu native/jvm. Ich udostępnienie ma dać jedynie ogólny pogląd, a wszelka zbieżność imion i nazwisk – przypadkowa 😉

Jeszcze jeden szczegół, który może mieć wpływ na wyniki – operacje te wykorzystują Javascript. Zarówno w trybie natywnym, jak i JVMowym GraalVMowy Javascript posiada swój JIT, gdy kod Javy podlega JITowi wyłącznie w trybie JVM. Może to spowodować, że różnice czasów mogą być mniejsze (amplituda), aniżeli gdybyśmy wykorzystywali tylko kod napisany w Javie.

Operacja nr 11234
JVM2m 08s1m 20s56s51s
native1m 40s1m 32s1m 22s1m 11s
Czas wykonania operacji nr 1 w kolejnych ponowionych żądaniach
Operacja nr 21234(…)min
JVM15s3.5s2.3s2s0,7s
native7s3s2s1.6s1,1s
Czas wykonania operacji nr 2 w kolejnych ponowionych żądaniach.
Min oznacza minimalny otrzymany czas wykonania operacji.

Widać zatem pewną logiczną zależność – pierwsze operacje są wykonywane szybciej w trybie natywnym, ale już od 2 lub 3 czasy są porównywalne, a na dłuższą metę tryb JVM jest kilkadziesiąt procent szybszy.

Mogę się jeszcze podzielić jeszcze jednym wynikiem. Otóż operacja nr 1 była wykonywana z różnymi danymi kilka tysięcy razy. W trybie JVM całościowy czas to było 16 minut, gdy w trybie natywnym – 43 minuty.

Podsumowanie

GraalVM Native Image jest bezsprzecznie ciekawą technologią, która w pewnych przypadkach może mieć pozytywny wpływ na produkt – chodzi zarówno o czas inicjalizacji, jak ilość pamięci potrzebnej do działania aplikacji. Trzeba być jednak świadomym dodatkowych kosztów – rosnącego skomplikowania technicznego projektu oraz spowolnienia dewelopowania spowodowanego dłuższym czasem kompilacji. Warto zatem rozważyć, czy tryb natywny jest koniecznie niezbędny w naszym przypadku biznesowym.

Pax et bonum.

Quarkus – luźne przemyślenia po 500h developmentu (cz. 1) 0 (0)

Łatwo znaleźć informacje o pierwszych doświadczeniach z daną technologią. Jeśli chodzi o doświadczenia po spędzeniu z nią większej ilości czasu, to jest tychże znacznie mniej…

W tym wpisie przedstawię subiektywne odczucia odnośnie frameworku Quarkus.

Kontekst projektu

Projekt polega na ogólnie mówiąc generowaniu wydruków (czyt. więcej przetwarzania niż oczekiwania na I/O). Mimo to interakcja z innymi serwisami jest również konieczna. Dodatkowym wymaganiem jest wsparcie dla Javascriptu na backendzie.

Dlaczego Quarkus?

Kluczowe byłyby dwa wymagania: wsparcie dla Javascriptu oraz szybkość generowania wydruku.

Pierwszy wymóg mógł zostać łatwo spełniony poprzez wybór GraalVM jako JVM – GraalVM zapewnia wsparcie dla różnych języków w ramach całej platformy. Co więcej – nie jest to symulowanie działania danego języka wewnatrz JVMa poprzez interpretowanie kolejnych instrukcji, ale to JVM (właściwie to GraalVM) rozumie dany język i wykonuje instrukcje na dość niskim poziomie. Stąd wydajność rozwiązania jest znacznie lepsza niż w wersji interpretowanej.

Alternatywnie wsparcie dla Javascriptu można było zapewnić poprzez dodanie zależności mavenowej/gradle’owej Graal.js. Ta wersja pozwala na wykorzystanie zwykłej JVMki zamiast GraalVMa, jednak kosztem wydajności – uruchomienie skryptów Javy (hi hi) odbywa się poprzez interpretowanie kodu.

Drugie wymaganie dotyczyło prędkości generowania wydruku. Należałoby tu rozważyć dwie sytuacje – na krótszą i dłuższą metę. Czas pierwszych wydruków możnaby zminimalizować korzystając z GraalVMa Native Image. Za to na dłuższą metę można skorzystać ze standardowej GraalVM bez kompilacji do kodu natywnego, gdyż JIT zapewnia lepszą wydajność niż kompilacja Ahead-of-time (o więcej o tym później).

Na etapie wyboru technologii Spring Native nie był jeszcze wydany, zatem najbardziej powszechny framework musiał zostać odrzucony. Był oczywiście jeszcze Micronaut, jednak wybór padł na Quarkusa. Jeśli szukacie porównania tych technologii, polecam artykuł o wyborze frameworku dla Stargate v2.

Jak poukładany jest Quarkus

Quarkus w swych marketingowych materiałach używa sentencji

Developer Joy: Unified configuration, Zero config, live reload in the blink of an eye, Streamlined code for the 80% common usages, flexible for the 20%, No hassle native executable generation, live coding.

Jest w tym dużo racji. Jest bardzo wiele tutoriali, które ogrywają standardowe sytuacje i działają jak należy. Live reloading odświeża kontekst aplikacji i przeładowuje źródła (choć dopiero przy wysłaniu requesta, a nie na zapis pliku, co może być nieintuicyjne). Generator Quarkusa oprócz hierarchii katalogów i wydmuszki aplikacji, tworzy również gotowe profile developerskie, produkcyjne, kompilacje do Native Image, a także obrazy dockerowe – właściwie to Dockerfile’e (z apka w wersjach JVM oraz natywnej).

Sam Quarkus jest skonstruowany tak, by możliwie najwięcej działań potrzebnych mu do uruchomienia było wykonanych przed uruchomieniem aplikacji. Chociażby określenie dostępnych beanów odbywa się poprzez indeksowanie ich w czasie kompilacji, a nie poprzez skanowanie classpatha w czasie inicjalizacji aplikacji, jak to ma miejsce w Spring Boot. Generowanie Proxy do obsługi przykładowo @Transactional równiez generowane są na etapie kompilacji źródeł. Wszystkie beany są domyślnie inicjowane w trybie Lazy – dopóki czegoś nie potrzebujemy, to nie jest to tworzone. Dodatkowo same biblioteki frameworku i sam Quarkus jest ładowany przez osobny classloader niż klasy naszej aplikacji. Dzięki temu przy przeładowaniu kodu wszystkie biblioteki nie muszą być ponownie ładowane.

Oszczędzamy dzięki temu wszystkiemu czas na przeładowaniu kontekstu i w efekcie apka wstaje szybciej.

Tryb deweloperski, a właściwie jego konsola jest dość przydatna. Oprócz wymuszenia przeładowania i puszczenia wszystkich testów w projekcie jest tam bardzo przydatna funkcja zmiany poziomu logowania z użyciem jednego klawisza w czasie działania serwera. Opcja powszechna w różnego rodzaju JMXach, Actuatorach, havt.io i innych, ale jednoklawiszowej opcji dotychczas nie spotkałem 😉

Tryb deweloperski ma jeszcze jeden smaczek: jeśli port jest zajęty, to Quarkus podpowie komendę netstat która sprawdzi PID procesu zajmującego ten port – mała rzecz, a cieszy 🙂

Jakkolwiek muszę też wspomnieć o tych 20%, gdzie trzeba wyjść przed szereg i skonfigurować coś niestandardowego. Jeśli mamy jakąś starą zależność (ot, jakieś JMSy sprzed 10 lat), z której musimy korzystać, bo cała reszta naszej platformy z niej korzysta, to jest to uciążliwa sprawa. Wszytko jest „zrabialne”, ale aby wykonać to porządnie, trzeba się mocno napracować. Wsparcie dla trybu deweloperskiego, natywnego, współpraca miedzy wątkami, przekazywanie kontekstu – wszystko zrobić się da, acz niezerowym nakładem.

Największym dramatem jest to, że większość tych customowych problemów da się rozwiązać, jednak dopiero po trafieniu na odpowiednie miejsce w internecie, które co prawda istnieje, jednak nawet google nie wie jak tam trafić… Najbardziej jednak boli to, że to przeważnie kilka linijek kodu…

Pluginy

Jak już wspomniałem, Quarkus posiada bardzo liczne integracje z popularnymi bibliotekami. Dodawanie integracji do projektu odbywa się poprzez dodanie odpowiedniego „pluginu” (plugin to nic innego jak zwykła zależność mavenowa/gradle’owa). Każdy plugin jest automatycznie przystosowany zarówno do trybu deweloperskiego, jak i produkcyjnego (jej i native). Dokumentacja ogólna, jak korzystać z pluginu, jak i szczególna szczegółowe propertiesy konfigurujące plugin są dostępne na tej stronie (jest też opcja wyszukiwania).

Podstawowe technologie

Quarkus integruje ze sobą przeróżne wypróbowane technologie i na nich się koncentruje. Dla obsługi wystawiania serwisów – adnotacje JAX-RS z serializacja Jacksonem. Wstrzykiwanie zależności – CDI. Konfiguracja projektu oparta jest na Smallrye Config. Logowanie – JBoss Logging (acz dostępne adaptery dla Log4J i SLF4J). Domyślny ORM to Hibernate, z opcją Hibernate Reactive. Jak już mowa o reaktywności to tutaj wykorzystywana jest biblioteka Mutiny, co więcej wszystkie reaktywne pluginy wystawiają interfejs z użyciem Mutiny. Całość postawiona jest na Vert.x.

Używanie bibliotek uznawanych za standard ma swoje zalety, lecz również wady. Przede wszystkim używanie bibliotek porządnie wygrzanych na produkcji jest potencjalnie bardziej odporne na błędy, niż pisanie swoich bibliotek. Odchodzi też kwestia utrzymania, dokumentacji i rozwoju – zajmuja się tym inne organizacje, a Quarkus jedynie integruje wszystko razem.

Problem z tym podejściem jest taki, że nie wszystko znajdziemy na stronie Quarkusa. Trzeba czasem szukać podpowiedzi na stronie danej biblioteki, a nawet specyfikacji standardu, który biblioteka implementuje (przykładowo CDI). Zdarza się czasem taka szara strefa jeśli chodzi o odpowiedzialności, kto za co odpowiada. Przykładowo jeśli dodamy OpenTelemetry z traceId, które logujemy, to w przypadku użycia vert.x’owych dodatkowych feature’ów, ten traceId się gubi na kilka wpisów w logach. A to właśnie za sprawą tego, że vert.x’owe dodatki działają na osobnej Poli wątków, których uogólniając OpenTelemetry nie jest świadome.

Ciąg dalszy nastąpi…

Tyle by było ogólnych informacjo-opinii na temat Quarkusa. W kolejnym wpisie/kolejnych wpisach będzie więcej szczegółów technicznych, a i ostatecznego podsumowania się spodziewajcie 😉

Pax et bonum

Taka refleksja, ale szybsza… JDK18 update 0 (0)

Implementacja wywoływania metod z użyciem refleksji ma całkiem bogatą historię. Dostępna od początku w Javie (początkowo bardzo niewydajna), gruntownie przepisana w Javie 1.4, następnie zyskała konkurencję w postaci MethodHandle. Ostatecznie konkurenci będą musieli się pogodzić, gdyż w Jdk 18 w ramach JEP-416 wywołanie metod z użyciem refleksji tj. Method::invoke zostanie przepisane na używające pod spodem MethodHandle.

Co aktualnie nie domaga w refleksji?

Zacznijmy od tego, jak aktualnie działa wywołanie Method::invoke.

  1. Sprawdzany jest dostęp do tej metody – czy w ogóle możemy ją wywołać.
  2. Jeśli ta metoda była wywoływana często, to ostatecznie treść wywoływanej metody jest kompilowana JITem. Trzeba zatem sprawdzić, czy taki kod istnieje i go wywołać.
  3. Jeśli metoda nie jest skompilowana JITem, to wywoływana jest metoda natywna uruchamiająca kod metody.

Warto zauważyć, że dostęp jest sprawdzany przy każdym wywołaniu. Jest to nadmiarowe, gdyż wystarczyłoby sprawdzić kontekst na poziomie klasy tylko raz i taki kontekst wywoływania zcache’ować w klasie.

Drugą niedogodnością jest brak możliwości wniknięcia JITa do treści wywoływanej metody. JIT po prostu nie wie, jakie operacje są wywoływane w tej metodzie – traktuje wywołaną metodę jako „black box”.

Method Handle

Te dwie wady Method::invoke adresuje mechanizm MethodHandle wprowadzony w Jdk 1.7. Po pierwsze kontekst (Lookup) jest wymagany do znalezienia uchwytu na metodę, zatem dostęp sprawdzany jest jednokrotnie.

Po drugie MethodHandle rozumie kod wykonywany, dzięki czemu JIT ma możliwość zinline’owania wykonywanego kodu.

O ile ten mechanizm pozwala na wykonanie optymalnego kodu, o tyle problemem jest poziom trudności. Znacznie trudniej stworzyć kod wywołujący MethodHandle i łatwiej w nim o pomyłkę. Zrozumienie takiego kodu również jest trudniejsze.

Dodatkowo, o ile JIT ma możliwość rozumienia treści MethodHandle, o tyle ta informacja jest wykorzystywana praktycznie tylko w przypadku MethodHandle przetrzymywanych w polach statycznych finalnych (constant). W innych sytuacjach JIT nie wykorzystuje informacji o wykonywanym kodzie (a przynajmniej OpenJdk tak nie robi).

JEP-416

Próbę pożenienia Method::invoke z MethodHandle podejmuje wspomniany JEP-416. Dostarczony zostanie wraz z Jdk 18 w najbliższych dniach.

Zgodnie z oczekiwaniami otrzymujemy usprawnienia działania z MethodHandle wraz z prostotą wykonania Method::invoke. Dodatkowo autorzy JEPa wspominają jeszcze o zalecie „mniejszej ilości StackFrame’ów” oraz ułatwieniu dalszego rozwoju platformy poprzez usunięciu specjalnego traktowania refleksji.

Wydajność

Normalnie w tym akapicie przeszedłbym do benchmarkowania rozwiązania, gdyby nie to, że wyniki benchmarków są już zawarte w JEPie. O ile znacząca poprawa wydajności w przypadku Method, Constructor i Field trzymanych w polach static final (43–57% poprawa) zadowala, o tyle zawieść mogą benchmarki innych wywołań (51–77% pogorszenie).

Autorzy zapewniają, że w rzeczywistych aplikacjach zmiany nie mają znaczenia (a sprawdzili w Jacksonie, XStream i Kryo). Obiecują również poprawę na polach na których wystąpiło pogorszenie.

Podsumowanie

Wprowadzone zmiany należy mimo wszystko uznać za pozytywne. O ile zmniejszenie wydajności ma negatywny wydźwięk, o tyle zwiększenie utrzymywalności, potencjał na zwiększenie wydajności oraz uzasadnienie przygotowaniem pod Valhallę i Loom kompensują ten negatywny efekt.

Żebyśmy jeszcze dożyli tychże…

Jeśli ten wpis Cię zainteresował, polecam moje dwa poprzednie wpisy o refleksji – Taka refleksja tylko szybsza… i Przepisujemy Jacksona z refleksji na LambdaMetafactory [ZOBACZ JAK].

Pax et bonum!

Kilka słów o Recordach w Javie 0 (0)

Jako preview feature pojawiły się w JDK 14, by po dwóch odsłonach Javy wejść do standardu w wersji 16. Pół roku później została wydana Java 17, która będąc wydaniem Long Term Support prawdopodobnie będzie się cieszyła dużym zainteresowaniem.

Czas zatem przyjrzeć się, co w tych rekordach siedzi…

Krótkie wprowadzenie

Czym są rekordy w Javie? – to pytanie iście filozoficzne. Sami Twórcy musieli się nagłowić, co to właściwie jest i jak ten twór nazwać. Wszystkie te rozterki ładnie słychać w wywiadzie z Brianem Goetzem, który polecam. Dużo teorii o można wyczytać również ze źródłowego JEPa. Tam w podsumowaniu można między innymi przeczytać:

(…)records, which are classes that act as transparent carriers for immutable data. Records can be thought of as nominal tuples.”

JEP 395

Czyli krótko: rekord to pojemnik na dane, przy czym dane są określone nazwą (w przeciwieństwie do Tuple).

Wielu mądrzejszych ode mnie ludzi pisało już o Rekordach, zatem nie będę pisał o rzeczach już opisanych, a podrzucę kilka linków (po angielsku i po polsku – pierwsze z brzegu znalezione na jvm-bloggers).

Implementacja Recordów

Warto zacząć od tego, rekordy są (nie)zwykłą klasą Javową. Zwięzły zapis:

public record Record(int i, String j)

kompilowany jest do:

public final class Record extends java.lang.Record {
    private final int i;
    private final String j;

    public Record(int i, String j) {
        this.i = i;
        this.j = j;
    }

    public int i() {
        return i;
    }

    public String j() {
        return j;
    }

    public final java.lang.String toString() {
        // (implementacja)
    }

    public final int hashCode() {
        // (implementacja)
    }

    public final boolean equals(java.lang.Object) {
        // (implementacja)
    }
}

Jakby porównać te dwa zapisy, to ewidentnie łatwiej i szybciej utworzyć rekord niż tę drugą implementację.

Dodatkowo stworzone rekordy posiadają pewne feature’y, których normalnie zaimplementować się nie da. Przede wszystkim implementacje toString, hashCode i equals nie są generowane w czasie kompilacji do bytecode’u, a przy pierwszym użyciu danej metody. Podobny mechanizm można znaleźć w konkatenacji stringów lub w lambdach. Dodatkowo, do wygenerowania każdej z nich jest używana jedna metoda, chociaż w parametrach jest przekazywany pewien znacznik określający, którą metodę wygenerować. Wszystko to pozwala nieco odchudzić sam bytecode – nawet o 10%. Jednakże polemizowałbym, czy jest sensowne w czasach terabajtowych dysków twardych oszczędzać 100KB na bajtkodzie.

Mechanizm refleksji również został wzbogacony o informacje typowe dla rekordów. Można nie tylko wyciągnąć jakie pola istnieją w tym rekordzie (getRecordComponents), ale również jest informacja, jaka metoda pozwala na pobranie wartości tego pola. Warto tutaj nadmienić, że rekordy nie spełniają standardu JavaBean – nie mają getterów. Jednak można to uznać za zaletę, gdyż nie musimy się zastanawiać, czy akcesor zaczyna się o „get” czy „is”.

Kolejność pól oraz argumentów konstruktora jest taka sama, da się ją pobrać refleksją i daje dobre podstawy do PatternMatchingu na rekordach. Istnieje na to już JEP i aktualnie jest w stanie „kandydat do JDK 18”.

Wsparcie dla serializacji jest również dostarczane automagicznie. Wystarczy dodać interfejs Serializable, a JVM zapewni, że nikt niczego nikczemnego nie poczyni z obiektem. Dodatkowo deserializacja wywołuje domyślnie wygenerowany konstruktor, co jest niestandardowym zachowaniem w serializacji Javowej.

„Wyzwania”

Rekordy mają też swoje niedogodności.

Pierwszą z nich dodawanie adnotacji. Jeśli chcemy dodać adnotację to robimy to następująco public record Record(@SomeAnno int i, String j) . Niestety w ten sam sposób adnotujemy zarówno pole, jak i metodę dostępu do pola, ale również parametr konstruktora. Adnotacja zostanie dodana we wszystkich miejscach, do których pasuje – jeśli adnotacja może być zastosowana do pola, wówczas pole zostanie zaadnotowane; jeśli adnotacja przeznaczona jest do metod, wówczas akcesor zostanie zaadnotowany. Mimo wszystko może to tworzyć pewne konflikty.

Drugim problemem, na który się natknąłem, jest stawianie breakpointa przy debugowaniu. Aktualnie wszystkie akcesory, pola i konstruktor mają w bytecodzie przypisaną tę samą linię początku konstruktora. Zatem zastawienie się na „getterze”, a właściwie to akcesorze może być mało komfortowe.

Pewną niedogodnością może być brak prostego stworzenia nowego rekordu na podstawie starego bez przepisywania wszystkich wartości. Jednak tutaj z pomocą przychodzi Lombok, który aktualnie wspiera rekordy zarówno @Builderem, jaki i @Withem.

Wsparcie bibliotek

Rekordy są aktualnie wspierane przez Jacksona (od wersji 2.12) w serializacji i deserializacji bez dodatkowych akcji.

Hibernate również wspiera rekordy, ale jedynie w odczycie tj. rezultat zapytania można zmapować do rekordu/ów. O ile nie mogą one pełnić funkcji encji, o tyle można je wykorzystać jako obiekt wartości i w tej roli wykorzystać je w polach encji.

W Springu konfigurację można zmapować do rekordów. Można je również wykorzystać jako DTO do przekazywania wartości do silników template’ów takich jak Thymeleaf.

Garść linków

Wpisów o rekordach w internecie jest wiele, postaram się zalinkować najciekawsze:

  • Na początek tekst o rekordach + porównanie z case klasami ze Scali i data klasami z Kotlina – link,
  • Użycie rekordów w JPA/Hibernate – link oraz link,
  • Nieco teorii, czyli JEP-395,
  • Zapiski Briana Goetza o rekordach – link,
  • Gdzie można zastosować dokładniej rekordy w Springu – link,
  • Obszerne omówienie rekordów (po polsku!) w serii wpisów Piotra Przybył – intro, możliwości rekordów, refleksja, lombok; wpisów jest więcej, ale po angielsku 😉

Pax!

Sygnatura metody, jej parametry i kwestia wydajności 0 (0)

Ile parametrów powinna mieć metoda?

Wujek Bob w „Czystym kodzie” twierdzi, że najlepiej zero. Dopuszcza też metody jedno i maksymalnie dwuparametrowe. Trzy i więcej parametry to już tłok, który przy przekazywaniu należy zgrupować w obiekt.

Pod kątem czystości kodu mamy zatem pewne wytyczne. Jak to wygląda od strony wydajności kodu? Czy opłaca się grupować parametry w obiekty, które można przekazać, czy wręcz przeciwnie?

Benchmark przekazywania parametrów

Załóżmy, że mamy dwie nieskomplikowane metody – w jednej przekazujemy parametry, w drugiej wrapper z tymi parametrami:

private int func(int i1, int i2, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, i2, counter);
    }
}

private int func(Wrapper2 i1, int counter) {
    counter--;
    if (counter == 0) {
        return counter;
    } else {
        return func(i1, counter);
    }
}
private static class Wrapper2 {
    private final int i1;
    private final int i2;

    public Wrapper2(int i1, int i2) {
        this.i1 = i1;
        this.i2 = i2;
    }
}

Metody te pozwalają na wykonanie nieskomplikowanej czynności dekrementacji oraz rekurencyjnie wywołują te metody, aż licznik się wyzeruje. Badać będziemy przypadki z 1,2,4,8 i 16 argumentami/polami wrapperów.

Uruchamiając benchmark z licznikiem o wartości 1 zauważymy te same wyniki (u mnie ok 3,327 ± 0,013 ns/op). Jest to efekt inline’owania, dzięki któremu można zaoszczędzić wywołań metod. Kod maszynowy skompilowany przez c2 dla wszystkich metod jest taki sam.

Zatem, jeśli chcielibyśmy zbadać, czy wywołania metod z różnymi sygnaturami się różnią, to… trzeba wywołać te metody 😉 Można to zrobić na dwa sposoby – albo zwiększyć counter do wartości uniemożliwiającej inline’owanie albo zakazać inline’owania.

Po zmianie wartości counter na 10 wyniki już się znacząco różnią:

Benchmark                                Mode  Cnt   Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  12,498 ± 0,123  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  12,817 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  12,960 ± 0,146  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  23,064 ± 0,145  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  55,409 ± 0,750  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  12,529 ± 0,088  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  12,519 ± 0,285  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  12,481 ± 0,164  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  12,521 ± 0,130  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  12,455 ± 0,144  ns/op

Jeśli natomiast zakażemy inline’owania, to wyniki różnią się, choć mniej znacząco:

Benchmark                                Mode  Cnt  Score   Error  Units
WriteParamsBenchmark.primitiveParams_01  avgt   10  5,657 ± 0,100  ns/op
WriteParamsBenchmark.primitiveParams_02  avgt   10  5,630 ± 0,044  ns/op
WriteParamsBenchmark.primitiveParams_04  avgt   10  5,964 ± 0,059  ns/op
WriteParamsBenchmark.primitiveParams_08  avgt   10  6,395 ± 0,061  ns/op
WriteParamsBenchmark.primitiveParams_16  avgt   10  8,766 ± 0,143  ns/op
WriteParamsBenchmark.wrappedParams_01    avgt   10  5,624 ± 0,036  ns/op
WriteParamsBenchmark.wrappedParams_02    avgt   10  5,650 ± 0,052  ns/op
WriteParamsBenchmark.wrappedParams_04    avgt   10  5,630 ± 0,097  ns/op
WriteParamsBenchmark.wrappedParams_08    avgt   10  5,673 ± 0,077  ns/op
WriteParamsBenchmark.wrappedParams_16    avgt   10  5,624 ± 0,056  ns/op

Z obu benchmarków wynika jednoznacznie, że przekazywanie większej ilości parametrów kosztuje (porównując 1 parametr z 16 parametrami widać różnicę od 50% do 440%). Jednocześnie zgrupowanie parametrów w obiekt pozwala ten koszt zredukować – przekazujemy wówczas tak naprawdę tylko jeden parametr – wskaźnik na wrapper.

Korzystanie z parametrów

W powyższym benchmarku sprawdziliśmy jak tylko jak przekazywanie parametru wpływa na wydajność, ale nie sprawdziliśmy wpływu na korzystanie przekazanych danych. Potencjalnie te dane mogą trafić do jakichś cache’ów, więc może większy koszt przekazania danych przez parametr zostanie jakoś zrekompensowany.

Sprawdza to drugi benchmark będący modyfikacją pierwszego. W tym przypadku zamiast zwracać counter zwracamy sumę parametrów metody.

    private int func(int i1, int i2, int counter) {
        counter--;
        if (counter == 0) {
            return i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2 + i1 + i2;
        } else {
            return func(i1, i2, counter);
        }
    }

    private int func(Wrapper2 i1, int counter) {
        counter--;
        if (counter == 0) {
            return i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2
                    + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2 + i1.i1 + i1.i2;
        } else {
            return func(i1, counter);
        }
    }

Gdy inline’owanie pozostało włączone, a licznik wynosił 1 wyniki były następujące:

Benchmark                               Mode  Cnt  Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10  3,356 ± 0,023  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10  3,343 ± 0,013  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10  3,429 ± 0,019  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10  3,340 ± 0,014  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  3,358 ± 0,017  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10  5,639 ± 0,096  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10  5,652 ± 0,051  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10  5,995 ± 0,049  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10  6,240 ± 0,268  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10  6,621 ± 0,026  ns/op

Widać zatem pewien zysk na przekazywaniu parametrów zamiast obiektu. Wynika on poniekąd z faktu, że przekazywany obiekt już był utworzony jeden raz i udostępniony na Heapie, zatem odczyty w wrappedParams dotyczą obiektu z Heapa. Sytuacja wygląda inaczej, jeśli będziemy tworzyć nowe obiekty, które po escape analysis zostaną zamienione na zmienne lokalne – wówczas nie ma różnicy w czasie wykonywania metod zarówno dla parametrów, jak i wrapperów (jest to około 3,358 ± 0,017 ns/op).

Jeśli jednak zakażemy inline’owania, to rezultaty są następujące:

Benchmark                               Mode  Cnt   Score   Error  Units
ReadParamsBenchmark.primitiveParams_01  avgt   10   7,561 ± 0,126  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   7,651 ± 0,038  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   7,847 ± 0,031  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   8,800 ± 0,075  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  10,405 ± 0,060  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   8,044 ± 0,104  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   8,084 ± 0,068  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   8,356 ± 0,076  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   8,573 ± 0,065  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   9,073 ± 0,042  ns/op

W tym wypadku korzystanie z przekazywania parametrów, bez umieszczania ich w obiekcie jest korzystniejsze. Wynika to (podobnie jak wcześniej) z tego, że przekazujemy wartości, a nie wskaźnik na obiekt z wartościami. Zatem oszczędzamy na dostępie do pamięci.

Jednak często się zdarza, że wykorzystujemy wartości dopiero po kilkukrotnym przekazaniu (przepchaniu) ich przez inne metody – przekazujemy parametry tylko po to, aby wykorzystać je w kolejnych wywołaniach metody. Warto zatem się przyjrzeć sytuacji, gdzie zanim skorzystamy z parametrów, musimy je przekazać w kolejnych wywołaniach metod.

Taką sytuację symuluje wywołanie benchmarku z licznikiem równym 10. Wyniki są następujące:

ReadParamsBenchmark.primitiveParams_01  avgt   10   27,279 ± 0,407  ns/op
ReadParamsBenchmark.primitiveParams_02  avgt   10   27,115 ± 0,803  ns/op
ReadParamsBenchmark.primitiveParams_04  avgt   10   26,793 ± 0,100  ns/op
ReadParamsBenchmark.primitiveParams_08  avgt   10   63,575 ± 0,380  ns/op
ReadParamsBenchmark.primitiveParams_16  avgt   10  203,437 ± 1,001  ns/op
ReadParamsBenchmark.wrappedParams_01    avgt   10   27,635 ± 0,194  ns/op
ReadParamsBenchmark.wrappedParams_02    avgt   10   27,311 ± 0,193  ns/op
ReadParamsBenchmark.wrappedParams_04    avgt   10   27,731 ± 0,219  ns/op
ReadParamsBenchmark.wrappedParams_08    avgt   10   27,744 ± 0,181  ns/op
ReadParamsBenchmark.wrappedParams_16    avgt   10   29,646 ± 0,106  ns/op

W tym wypadku wyniki są mniej korzystne dla przekazywania parametrów. Zysk z odczytu parametrów został został praktycznie zniwelowany przez koszt przekazania tych parametrów.

Przekazanie parametrów według C2

Warto zwrócić uwagę na jeden fakt – czasy wykonania benchmarków znacznie się zwiększają w przypadku przekazania 8 lub 16 parametrów. Czasy się zwiększają nieliniowo w stosunku do ilości parametrów.

Warto spojrzeć w kod maszynowy wygenerowany przez C2. Tam przekazywanie parametrów jest wykonywane w następujący sposób:

  0x00007efdb9222d8c: mov     $0x1,%edx
  0x00007efdb9222d91: mov     $0x1,%ecx
  0x00007efdb9222d96: mov     $0x1,%r8d
  0x00007efdb9222d9c: mov     $0x1,%r9d
  0x00007efdb9222da2: mov     $0x1,%edi
  0x00007efdb9222da7: mov     $0x1,%r10d
  0x00007efdb9222dad: mov     %r10d,(%rsp)
  0x00007efdb9222db1: mov     $0x1,%r11d
  0x00007efdb9222db7: mov     %r11d,0x8(%rsp)
  0x00007efdb9222dbc: mov     %r10d,0x10(%rsp)
  0x00007efdb9222dc1: mov     %r11d,0x18(%rsp)
  0x00007efdb9222dc6: mov     %r10d,0x20(%rsp)
  0x00007efdb9222dcb: mov     %r11d,0x28(%rsp)
  0x00007efdb9222dd0: mov     %r10d,0x30(%rsp)
  0x00007efdb9222dd5: mov     %r11d,0x38(%rsp)
  0x00007efdb9222dda: mov     %r10d,0x40(%rsp)
  0x00007efdb9222ddf: mov     %r11d,0x48(%rsp)
  0x00007efdb9222de4: mov     %r10d,0x50(%rsp)
  0x00007efdb9222de9: mov     $0xa,%r11d
  0x00007efdb9222def: mov     %r11d,0x58(%rsp)

Z powyższego listingu wynik jeden wniosek – pierwsze parametry przekazywane są bezpośrednio w rejestrze, gdy kolejne poza rejestrami procesora. To jest prawdopodobnie przyczyna zwiększonych czasów przy większej ilości parametrów. Oczywiście możliwe, że taka sytuacja występuje na moim przedpotopowym laptopie, a dla maszyn serwerowych o lepszych procesorach C2 wygeneruje kod wykorzystujący większą ilość rejestrów.

Podsumowanie

Wydaje się, że mniejsza ilość parametrów metod może wpływać nie tylko na czytelność kodu, ale również na wydajność. Oczywiście różnice są nieznaczne, ale jednak istnieją. Zależy również, jak często korzystamy z tych parametrów. Jeśli parametry przekazujemy częściej, niż z nich korzystamy, to lepiej użyć obiektu opakowującego parametry.

Pax!

Serverlesslessless… 0 (0)

Czy da się napisać taką apkę webową, która będzie działała w internecie bez kodu w backendzie? Oczywiście! Odpowiedź to serverless!

A czy da się za ten serverless nie płacić? Oczywiście – do jakiejś skali są darmowe plany, a w nich darmowe usługi.

Ale nie trzeba serverlessa – jest inna droga. W tym wpisie pokrótce opiszę prywatny projekcik realizujący tę nieserverlessową drogę.

Inspiracja

Prywatne projekty zwykle cierpią na nadmiar inspiracji i niedobór zasobów. Brakuje przede wszystkim zasobów ludzkich (1/16 etatu 1 osoby to raczej niewiele…). Budżet również jest ograniczony – najlepiej by wydatki ograniczyły się do liczby nieujemnej i niedodatniej jednocześnie (waluta obojętna)… Potencjał skalowania projektu również marny.

A jednak człowiek czasem coś by zakodził… Na przykład kolejny w internecie projekcik apki webowej do własnych przepisów kulinarnych. Albo do trzymania tekstów piosenek (lub akordów). Albo do zdjęć kapsli od piwa, ewentualnie znaczków pocztowych.

Niby dane niewrażliwe, a jednak nie chcemy, żeby ktoś oglądał nasze kapsle od piwa. Zatem jakieś uwierzytelnienie by się przydało. Jeszcze lepiej by było, gdybyśmy mogli również komuś udostępniać pewne dane. I aby się dało otworzyć wszędzie na świecie. I aby dało się łatwo administrować tymi danymi. A gdyby to wszystko było rozszerzalne, skalowalne i aby to jakoś wyglądało… I najważniejsze – żeby było za darmo!

Jak wiadomo – nie da się. Jednak podjąłem próbę i jestem z niej całkiem zadowolony 😉

Front end

W przypadku tego projektu założyłem, że aplikacja ma wyglądać nie najgorzej oraz ma być przyjazna urządzeniom mobilnym. Do tego celu wykorzystałem Ionic. Domyślnie pisze się w nim jak aplikacje webowe na Angulara, ale wsparcie obejmuje również Reacta i Vue.js. Co więcej, style są dopasowane do systemu operacyjnego – na Androidzie styl androidowy, a na iPhone’ie styl makowy.

W tym frameworku jest dostępnych wiele typowych komponentów i funkcjonalności typowych dla aplikacji mobilnych. Można z takiej aplikacji zrobić aplikację typowo mobilną oraz używać mnóstwa natywnych funkcjonalności poprzez używanie pluginów.

Skorzystałem zatem z predefiniowanych komponentów (List, Infinite Scroll, Floating Action Button, Modal, Refresher, Loader, Slides, Toasts) i z ich pomocą stworzyłem kolejną przeglądarkę, ogólnie mówiąc, systemu plików.

Podgląd plików oraz wyświetlanie ich treści ukryłem za pewną abstrakcją, przez co dodanie nowych wspieranych typów plików jest stosunkowo proste. A same wsparte typy danych ograniczyłem do plików Markdown oraz plików tekstowych. Jak będzie trzeba, to dodanie podglądacza plików przez dodanie 3 klas nie powinno być trudne.

Data source

Projekt miał być w miarę niezależny od użytego źródła danych, dlatego musiałem wydzielić pewną abstrakcję plików i folderów.

Pierwszym i aktualnie jedynym wpieranym typem systemów plików jest „repozytorium” na BitBuckecie.

Ma to pewne plusy – dostęp poprzez Restowe Api Bitbucketowe wymaga uwierzytelnienia w Bitbuckecie. Daje to możliwości ograniczenia dostępu do danych, ale również możliwość wpółdziałania z innymi osobami posiadającymi dostęp do projektu. Darmowe prywatne projekty są aktualnie do 5 osób. Jeśli takie repozytorium będzie przykładowo postawione na gitcie, to dostajemy w prezencie również historię repozytorium oraz jego audyt oraz możliwości PullRequestów. A jeśli bardzo się postaramy, to możemy przy commicie odpalać jakieś pipeline’y (do 50 minut na miesiąc free).

Oczywiście te same feature’y były by dostępne dla GitHuba, gdyż oferuje podobny zakres funkcjonalności. Były by dostępne, gdybym je zaimplementował 😉

A gdzie ten projekt?

Rzeczy jasna najtańszy hosting, to darmowy hosting 😉 Skorzystałem z dobrodziejstwa Github Pages, gdzie zdecydowałem się wrzucić demo aplikacji. Bierzcie i klikajcie.

Jeśli chcecie – zajrzyjcie na źródła. Jeśli chcecie się tym pobawić – forkujcie. W Readme powinno być w miarę ładnie opisane, co i jak.

Backlog

Pomysłów na rozwój jest mnóstwo, a zasobów żadnych 😉

Można by w przyszłości:

  • dodać wsparcie dla Api Githuba,
  • zrobić z tego apkę natywną,
  • dodać wsparcie nie tylko dla odczytów, ale także dla zapisów (nie tylko podgląd pliku, ale także edycja)
  • wpierać przeglądanie wielu plików jednocześnie (coś jak karty przeglądarki)
  • zaimplementować, testy jednostkowe 😉

Ale kto by miał na to czas…

Podsumowanie

Tak, wiem, znów strollowałem nagłówkiem, bo coś o serwerlessie, a tu w sumie wychodzi SaaS – serwis Bitbucketowy to chyba Software as a Service… a może Platform, bo w sumie Bitbucket wystawia API… Nie wiem, nie znam się, ale co po kodziłem, to moje 🙂

Wraz z tym projekcikiem kończą mi się zaległe tematy do opisania na blogu. Zatem w kolejnych wpisach mam nadzieję wróci Nasza Ukochana Java.

A jeśli ten wpis Ci się podobał, to daj komentarz, odpowiednią ilość gwiazdek, cobym wiedział, jaki jest odbiór tego wpisu.

A jeśli interesuje Cię bieda-programowanie, to popełniłem już wcześniej jeden wpis o Lambdach dla ubogich.

Pax

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…