Funkcyjna obsługa błędów w Kotlinie i Javie

Czasem obsługa błędów jest prosta, „zerojedynkowa”: albo operacja się powiedzie, albo rzuci wyjątkiem. Mamy jednak też sytuacje, gdy od strony biznesowej podejście „wszystko albo nic” nie ma sensu i oczekiwane jest działanie w trybie „best effort”. Na wejściu jest zestaw danych, wykonujemy na nich operacje, jeśli w trakcie pojawiają się błędy, to po prostu idziemy dalej i próbujemy doprowadzić do końca tak wiele, jak się da, nie wycofując się z niczego. Nie mówimy o beztroskim ignorowaniu błędów: nawet jeśli zgadzamy się na częściowe niepowodzenie, dobrze jest wiedzieć, które z danych były przetworzone pomyślnie, a które z błędem. Jak zaprogramować takie podejście w sposób czytelny i jasno przekazujący intencję?

Imperatywne podejście (try-catch) w naturalny sposób modeluje sytuację zerojedynkową. Żeby zamodelować wersję „best effort” musimy myśleć w sposób bardziej funkcyjny, potraktować zarówno operację do wykonania jak i błąd jako byty na równi z danymi. Będziemy „kolekcjonować” błędy, żeby móc później zanalizować, ile ich było.

Języki nastawione na programowanie funkcyjne ułatwiają tu sprawę, ponieważ mają sprawdzone i dobrze opisane rozwiązania. Przykładowo Scala ma w bibliotece standardowej typ Try. Kotlin i Java nie są językami tradycyjnie wykorzystywanymi w sposób funkcyjny — nie oznacza to, że nie da się działać w nich w analogiczny sposób, jedynie to, że może być trudno znaleźć odpowiedź, jak do tego podejść.

Przykładowy problem

Dla uproszczenia wyobraźmy sobie, że interesuje nas kawałek kodu robiący jakiś rodzaj schedulingu zadań. Mamy zaimplementować metodę cancelTasks, która dostając identyfikatory zadań będzie anulować ich wykonanie, przy czym operacja anulowania (tryToCancelTask) może rzucić wyjątkiem (na przykład gdy identyfikator nie odpowiada żadnemu zadaniu) albo zwrócić obiekt z informacją, jak przebiegło anulowanie (klasa Cancellation).

Kotlin

Biblioteka Arrow ma na celu ułatwiać programowanie funkcyjne w Kotlinie. Umożliwia funkcyjną obsługę błędów, ale też jest biblioteką mocno inwazyjną, zmieniającą styl pisania w porównaniu do „typowego” Kotlina.

Prostszą opcją jest użycie dostarczanego w bibliotece standardowej typu Result. Gdy dostajemy instancję Result<Cancellation>, może on przechowywać albo wynik pomyślnego działania (obiekt Cancellation) albo wyjątek powstały przy niepowodzeniu (Throwable).

Wygodnym sposobem opakowania wyniku naszej operacji w typ Result jest skorzystanie ze „statycznej” (w rozumieniu Javy) funkcji runCatching — przykrywa ona łapanie wyjątków i odpowiednie tworzenie typu Result:

runCatching { tryToCancelTask(id) }

Teraz jeśli chcemy wykonać naszą operację na zestawie danych, możemy łatwo rozdzielić porażki od sukcesów:

fun cancelTasks(taskIds: List<TaskId>) {
    val (failures, successful) = taskIds
            .map { id -> Pair(id, runCatching { tryToCancelTask(id) }) }
            .partition { it.second.isFailure }

Tworzenie pary (id, wynik) pozwala nam na dotarcie do tego, które konkretnie elementy nie powiodły się. Możemy je zalogować:

logger.warn("Failed to cancel for IDs {}", failures.map { it.first })

albo rzucić wyjątek:

failures.firstOrNull()
        ?.let { throw OperationException("Failed to cancel", it.second.exceptionOrNull()) }

W następujący sposób dostaniemy pomyślne wyniki:

val cancellations: List<Cancellation> =
        successful.map { it.second.getOrThrow() }

Java

W Javie nie mamy do dyspozycji żadnego wbudowanego rozwiązania. Typ Try dostarcza na przykład biblioteka Vavr, ale tak jak w przypadku Arrow dla Kotlina, Vavr to spory kawał kodu do wciągnięcia do projektu i proponuje model programowania inny niż typowa Java.

Poniższy przykład oprę o niewielką bibliotekę better-monads, dołączając Apache Commons Lang, aby mieć do dyspozycji typ Pair (Kotlin ma taki typ w bibliotece standardowej). W Gradle’u trzeba dodać następujące zależności:

implementation 'com.jason-goodwin:better-monads:0.4.1'
implementation 'org.apache.commons:commons-lang3:3.9'

Kod wykonujący operację na zestawie danych wejściowych jest podobny jak w przypadku Kotlina:

import com.jasongoodwin.monads.Try;
import org.apache.commons.lang3.tuple.Pair;

void cancelTasks(List<TaskId> taskIds) {
    var results = taskIds.stream()
            .map(id -> Pair.of(
                    id,
                    Try.ofFailable(() -> tryToCancelTask(id))
            ))
            .collect(partitioningBy(pair -> pair.getRight().isSuccess()));

Tak wygląda logowanie wszystkich ID, dla których operacja się nie udała:

logger.warn("Failed to cancel for IDs {}",
        results.get(false).stream().map(Pair::getLeft).collect(toList()));

a tak rzucenie pierwszego z wyjątków:

results.get(false).stream().findFirst()
        .ifPresent(pair -> pair.getRight()
                .onFailure(e -> { throw new OperationException("Failed to cancel", e); })
        );

Na koniec kod zbierający wynik udanych operacji:

List<Cancellation> cancellations = results.get(true).stream()
        .map(pair -> pair.getRight().getUnchecked())
        .collect(toList());

Mam wątpliwości, czy użyłbym better-monads do czegoś innego niż przykład na blogu. Biblioteka nie miała commitów od 4 lat, nie ma też ani linijki JavaDoców. Co dziwne, nie ma lepszej alternatywy — niczego, co w kwestii funkcyjnej obsługi błędów robiłoby jedną rzecz, a dobrze.


Zdjęcie wykonał Krzysztof Niewolny.