Ratpack: nieblokujący serwer HTTP

Ratpack: nieblokujący serwer HTTP

Chcesz szybko i efektywnie komunikować się po HTTP ze światem? Poznaj Ratpack, nieblokujący serwer HTTP będący alternatywą dla servletów.

niedziela, 27 maja 2018

Informatyka

Będąc piechurem długodystansowym i informatykiem od miesiąca piszę o rowerach, dlatego pora wrócić przed ekran i zająć się czymś poważnym ;). Ratpack to jeden z ciekawszych projektów, które odkryłem w ostatnich latach, doskonale pasujący do mojej filozofii wykorzystywania lekkich narzędzi, robiących dobrze jedno, ściśle określone zadanie. Jest to biblioteka implementująca funkcjonalność serwera HTTP. Posiada API dla Groovy'ego i Javy i stanowi ciekawą alternatywę dla mechanizmu serwletów. Dlaczego? Klasyczna implementacja serwletów zakłada, że przetwarzaniem poszczególnych żądań zajmują się oddzielne wątki. Każda operacja I/O powoduje, że wątek musi zostać uśpiony i zaczekać na jej zakończenie.

Zauważmy, że takie podejście wykorzystuje zasoby systemowe dość nieefektywnie, co jest szczególnie widoczne przy dużym obciążeniu. Każdy wątek w Javie już na starcie zabiera kilka MB pamięci zarezerwowanych na potrzeby stosu wykonania. Ponadto przełączanie się między wątkami wykonywanymi na tym samym procesorze, ich wybudzanie i usypianie to operacje dość kosztowne. W serwerach obsługujących duży ruch liczba pracujących jednocześnie wątków na pewno przekroczy liczbę procesorów logicznych. W dodatku operacje I/O to nie tylko odczyt danych z gniazda sieciowego, ale także wszelkie operacje na plikach oraz komunikacja z bazą danych.

Ratpack bazuje na koncepcji programowania reaktywnego. Liczba aktywnych wątków jest niewielka i raczej stała - pracują one na zasadzie pętli zdarzeń, które są przetwarzane jedno po drugim. Idea jest taka, aby obsługa poszczególnych zdarzeń nigdy nie blokowała wątku. Zamiast tego, delegujemy wykonanie takiej blokującej operacji gdzieś indziej i bierzemy się natychmiast za obsługę następnego w kolejności zdarzenia. Gdy potrzebne wyniki są już dostępne, trafiają one po prostu do kolejki jako zdarzenie i czekają tam na obsługę. Taka architektura pozwala efektywnie wykorzystać CPU, gdyż wzrastająca liczba żądań nie doprowadzi do sytuacji, w której procesor zajęty będzie głównie przełączaniem się między wątkami, zamiast robić coś sensownego. Choć czas obsługi pojedynczego żądania w takiej sytuacji pewnie wzrośnie, ale ich przetwarzanie będzie szło sprawnie.

Zaczynamy

Jeśli korzystamy z Gradle'a, najprostszym sposobem na dodanie Ratpacka do naszej aplikacji jest wykorzystanie dedykowanej wtyczki, która automatycznie skonfiguruje nam odpowiednie zależności:

buildscript {
    dependencies {
        classpath "io.ratpack:ratpack-gradle:1.5.0"
    }
}

apply plugin: 'io.ratpack.ratpack-java'

Ratpack jest biblioteką, a nie frameworkiem, dlatego to my decydujemy o tym, kiedy i jak uruchomić przetwarzanie żądań HTTP. Serwer konfigurujemy przy pomocy prostego, obiektowego DSL-a opisującego całą strukturę aplikacji oraz jej konfigurację. Wygląda to następująco:

public class RatpackBasicDemo {
    public static void main(String args[]) {
        try {
            RatpackServer.start(s -> s
                .handlers(h ->
                    h.path("hello", RatpackBasicDemoRestructured::renderHello)
                )
            );
        } catch (Exception exception) {
            exception.printStackTrace();
        }
    }

    public static void renderHello(Context ctx) {
        ctx.render("Hi, universe!");
    }
}

Po uruchomieniu możemy wejść na stronę http://localhost:5050/hello - ujrzymy napis Hi, universe!

Łańcuch handlerów

W przedstawionym przykładzie pojawia się metoda .handlers(). Przyjmuje ona lambdę, w której konfigurujemy tzw. handlery. Odpowiadają one za obsługę żądań i są przypisane do adresów URI, metod HTTP itd. - czyli ogólnie wszędzie tam, gdzie musimy zdecydować, co robić w zależności od tego, czego dotyczy żądanie. Nasz pierwszy przykład posiada tylko jeden handler, który uruchamiany jest, jeśli wpiszemy URL /hello. Stwórzmy zatem coś bardziej zaawansowanego.

public class RatpackHandlerDemo {
    private final Map<Integer, String> articles = new LinkedHashMap<>();

    public static void main(String args[]) {
        new RatpackHandlerDemo().run();
    }

    public void run() {
        try {
            RatpackServer server = RatpackServer.of(s -> s
                .handlers(h -> h
                    .path("article/:id", ctx -> ctx.byMethod(m -> m
                        .get(this::readArticle)
                        .post(this::createArticle)
                        .delete(this::removeArticle)
                    ))
                    .path("article", this::listArticles)
                )
            );
            server.start();
        } catch (Exception exception) {
            exception.printStackTrace();
        }
    }

    public void listArticles(Context ctx) {
        ctx.render(json(articles));
    }

    public void createArticle(Context ctx) {
        PublicAddress addr = ctx.get(PublicAddress.class);
        ctx.getRequest().getBody()
            .onError(error -> ctx.getResponse().status(500).send(error.getMessage()))
            .then(body -> {
                int id = ctx.getPathTokens().asInt("id");
                articles.put(id, body.getText());
                ctx.getResponse().status(201);
                ctx.getResponse().getHeaders().set("Location", addr.builder()
                    .path("article/" + id).build()
                );
                ctx.getResponse().send();
            });
    }

    public void readArticle(Context ctx) {
        int id = ctx.getPathTokens().asInt("id");
        if (articles.containsKey(id)) {
            ctx.render(articles.get(id));
        } else {
            ctx.getResponse().status(404).send("Not found");
        }
    }

    public void removeArticle(Context ctx) {
        int id = ctx.getPathTokens().asInt("id");
        if (articles.containsKey(id)) {
            articles.remove(id);
            ctx.getResponse().status(200).send();
        } else {
            ctx.getResponse().status(404).send("Not found");
        }
    }
}

Jest to kompletny przykład implementujący operacje CRUD do zarządzania artykułami. Dużo się tu dzieje, zatem przyjrzyjmy się mu kawałek po kawałku. Na początek - handlery. Mamy tutaj obsługę dwóch różnych ścieżek: /article/{id} oraz /article, przy czym pierwszą z nich rozbijamy jeszcze na trzy dodatkowe metody HTTP. Zauważmy, że konfiguracja handlerów dla poszczególnych metod znajduje się tak naprawdę w handlerze dla ścieżki /article/{id}. Oznacza to, że w naszym przykładzie Ratpack najpierw wybierze nasz handler dla ścieżki, a dopiero potem sprawdzi, z jaką metodę ma do czynienia i co robić dalej. Możemy to łatwo zweryfikować, wysyłając żądanie na adres http://localhost:5050/article do wyświetlenia wszystkich artykułów. Nie mamy tutaj żadnej obsługi metod HTTP, dlatego możemy puścić zarówno żądanie GET, jak i POST (jak i dowolne inne), a zawsze dostaniemy ten sam wynik.

Przejdźmy teraz do poszczególnych handlerów, aby zobaczyć, co możemy robić z żądaniami i odpowiedziami. Zacznijmy od listArticles(), gdzie generujemy dokument JSON. Używamy w tym celu metody render(), do której przekazujemy renderer formatu JSON, którym jest biblioteka Jackson. Ratpack wspiera też strumieniowanie JSON-ów. Jeśli korzystamy z RxJava, możemy użyć specjalnego renderera, który będzie czytał kolejne obiekty z kanału i renderował je w locie. Więcej na ten temat napiszę w kolejnym artykule.

Metoda createArticle() demonstruje, w jaki sposób możemy przetwarzać złożone żądania. Dzieje się tu sporo rzeczy. Zacznijmy od następującej linijki:

int id = ctx.getPathTokens().asInt("id");

Tak właśnie w Ratpacku odczytujemy atrybuty z adresów URI. API udostępnia szereg metod do zwracania wyniku w postaci typów prymitywnych, oszczędzając nam zabawy z ręczną konwersją. Kiedy mamy już wszystkie dane i zrobiliśmy coś z nimi, przyszła pora na wygenerowanie odpowiedzi. W dotychczasowych przykładach generowaliśmy standardowe odpowiedzi przy pomocy metody render(). Jednak tutaj chcemy użyć innego kodu statusu, a także dodać nagłówek. W kontekście mamy dostęp do obiektu Response, w którym możemy poustawiać wszystkie potrzebne informacje.

ctx.getResponse().status(201);
ctx.getResponse().getHeaders().set("Location", addr.builder()
       .path("article/" + id).build()
);
 ctx.getResponse().send();

Pokazane jest tutaj także generowanie adresów URI na potrzeby nagłówka Location. Czym jest jednak ta magiczna zmienna addr i skąd się ona bierze? Jest to obiekt implementujący interfejs PublicAddress zawierający informacje o publicznym adresie serwera. Na jego podstawie możemy z kolei utworzyć HttpUriBuilder, który służy już do budowania konkretnych adresów URI. Samą instancję PublicAddress pobieramy z tzw. rejestru - jest to mapa dodatkowych serwisów, które są dostępne w handlerach. Możemy tam rejestrować także własne rzeczy (podczas konfigurowania serwera), zaś jeśli korzystamy z Google Guice, Ratpack może się z nim zintegrować i pobierać obiekty właśnie stamtąd. W handlerze zawartość rejestru możemy odczytać przy pomocy get() w kontekście:

PublicAddress addr = ctx.get(PublicAddress.class);

Promises

Ostatnim elementem przykładu jest następująca konstrukcja:

ctx.getRequest().getBody()
    .onError(error -> ...)
    .then(body -> ...);

Tak właśnie wygląda w praktyce asynchroniczność w Ratpacku. Treść żądania może być bardzo długa - serwer wywołuje nasz handler, zanim jeszcze zostanie ona załadowana. Dlatego metoda getBody() zwraca obiekt "obietnicy" Promise mówiący o tym, że treść pojawi się w przyszłości. My natomiast rejestrujemy dwie funkcje, które będą odpowiadały za przetworzenie wyniku, gdy stanie się dostępny. W międzyczasie główny handler może się zakończyć i oddać Ratpackowi sterowanie, by ten zajął się póki co innym żądaniem.

Obietnice są kluczowym elementem API Ratpacka, ponieważ w handlerach nie wolno nam wykonywać żadnej operacji blokującej. Możemy je tworzyć samodzielnie na kilka sposobów, w zależności od tego, jaką operację chcemy za ich pomocą obsłużyć. Zaczniemy od trywialnego przypadku, w którym określona wartość dostępna jest od razu. Nie ma to nic wspólnego z asynchronicznością, ale może przydać się w testach i prezentuje ogólną ideę API:

Promise.value(42)
    .onError(error -> {})
    .then(value -> {});

Promise to interfejs, w którym istnieją też inne statyczne metody konstruujące nowe obietnice. Kolejną z nich jest właśnie async() pozwalająca na obsługę asynchronicznych wywołań. Spróbujmy zmodyfikować nasz dotychczasowy przykład tak, aby obsługa mapy articles była realizowana w oddzielnym, dedykowanym wątku. Wykorzystamy w tym celu javowy ExecutorService oraz użyjemy Promie.async(), aby delegować do niego operacje na mapie:

private final ExecutorService articleService = Executors.newSingleThreadExecutor();

public void readArticle(Context ctx) {
    int id = ctx.getPathTokens().asInt("id");
    Promise
        .async(upstream -> {
            articleService.submit(() -> {
                if (articles.containsKey(id)) {
                    upstream.success(articles.get(id));
                } else {
                    upstream.error(new NotFoundException());
                }
            });
        })
        .onError(error -> {
            if (error instanceof NotFoundException) {
                ctx.getResponse().status(404).send("Not found");
            } else {
                ctx.getResponse().status(500).send("Internal server error");
            }
        })
        .then(article -> ctx.render(article));
}

Metoda async() przyjmuje jako argument lambdę, która daje nam dostęp do obiektu Upstream, służącego do powiadamiania o dostępności wyniku. Należy tutaj przestrzegać kilku zasad:

  • podana lambda nie może blokować wątku, gdyż wykonywana jest w obrębie pętli zdarzeń Ratpacka. To my musimy zadbać o to, aby oddelegować blokujący kod do innego wątku.
  • w lambdzie musimy dokładnie jeden raz wywołać jedną z następujących metod: .complete(), .success(), .error().
  • przetwarzanie obietnicy rozpoczyna się dopiero wtedy, gdy wywołamy .then() (nie w momencie jej utworzenia).

W naszym wypadku produkujemy tylko jedną wartość, dlatego możemy od razu skorzystać z wywołania .success(). Gdybyśmy musieli wyprodukować więcej wartości, musielibyśmy każdą z nich wysyłać przy pomocy jeszcze jednej metody: .accept(), a następnie powiadomić Ratpacka o zamknięciu strumienia, wywołując .complete().

Przykład pokazuje też jeszcze jedną, niestety negatywną cechę programowania asynchronicznego. Korzystając z "gołych" obietnic, bardzo szybko wpadniemy w tzw. callback hell. Wielokrotnie zagnieżdżone funkcje są niesamowicie trudne w testowaniu, a także znacząco wydłużają kod. Jest to cena, jaką musimy zapłacić za lepsze wykorzystanie możliwości współczesnych komputerów. W praktyce bardziej złożone przetwarzanie danych implementowane jest przy pomocy dodatkowych bibliotek, dodających odpowiednią abstrakcję - jedną z nich jest RxJava.

Zauważmy, że głównym celem użycia .async() w naszym przykładzie było wykonanie blokującej operacji. Są przypadki, kiedy chcielibyśmy mieć kontrolę nad wątkami, gdzie wykonywane są obliczenia - np. w powyższym przypadku musi to być zawsze jeden i zawsze ten sam wątek, ponieważ nie mamy żadnej synchronizacji na kolekcji articles. Jednak jeśli taka kontrola jest niepotrzebna, możemy uprościć nasz kod, korzystając z metody Blocking.get():

Blocking
    .get(() -> Files.readAllBytes(new File("foo.txt").toPath()))
    .then(value -> {});

Tym razem delegacją wywołania do innego wątku i obsługą komunikacji zajmuje się Ratpack. Nasze zadanie sprowadza się do zaimplementowania generowania wyniku.

Nieblokujące I/O

Decydując się na korzystanie z Ratpacka, musimy konsekwentnie wdrożyć nieblokujące I/O również w innych częściach naszej aplikacji. Na szczęście coraz większa liczba zewnętrznych narzędzi wspiera asynchroniczne operacje - reprezentacyjnym przykładem niech będzie baza danych MongoDB. Do integracji obu tych elementów możemy natomiast wykorzystać bibliotekę RxJava. Problem natomiast sprawią nam relacyjne bazy danych - interfejs JDBC do komunikacji z nimi jest w 100% blokujący i nie ma planów dodania wsparcia dla asynchroniczności. Zamiast tego, trwają prace nad zupełnie nowym API o nazwie ADBA (Asynchronous Database Access API), które jest zgłoszone jako propozycja standardu, jednak na jego upowszechnienie musimy jeszcze trochę poczekać. W międzyczasie pozostaje nam jedynie wykorzystanie paru sztuczek.

Temat współpracy Ratpacka z powyższymi bibliotekami omówię szerzej w kolejnym artykule, gdzie przedstawię dwa reprezentacyjne przykłady:

  • stworzenie nieblokującego kanału komunikacji Ratpack↔RxJava↔MongoDB
  • stworzenie nieblokującego kanału komunikacji Ratpack↔RxJava↔JDBC (Hibernate)

Dokumentacja i Java 9

Do Ratpacka mam w zasadzie tylko dwa zastrzeżenia. Pierwszym z nich jest stan dokumentacji. Oczywiście istnieje ona, ale już dobrych parę razy zdarzyło mi się, że szukając informacji na temat jakiejś bardziej zaawansowanej funkcjonalności odbijałem się od muru, ponieważ dany rozdział był pusty. W innych miejscach podane informacje są dość lakoniczne, co stanowi problem, kiedy chcemy zrobić coś bardziej zaawansowanego i np. połączyć ze sobą kilka elementów. Skoro już używam nieblokującego I/O, to nie chciałbym przez nieuwagę tej właściwości zepsuć - w praktyce kończyło się to napisaniem eksperymentalnej aplikacji, na której doświadczalnie sprawdzałem co Ratpack robi.

Na chwilę obecną Ratpacka ciężko jest też używać razem z modułami Javy 9. Wszystko z powodu bardzo luźnego podejścia autorów do idei pakietów. Ratpack podzielony jest na szereg mniejszych podprojektów - niestety okazuje się, że pakiety nie tylko nie trzymają się przyjętej w świecie Javy konwencji nazewnictwa (to da się jeszcze przeżyć), ale jeszcze w dodatku te same pakiety powtarzają się w różnych projektach. To już jest poważny problem - z punktu widzenia Javy 9 jest to tzw. package split, który w świecie modułów jest niedopuszczalny: po prostu właścicielem pakietu może być co najwyżej jeden moduł. Aby użyć Ratpacka, trzeba zrobić ciężką hackerkę przy pomocy flagi --patch-module, co nie jest zbyt przyjemne. Istnieje otwarte zadanie na naprawę tego, ale nie nastąpi to szybko, gdyż wiąże się to z całkowitą reorganizacją projektu i zmianami w API, więc nie wydarzy się wcześniej niż w wersji 2.0.

Podsumowanie

To już koniec wprowadzenia do Ratpacka. Koncentruje się ono na przedstawieniu idei stojącej za tą biblioteką, jednak jej możliwości są dużo większe. W szczególności polecam przyjrzeć się rozbudowanemu API do testowania naszych serwerów.

Pomimo pewnych braków w dokumentacji uważam Ratpacka za bardzo dobry projekt i już od jakiegoś czasu wykorzystuję go przy różnych okazjach. Jeżeli mamy podjąć decyzję, w którą stronę iść: servlety czy Ratpack, podstawowe pytanie, na jakie powinniśmy sobie odpowiedzieć, brzmi: czy zysk wydajności wynikający z użycia nieblokujących operacji jest wart większej złożoności kodu? Klasyczne podejście kapituluje, kiedy liczba żądań HTTP zaczyna wyczerpywać zasoby serwera z powodu dużej liczby równolegle przetwarzanych wątków. Degradacja wydajności w Ratpacku przebiega dużo wolniej. Mierzalną poprawę można też uzyskać przy mniejszym obciążeniu, zwłaszcza jeśli dane przetwarzane są w sposób nieblokujący od początku do końca (baza - przetwarzanie - serwer HTTP).

Jak wspomniałem, planuję napisać jeszcze jeden artykuł, który będzie poświęcony integracji Ratpacka z RxJavą oraz bazami danych. Na koniec podaję kilka dodatkowych materiałów w sieci do poczytania:

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: dariorug, CC-BY-2.0

zobacz inne wpisy w temacie

Informatyka

poprzedni wpis Jak testować I/O następny wpis Async await w Javie

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, anonimizowany po zatwierdzeniu komentarza.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.

Klikając "Wyślij komentarz" wyrażasz zgodę na przetwarzanie podanych w nim danych osobowych do celów moderacji i publikacji komentarza, zgodnie z polityką prywatności: polityka prywatności