Перейти к основному содержимому

Руководство по испытанию в Вавре

· 7 мин. чтения

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<Response>. Когда метод возвращает такой тип результата, нам нужно обработать его правильно и помнить, что этот тип результата может быть Success или Failure , поэтому нам нужно обрабатывать это явно во время компиляции.

3.1. Обращение к успеху

Давайте напишем тестовый пример, который использует наш класс Vavr в случае, когда httpClient возвращает успешный результат. Метод getResponse() возвращает объект Try<Resposne> . Поэтому мы можем вызвать для него метод 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 в качестве аргумента и возвращает hashCode поля 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() возвращает сбой , поэтому метод isFailure возвращает true. ``

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

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

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

Когда наш httpClient возвращает Exception , мы можем выполнить сопоставление с образцом для типа этого Exception. Затем, в соответствии с типом этого исключения в методе 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, поэтому его легко импортировать и запускать как есть.