CompletableFuture – asynchroniczność w Javie

CompletableFuture to rewolucja w asynchronicznym programowaniu w Javie 8. Pozwala łączyć operacje asynchroniczne w czytelny sposób, zastępując callback hell eleganckim API typu fluent.

Dlaczego asynchroniczność jest ważna?

W dzisiejszych aplikacjach często wykonujemy operacje które zajmują dużo czasu – wywołania REST API, zapytania do bazy danych, przetwarzanie plików. Bez asynchroniczności każda taka operacja blokuje wątek, co dramatycznie ogranicza skalowalność aplikacji.

CompletableFuture rozwiązuje ten problem oferując elegancki sposób na komponowanie operacji asynchronicznych. Zamiast zagnieżdżonych callbacków otrzymujemy czytelny kod który można łatwo testować i debugować.

Co się nauczysz:

  • Jak tworzyć i komponować operacje asynchroniczne z CompletableFuture
  • Różnice między supplyAsync(), runAsync() i thenApply()
  • Jak obsługiwać błędy w operacjach asynchronicznych
  • Wzorce łączenia wielu CompletableFuture w jeden pipeline
  • Best practices i typowe pułapki w programowaniu asynchronicznym
  • Praktyczne przykłady z aplikacji webowych i mikrousług

Wymagania wstępne:

  • Dobra znajomość Javy 8 (lambda expressions, streams)
  • Podstawy programowania wielowątkowego
  • Znajomość interfejsu Future i jego ograniczeń
  • Doświadczenie z REST API i bazami danych

Co to jest CompletableFuture?

CompletableFuture to implementacja interfejsu Future wprowadzona w Javie 8, która rozwiązuje główne problemy tradycyjnego Future:

CompletableFuture – klasa pozwalająca na tworzenie i komponowanie operacji asynchronicznych z możliwością obsługi błędów i transformacji wyników
Future (Java 5)CompletableFuture (Java 8)
Tylko get() – blokującyNieblokujące transformacje
Brak komponowaniaFluent API do łączenia
Trudna obsługa błędówEleganckie handle() i exceptionally()
Jeden wynikMożna łączyć wiele Future

Podstawowe operacje

Zacznijmy od prostego przykładu – asynchroniczne pobieranie danych użytkownika:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;

public class UserService {
    private final ExecutorService executor = Executors.newFixedThreadPool(10);
    
    // Asynchroniczne pobieranie użytkownika
    public CompletableFuture getUserAsync(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            // Symulacja wywołania bazy danych
            sleep(1000);
            return userRepository.findById(userId);
        }, executor);
    }
    
    // Asynchroniczne pobieranie profilu
    public CompletableFuture getProfileAsync(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(800);
            return profileRepository.findByUserId(userId);
        }, executor);
    }
    
    private void sleep(int millis) {
        try { Thread.sleep(millis); } 
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
Pro tip: Zawsze przekazuj własny ExecutorService do supplyAsync(). Domyślny ForkJoinPool.commonPool() może być niewystarczający dla I/O operations.

Komponowanie operacji asynchronicznych

Prawdziwa siła CompletableFuture ujawnia się przy komponowaniu operacji:

public class UserController {
    
    @GetMapping("/users/{id}/dashboard")
    public CompletableFuture getUserDashboard(@PathVariable Long id) {
        
        CompletableFuture userFuture = userService.getUserAsync(id);
        CompletableFuture profileFuture = userService.getProfileAsync(id);
        CompletableFuture> ordersFuture = orderService.getOrdersAsync(id);
        
        // Łączenie wyników z trzech różnych źródeł
        return userFuture
            .thenCombine(profileFuture, (user, profile) -> 
                new UserWithProfile(user, profile))
            .thenCombine(ordersFuture, (userProfile, orders) -> 
                new DashboardData(userProfile.getUser(), userProfile.getProfile(), orders))
            .exceptionally(throwable -> {
                log.error("Error loading dashboard for user: " + id, throwable);
                return DashboardData.empty();
            });
    }
}

Obsługa błędów

CompletableFuture oferuje eleganckie mechanizmy obsługi błędów:

public CompletableFuture processUserData(Long userId) {
    return CompletableFuture
        .supplyAsync(() -> fetchUserFromDatabase(userId))
        .thenCompose(user -> validateUser(user))
        .thenApply(user -> enrichUserData(user))
        .thenApply(user -> user.getName())
        .handle((result, throwable) -> {
            if (throwable != null) {
                log.error("Error processing user: " + userId, throwable);
                return "Unknown User";
            }
            return result;
        });
}

// Alternatywnie - używając exceptionally()
public CompletableFuture processUserDataWithFallback(Long userId) {
    return getUserAsync(userId)
        .thenApply(User::getName)
        .exceptionally(throwable -> {
            log.warn("Falling back to cache for user: " + userId);
            return userCache.getUserName(userId);
        });
}
Uwaga: handle() jest wywoływane zawsze (sukces i błąd), exceptionally() tylko przy błędzie. Wybierz odpowiednią metodę w zależności od potrzeb.

Wzorce zaawansowane

### Timeout i fallback

public CompletableFuture getDataWithTimeout(String url) {
    CompletableFuture apiCall = CompletableFuture
        .supplyAsync(() -> httpClient.get(url));
    
    CompletableFuture timeout = CompletableFuture
        .supplyAsync(() -> {
            sleep(5000); // 5 sekund timeout
            throw new TimeoutException("API call timeout");
        });
    
    return apiCall
        .applyToEither(timeout, result -> result)
        .exceptionally(throwable -> "Fallback data");
}

### Przetwarzanie wszystkich wyników

public CompletableFuture> getAllProductsData(List productIds) {
    List> futures = productIds.stream()
        .map(this::getProductDataAsync)
        .collect(Collectors.toList());
    
    // Czeka na wszystkie wyniki
    return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(v -> futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList()));
}
Pułapka: allOf() zwraca CompletableFuture. Musisz ręcznie wyciągnąć wyniki używając join() lub get().

Performance considerations

CompletableFuture to jak zamówienie w restauracji – zamiast czekać na każde danie osobno, zamawiasz wszystko na raz i otrzymujesz gdy będzie gotowe.

Porównanie wydajności dla 3 wywołań API (każde 1 sekunda):

PodejścieCzas wykonaniaZużycie wątków
Synchroniczne3 sekundy1 wątek przez 3s
CompletableFuture1 sekunda3 wątki przez 1s
Sekwencyjne Future3 sekundy3 wątki przez 3s

Integracja ze Spring Boot

W Spring Boot możesz użyć adnotacji @Async z CompletableFuture:

@Service
@EnableAsync
public class NotificationService {
    
    @Async
    public CompletableFuture sendEmail(String email, String message) {
        return CompletableFuture.runAsync(() -> {
            // Wysyłanie email
            emailClient.send(email, message);
        });
    }
    
    @Async
    public CompletableFuture sendSms(String phone, String message) {
        return CompletableFuture.supplyAsync(() -> {
            // Wysyłanie SMS
            return smsClient.send(phone, message);
        });
    }
}
Spring automatycznie konfiguruje ThreadPoolTaskExecutor dla metod @Async. Możesz też zdefiniować własny bean TaskExecutor.
Kiedy używać CompletableFuture zamiast Thread?

CompletableFuture gdy potrzebujesz wyniku z operacji asynchronicznej. Thread gdy robisz fire-and-forget operations. CompletableFuture to wyższy poziom abstrakcji.

Czy CompletableFuture jest thread-safe?

Tak, CompletableFuture jest thread-safe. Możesz bezpiecznie wywoływać metody z różnych wątków. Wynik jest publikowany bezpiecznie.

Co się dzieje z wyjątkami w CompletableFuture?

Wyjątki są opakowywane w CompletionException. Używaj handle() lub exceptionally() do obsługi. Nigdy nie rzucaj checked exceptions z lambda.

Jak debugować kod asynchroniczny?

Używaj logowania z nazwą wątku, IDE może nie pokazywać pełnego stack trace. CompletableFuture.whenComplete() pomaga w debugowaniu.

Czy powinienem używać join() czy get()?

join() nie rzuca checked exceptions, get() rzuca. W lambda expressions używaj join(). W normalnym kodzie – get() z obsługą wyjątków.

Jak unikać callback hell z CompletableFuture?

Używaj thenCompose() zamiast thenApply() gdy lambda zwraca CompletableFuture. To spłaszcza zagnieżdżenia. Dziel długie chains na mniejsze metody.

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Stwórz mikrousługę która asynchronicznie pobiera dane użytkownika z 3 różnych źródeł (baza danych, cache, zewnętrzne API), łączy je w jeden obiekt i zwraca wynik. Dodaj obsługę timeoutów i fallback na cache gdy API nie odpowiada. Zmierz różnicę w wydajności między podejściem synchronicznym a asynchronicznym.

Jak CompletableFuture zmienił Twoje podejście do programowania asynchronicznego? Jakie największe korzyści zauważyłeś w swoich projektach? Podziel się doświadczeniem w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry