«1. Обзор

В этой статье мы рассмотрим функциональный способ обработки ошибок, отличный от стандартного блока try-catch.

Мы будем использовать класс Try из библиотеки Vavr, который позволит нам создать более плавный и осознанный API, внедрив обработку ошибок в обычный поток обработки программы.

Если вы хотите получить больше информации о Vavr, прочтите эту статью.

2. Стандартный способ обработки исключений

Допустим, у нас есть простой интерфейс с методом call(), который возвращает Response или выдает ClientException, которое является проверенным исключением в случае сбоя:

public interface HttpClient {
    Response call() throws ClientException;
}

~ ~~ Response — это простой класс только с одним полем id:

public class Response {
    public final String id;

    public Response(String id) {
        this.id = id;
    }
}

Допустим, у нас есть служба, которая вызывает этот HttpClient, тогда нам нужно обработать это проверенное исключение в стандартном блоке try-catch: ~~ ~

public Response getResponse() {
    try {
        return httpClient.call();
    } catch (ClientException e) {
        return null;
    }
}

Когда мы хотим создать плавный и функционально написанный API, каждый метод, выбрасывающий проверенные исключения, нарушает ход выполнения программы, а наш программный код состоит из множества блоков try-catch, что делает его очень трудным для чтения.

В идеале мы хотели бы иметь специальный класс, который инкапсулирует состояние результата (успех или сбой), а затем мы можем связать операции в соответствии с этим результатом.

3. Обработка исключений с помощью Try

Библиотека Vavr дает нам специальный контейнер, который представляет собой вычисление, которое может либо привести к исключению, либо завершиться успешно.

Операция включения в объект Try дала нам результат, который является либо успешным, либо неудачным. Затем мы можем выполнять дальнейшие операции в соответствии с этим типом.

Давайте посмотрим, как тот же метод getResponse(), что и в предыдущем примере, будет выглядеть при использовании Try:

public class VavrTry {
    private HttpClient httpClient;

    public Try<Response> getResponse() {
        return Try.of(httpClient::call);
    }

    // standard constructors
}

Важно отметить, что возвращается тип Try\u003cResponse\u003e. Когда метод возвращает такой тип результата, нам нужно обработать его правильно и помнить, что этот тип результата может быть успешным или неудачным, поэтому нам нужно обрабатывать это явно во время компиляции.

3.1. Обработка успеха

Давайте напишем тестовый пример, который использует наш класс Vavr в случае, когда httpClient возвращает успешный результат. Метод getResponse() возвращает объект Try\u003cResposne\u003e. Поэтому мы можем вызвать для него метод map(), который выполнит действие над Response только тогда, когда Try будет иметь тип Success:

@Test
public void givenHttpClient_whenMakeACall_shouldReturnSuccess() {
    // given
    Integer defaultChainedResult = 1;
    String id = "a";
    HttpClient httpClient = () -> new Response(id);

    // when
    Try<Response> response = new VavrTry(httpClient).getResponse();
    Integer chainedResult = response
      .map(this::actionThatTakesResponse)
      .getOrElse(defaultChainedResult);
    Stream<String> stream = response.toStream().map(it -> it.id);

    // then
    assertTrue(!stream.isEmpty());
    assertTrue(response.isSuccess());
    response.onSuccess(r -> assertEquals(id, r.id));
    response.andThen(r -> assertEquals(id, r.id)); 
 
    assertNotEquals(defaultChainedResult, chainedResult);
}

Функция actionThatTakesResponse() просто принимает Response в качестве аргумента и возвращает хэш-код поля id:

public int actionThatTakesResponse(Response response) {
    return response.id.hashCode();
}

Как только мы сопоставляем наше значение с помощью функции actionThatTakesResponse(), мы выполняем метод getOrElse().

Если в Try есть Success, возвращается значение Try, в противном случае возвращается defaultChainedResult. Наше выполнение httpClient прошло успешно, поэтому метод isSuccess возвращает значение true. Затем мы можем выполнить метод onSuccess(), который выполняет действие над объектом Response. У Try также есть метод andThen, который принимает Consumer, который потребляет значение Try, когда это значение равно Success.

Мы можем рассматривать наш ответ Try как поток. Для этого нам нужно преобразовать его в поток с помощью метода toStream(), тогда все операции, доступные в классе Stream, можно будет использовать для выполнения операций над этим результатом.

Если мы хотим выполнить действие над типом Try, мы можем использовать метод transform(), который принимает Try в качестве аргумента и выполняет над ним действие, не разворачивая вложенное значение:

public int actionThatTakesTryResponse(Try<Response> response, int defaultTransformation){
    return response.transform(responses -> response.map(it -> it.id.hashCode())
      .getOrElse(defaultTransformation));
}

3.2. Обработка сбоя

Давайте напишем пример, когда наш HttpClient будет генерировать ClientException при выполнении.

По сравнению с предыдущим примером, наш метод getOrElse вернет defaultChainedResult, потому что Try будет иметь тип Failure:

@Test
public void givenHttpClientFailure_whenMakeACall_shouldReturnFailure() {
    // given
    Integer defaultChainedResult = 1;
    HttpClient httpClient = () -> {
        throw new ClientException("problem");
    };

    // when
    Try<Response> response = new VavrTry(httpClient).getResponse();
    Integer chainedResult = response
        .map(this::actionThatTakesResponse)
        .getOrElse(defaultChainedResult);
     Option<Response> optionalResponse = response.toOption();

    // then
    assertTrue(optionalResponse.isEmpty());
    assertTrue(response.isFailure());
    response.onFailure(ex -> assertTrue(ex instanceof ClientException));
    assertEquals(defaultChainedResult, chainedResult);
}

Метод getReposnse() возвращает Failure, таким образом, метод isFailure возвращает true.

Мы могли бы выполнить обратный вызов onFailure() для возвращенного ответа и увидеть, что исключение имеет тип ClientException. Объект типа Try может быть сопоставлен с типом Option с помощью метода toOption().

«Это полезно, когда мы не хотим распространять результат Try по всей кодовой базе, но у нас есть методы, которые обрабатывают явное отсутствие значения, используя тип Option. Когда мы сопоставляем нашу ошибку с опцией, метод isEmpty() возвращает true. Когда объект Try имеет тип Success, вызов toOption для него сделает Option, который определен, поэтому метод isDefined() вернет true.

3.3. Использование сопоставления с образцом

Когда наш httpClient возвращает исключение, мы можем выполнить сопоставление с образцом для типа этого исключения. Затем в соответствии с типом этого исключения в методе recovery() мы можем решить, хотим ли мы восстановиться после этого исключения и превратить нашу ошибку в успех или оставить результат наших вычислений как ошибку:

@Test
public void givenHttpClientThatFailure_whenMakeACall_shouldReturnFailureAndNotRecover() {
    // given
    Response defaultResponse = new Response("b");
    HttpClient httpClient = () -> {
        throw new RuntimeException("critical problem");
    };

    // when
    Try<Response> recovered = new VavrTry(httpClient).getResponse()
      .recover(r -> Match(r).of(
          Case(instanceOf(ClientException.class), defaultResponse)
      ));

    // then
    assertTrue(recovered.isFailure());

Сопоставление шаблонов внутри метода recovery() превратит отказ в успех только в том случае, если тип исключения является ClientException. В противном случае он оставит его как сбой(). Мы видим, что наш httpClient генерирует RuntimeException, поэтому наш метод восстановления не обработает этот случай, поэтому isFailure() возвращает true.

Если мы хотим получить результат от восстановленного объекта, но в случае критической ошибки повторно выбрасываем это исключение, мы можем сделать это с помощью метода getOrElseThrow():

recovered.getOrElseThrow(throwable -> {
    throw new RuntimeException(throwable);
});

Некоторые ошибки являются критическими, и когда они происходят, мы хотим сигнализировать об этом явным образом, выбрасывая исключение выше в стеке вызовов, чтобы позволить вызывающей стороне принять решение о дальнейшей обработке исключения. В таких случаях повторное создание исключения, как в приведенном выше примере, очень полезно.

Когда наш клиент выдает некритическое исключение, наше сопоставление с образцом в методе recovery() превратит нашу неудачу в успех. Мы восстанавливаемся после двух типов исключений ClientException и IllegalArgumentException:

@Test
public void givenHttpClientThatFailure_whenMakeACall_shouldReturnFailureAndRecover() {
    // given
    Response defaultResponse = new Response("b");
    HttpClient httpClient = () -> {
        throw new ClientException("non critical problem");
    };

    // when
    Try<Response> recovered = new VavrTry(httpClient).getResponse()
      .recover(r -> Match(r).of(
        Case(instanceOf(ClientException.class), defaultResponse),
        Case(instanceOf(IllegalArgumentException.class), defaultResponse)
       ));
    
    // then
    assertTrue(recovered.isSuccess());
}

Мы видим, что isSuccess() возвращает true, поэтому наш код обработки восстановления работал успешно.

4. Заключение

В этой статье показано практическое использование контейнера Try из библиотеки Vavr. Мы рассмотрели практические примеры использования этой конструкции для более функциональной обработки отказа. Использование Try позволит нам создать более функциональный и читабельный API.

Реализацию всех этих примеров и фрагментов кода можно найти в проекте GitHub — это проект на основе Maven, поэтому его легко импортировать и запускать как есть.