Async await w Javie

Async await w Javie

JavaScript i C# posiadają instrukcję await upraszczającą asynchroniczny kod. Oto jej implementacja dla Javy.

czwartek, 31 maja 2018

Informatyka

Artykuł Ratpack: nieblokujący serwer HTTP wywołał dużą dyskusję na forum 4programmers.net. Jednym z poruszanych zagadnień była kwestia czytelności asynchronicznego kodu pisanego w Javie. W Javie 8 pojawiło się wsparcie na poziomie biblioteki standardowej: klasa CompletableFuture, natomiast Ratpack posiada autorską implementację tzw. "obietnic", Promise (oryginalnie pisany był w czasach Javy 7). Oba rozwiązania wymagają od programisty zarejestrowania tzw. "callbacków", czyli funkcji, które zostaną wywołane, kiedy asynchroniczna operacja zostanie zakończona. Stosowanie callbacków bardzo szybko prowadzi do sytuacji znanej jako "callback hell" - długich łańcuchów zarejestrowanych funkcji, w których ciężko jest się rozeznać i które ciężko się debuguje.

Programiści piszący w C# oraz JS mają już do dyspozycji instrukcję await, która znakomicie upraszcza asynchroniczny kod, ukrywając wszystkie callbacki i ceremonię związaną z ich tworzeniem. Programista widzi na ekranie kod, który wygląda niemalże jak klasyczny, blokujący algorytm, jednak po uruchomieniu okazuje się, że tak nie jest. Java takiej instrukcji nie posiada, jednak ekipie Electronic Arts (tak, ci od gier) udało się stworzyć... jej implementację w formie biblioteki: EA Async. W tym artykule pragnę przedstawić, jak z niej skorzystać oraz jak ona działa.

Przygotowanie środowiska

Bibliotekę zaprezentuję na przykładzie prostej, demonstracyjnej aplikacji pobierającej kurs euro ze strony NBP. Zacznijmy od przygotowania środowiska - potrzebne nam będą:

Poniżej przedstawiam skrypt buildowy Gradle'a:

plugins {
    id 'io.franzbecker.gradle-lombok' version '1.14'
}

apply plugin: 'application'
apply plugin: 'idea'

group 'com.zyxist.example'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8
mainClassName = 'com.zyxist.example.async.CompletableFutureDemo'

configurations {
    asyncAgent {
        transitive = false
    }
}

repositories {
    mavenLocal()
    mavenCentral()
}

dependencies {
    compile group: 'org.glassfish.jersey.core', name: 'jersey-client', version: '2.25.1'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.5'
    compile 'org.glassfish.jersey.media:jersey-media-json-jackson:2.25.1'

    // zależność do biblioteki
    compile 'com.ea.async:ea-async:1.1.1'
    asyncAgent 'com.ea.async:ea-async:1.1.1'
}

// konfiguracja Java Agenta
applicationDefaultJvmArgs = [
    "-javaagent:${configurations.asyncAgent.singleFile}"
]

Biblioteka składa się z dwóch części. Pierwsza zawiera statyczną metodę await() emulującą analogiczną instrukcję z JS/C#. Druga to tzw. Java Agent, instrumentator kompilatora, który potrafi przepisać kod bajtowy używający wspomnianej metody na callbacki CompletableFuture. Funkcjonalność Java Agentów istnieje już od wielu lat - pojawiła się ona w JDK5. Jeśli chodzi o samą instrumentację, to transformację można wykonać albo w momencie uruchomienia programu, albo podczas kompilacji, co jest idealnym rozwiązaniem dla bibliotek, gdyż wynikowy kod nie będzie zawierał śladu po await(). Niestety, realizowane jest to przez dodatkową wtyczkę, która jest dostępna tylko dla Mavena.

W samym skrypcie buildowym zadeklarowałem zależność dwukrotnie - raz, aby mieć ją dostępną w źródłach, a drugi raz, aby móc szybko i wygodnie znaleźć ścieżkę do pliku JAR celem skonfigurowania JVM. Stworzyłem w tym celu dedykowaną konfigurację asyncAgent zawierającą jedną zależność.

Model danych

Zanim przejdziemy do demonstracji, stwórzmy jeszcze szybko model tabeli kursów walut. Składa się on z dwóch klas.

Tabela kursów:

@Data
public class ExchangeTable {
    @JsonProperty
    private String table;
    @JsonProperty
    private String no;
    @JsonProperty
    private String effectiveDate;
    @JsonProperty
    private List<ExchangeRate> rates;
}

Kurs pojedynczej waluty:

@Data
public class ExchangeRate {
    @JsonProperty
    private String currency;
    @JsonProperty
    private String code;
    @JsonProperty("mid")
    private BigDecimal rate;
}

"Callback hell"

Demonstrację rozpoczniemy od napisania klasycznego kawałka kodu, wykorzystującego wyłącznie API CompletableFuture. Potrzebna nam będzie implementacja blokującej operacji ściągającej tabelę kursów przy pomocy Jersey Clienta:

public static List<ExchangeTable> callREST() {
    Client client = ClientBuilder.newClient();
    try {
        return client.target("http://api.nbp.pl/api/exchangerates/tables/A/")
            .request()
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .get(new GenericType<List<ExchangeTable>>() {});
    } finally {
        client.close();
    }
}

Kolejna metoda, getEURExchangeRate() ma za zadanie przeczytać tabelę kursów i znaleźć w niej kurs euro. Tutaj też zlecamy pobranie tabeli kursów w sposób asynchroniczny:

public static CompletableFuture<BigDecimal> getEURExchangeRate() {
    return CompletableFuture.supplyAsync(CompletableFutureDemo::callREST)
        .thenApply(tables -> {
            for (ExchangeTable table: tables) {
                for (ExchangeRate rate: table.getRates()) {
                    if (rate.getCode().equals("EUR")) {
                        return rate.getRate();
                    }
                }
            }
            throw new RuntimeException("Kurs wymiany niedostępny!");
        });
}

Kiedy mamy już kurs euro, musimy go wyświetlić - staramy się w dalszym ciągu operować na callbackach, dekorując nimi nasz CompletableFuture, aby utrudnić sobie życie:

public static CompletableFuture<Void> printExchangeRate() {
    return getEURExchangeRate()
        .thenAccept(rate -> System.out.println("Kurs wymiany PLN na EUR to " + rate.toString()))
        .exceptionally(err -> {
            System.err.println(err.getMessage());
            return null;
        });
}

Ostatni kawałek układanki to metoda main(), która rozpoczyna cały proces, natomiast w trakcie oczekiwania na kurs waluty wyświetla w pętli stosowny komunikat:

public static void main(String args[]) throws InterruptedException {
    System.out.println("CompletableFuture demo");
    CompletableFuture<Void> conversionRate = printExchangeRate();
    while (!conversionRate.isDone()) {
        System.out.println("Oczekiwanie na kurs waluty...");
        Thread.sleep(100);
    }
}

Wynik wykonania powyższego kodu będzie wyglądał mniej więcej tak:

CompletableFuture demo
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Kurs wymiany PLN na EUR to 4.3195

Klękajcie narody

Przepiszmy teraz fragment kodu tak, aby skorzystać z await(). Zmian będą wymagać tylko getEURExchangeRate() oraz printExchangeRate(). Zacznijmy od tej pierwszej:

public static CompletableFuture<BigDecimal> getEURExchangeRate() {
    List<ExchangeTable> tables = await(CompletableFuture.supplyAsync(CompletableFutureDemo::callREST));
    for (ExchangeTable table: tables) {
        for (ExchangeRate rate: table.getRates()) {
            if (rate.getCode().equals("EUR")) {
                return completedFuture(rate.getRate());
            }
        }
    }
    throw new RuntimeException("Kurs wymiany niedostępny!");
}

Nasz kod zaczął wyglądać, jak zwykły, synchroniczny algorytm. Zapis await(CompletableFuture.supplyAsync(CompletableFutureDemo::callREST)) wygląda jak zwykłe wywołanie metody, lecz w rzeczywistości jest to granica odpowiadająca zdefiniowaniu callbacka przy pomocy .thenApply() na podanym w argumencie CompletableFuture. Treścią callbacka jest kod rozpoczynający się w miejscu "wywołania" await(). Jego przetłumaczeniem zajmie się wspomniany już wcześniej instrumentator. Kiedy korzystamy z await(), musimy pamiętać o tym, że tę "metodę" wolno "wywoływać" jedynie w tych metodach, które zwracają CompletableFuture, CompletionStage lub klasy dziedziczące po CompletableFuture.

Idźmy zatem dalej i przeróbmy naszą drugą metodę, printExchangeRate():

public static CompletableFuture<Boolean> printExchangeRate() {
    try {
        BigDecimal rate = await(getEURExchangeRate());
        System.out.println("Kurs wymiany PLN na EUR to " + rate.toString());
    } catch (RuntimeException exception) {
        System.err.println(exception.getMessage());
    } finally {
        return completedFuture(true);
    }
}

Użycie completedFuture(true) wymusiło na nas zmianę typu zwracanego wyniku z CompletableFuture<Void> na CompletableFuture<Boolean>. Po poprawieniu sygnatury w metodzie main() nasz program jest gotowy do uruchomienia i co więcej, da nam analogiczny wynik:

Async-await demo
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Oczekiwanie na kurs waluty...
Kurs wymiany PLN na EUR to 4.3195

Zakończenie

Biblioteka EA Async to doskonały przykład elastyczności środowiska Java. Oczywiście, idealnym rozwiązaniem byłoby wsparcie na poziomie samego języka, ale dzięki instrumentacji biblioteka radzi sobie równie dobrze - poza dodatkową konfiguracją Gradle'a robi ona dokładnie to samo, co robiłaby hipotetyczna wbudowana instrukcja async, a jednocześnie pokazuje, że jej potencjalna implementacja nie jest trudna. Zatem kto wie? Java wychodzi obecnie w cyklu półrocznym - może Java 12 czy Java 13 się jej dorobią? Implementacja w formie biblioteki ma też tę zaletę, że oprócz Javy 8-10 działa także ze Scalą, a autorzy wierzą, że powinna ona także bez problemu obsłużyć inne języki działające na JVM.

Jeśli chodzi o samą składnię, to bez dwóch zdań jest ona prostsza. Osobiście na razie jej czytanie wymaga ode mnie dodatkowego wysiłku, gdyż jestem przyzwyczajony, że kod pisany synchronicznie działa synchronicznie, ale to jest po prostu kwestia "obycia" się z innym sposobem zapisu. Zatem - czy w Javie jest await? Odpowiedź brzmi: tak.

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: Amy Claxton, CC-BY-2.0

zobacz inne wpisy w temacie

Informatyka

poprzedni wpis Ratpack: nieblokujący serwer HTTP następny wpis Ratpack, MongoDB i RxJava

Komentarze (3)

av

Grzesiek Pisarek

Powiem Ci Tomek, ze ja sie osobiscie wzdrygam przed uzywaniem czegokolwiek do EA ;)
No i szczerze mowiac nie widze, zeby kod byl specjalnie czytelniejszy.
Ale fajnie wiedziec, ze cos takiego istnieje.

av

Paweł

Cześć Tomek próbuje twój program uruchomić w swoim IDE i wyskakuje mi w gradlu takie coś i nie chce się budować mam wszystko tak samo oprucz group_id i artifact_id liczę na szybką odpowiedz bo jestem zaciekawiony opisanym przykładem przez ciebie :) a tutaj ten błąd:

Plugin [id: 'io.franzbecker.gradle-lombok', version: '1.14'] was not found in any of the following sources:

  • Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
  • Plugin Repositories (could not resolve plugin artifact 'io.franzbecker.gradle-lombok:io.franzbecker.gradle-lombok.gradle.plugin:1.14')
    Searched in the following repositories:
    Gradle Central Plugin Repository
    Open File
av

zyxist

Czy modyfikowałeś coś w linijce z konfiguracją pluginu w stosunku do tego, co podałem? Jeśli nie, oznaczałoby to jakąś niewłaściwą konfigurację repozytoriów w Twojej instalacji Gradle'a. Nazwa artefaktu w drugim punkcie wygląda dość dziwnie. Zerknij sobie tutaj czy na pewno masz wszystko tak samo: https://plugins.gradle.org/plugin/io.franzbecker.gradle-lombok

Do uruchomienia przykładu nie jest potrzebny Lombok - jeśli nie chcesz się nim bawić, po prostu dopisz sobie ręcznie gettery i settery do klas ExchangeTable i ExchangeRate. Lomboka użyłem tylko po to, by skrócić kod tych dwóch klas do absolutnego minimum.

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