Async await w Javie
JavaScript i C# posiadają instrukcję await upraszczającą asynchroniczny kod. Oto jej implementacja dla Javy.
czwartek, 31 maja 2018
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ą:
- Jersey Client (do ściągnięcia tabeli kursów)
- Jackson (do czytania JSON-ów)
- Lombok (do zmniejszenia ilości kodu)
- sam EA Async
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.
zobacz inne wpisy w temacie
Komentarze (3)
Grzesiek Pisarek
# czwartek, 31 maja 2018, 18:23
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.
Paweł
# poniedziałek, 4 czerwca 2018, 23:17
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:
Searched in the following repositories:
Gradle Central Plugin Repository
Open File
zyxist
# wtorek, 5 czerwca 2018, 08:40
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
iExchangeRate
. Lomboka użyłem tylko po to, by skrócić kod tych dwóch klas do absolutnego minimum.