- PVSM.RU - https://www.pvsm.ru -
Источник [1]
Обработка ошибок в любой разработке играет важнейшую роль. В программе может пойти не так практически всё: пользователь введёт некорректные данные, или они могут прийти такими по http, или мы ошиблись при написании сериализации/десериализации и в процессе обработки программа падает с ошибкой. Да может банально закончится место на диске.
¯_(ツ)_/¯, нет единого способа, и в каждой конкретной ситуации придётся подбирать наиболее подходящий вариант, но есть рекомендации, как это делать лучше.
К сожалению (или просто такая жизнь?), этот список можно продолжать бесконечно. Разработчику постоянно нужно думать о том, что где-то может возникнуть ошибка, и тут есть 2 ситуации:
И если ожидаемые ошибки хотя бы локализованы, то остальные могут произойти практически везде. В случае, если мы не обрабатываем ничего важного, то можно просто упасть с ошибкой (хотя и такое поведение недостаточно и требуется как минимум добавить сообщение в лог об ошибке). Но если именно сейчас происходит обработка платежа и нельзя просто упасть, а нужно хотя бы вернуть ответ о неуспешной операции?
Перед тем как рассмотрим способы обработки ошибок, несколько слов об Exception (исключениях):
Источник [2]
Иерархия исключений хорошо описана и о ней можно найти много информации, поэтому нет смысла тут её расписывать. Что до сих пор иногда вызывает жаркое обсуждение, так это checked
и unchecked
ошибки. И хоть unchecked
исключения большинство приняло предпочтительными (в Kotlin вообще нет checked
исключений), с этим не все ещё согласны.
За checked
исключениями действительно стояло благое намерение сделать их удобным механизмом обработки ошибок, но реальность внесла свои корректировки, хоть и сама идея внесения в сигнатуру всех исключений, которые могут быть брошены из этой функции, понятна и логична.
Давайте рассмотрим это на примере. Предположим, у нас есть функция method
, которая может бросить проверяемое исключение PanicException
. Такая функция будет выглядеть следующим образом:
public void method() throws PanicException { }
Из её описания видно, что она может бросить исключение и что исключение может быть только одно. Вроде выглядит вполне удобным? И пока у нас маленькая программа, всё так и есть. Но если программа чуть больше и таких функций становится больше, то появляются некоторые проблемы.
Проверяемые исключения требуют по спецификации, чтобы в сигнатуре функции перечислялись все возможные проверяемые исключения (либо общий предок для них). Поэтому, если у нас есть цепочка вызовов a
-> b
-> c
и самая вложенная функция кидает какое-либо исключение, то оно должно по цепочке быть проставлено у всех. А если этих исключений несколько, то и у самой верхней функции в сигнатуре должно быть описание их всех.
Так, по мере усложнения программы, этот подход приводит к тому, что у верхней функции исключения постепенно схлопываются к общим предкам и сводятся в конечном счёте к Exception
. Что в таком виде становится похожим на unchecked
исключение и сводит на нет все преимущества проверяемых исключений.
А если учесть, что программа, как живой организм, постоянно изменяется и эволюционирует, то практически невозможно заранее предусмотреть, какие исключения могут в ней возникать. И в результате получается ситуация, что когда мы добавляем новую функцию с новым исключением, приходится пройтись по всей цепочке её использования и менять сигнатуры у всех функций. Согласитесь, это не самое приятное занятие (даже учитывая, что современные IDE это делают за нас).
Но последний, и, наверное, самых большой гвоздь в проверяемые исключения «вогнали» лямбды из Java 8. В их сигнатуре нет никаких проверяемых исключений ¯_(ツ)_/¯ (т.к. в лямбде можно вызывать любую функцию, с любой сигнатурой), поэтому любой вызов функции с проверяемым исключением из лямбды заставляет оборачивать её в проброс исключения как непроверяемое:
Stream.of(1,2,3).forEach(item -> {
try {
functionWithCheckedException();
} catch (Exception e) {
throw new RuntimeException("rethrow", e);
}
});
К счастью, в спецификации JVM вообще нет проверяемых исключений, поэтому в Kotlin можно в такой же лямбде ничего не оборачивать, а просто вызывать нужную функцию.
Хоть это и приводит иногда к неожиданным последствиям, как, например, к неверной работе @Transactional
в Spring Framework
, который «ожидает» только unckecked
исключения. Но это больше особенность фреймворка, и, возможно, такое поведение в Spring изменится в ближайшее время github issue [3].
Исключения сами по себе являются особыми объектами. Помимо того, что их можно «пробрасывать» через методы, они ещё и собирают stacktrace при создании. Эта особенность потом помогает с анализом проблем и поиском ошибок, но может и привести к некоторым проблемам с производительностью, если логика работы приложения становится сильно завязанной на бросаемые исключения. Как показано в статье [4], отключение сборки stacktrace позволяет в этом случае значительно увеличить их производительность, но к нему стоит прибегать только в исключительных случаях, когда это действительно требуется!
Основное, что нужно сделать с «неожиданными» ошибками, — найти место, где можно их перехватить. В JVM-языках это может быть либо точка создания потока, либо фильтр/точка входа в http-метод, где можно поставить try-catch с обработкой unchecked
ошибок. Если вы используете какой-либо фреймворк, то, скорее всего, в нём уже есть возможность создавать общие обработчики ошибок, как, например, в Spring Framework можно использовать методы с аннотацией @ExceptionHandler
.
До этих же центральных точек обработки можно «поднимать» исключения, которые мы не хотим обрабатывать в конкретных местах, прокидывая те же unckecked
исключения (когда, например, не знаем, что делать именно в конкретном месте и как обрабатывать ошибку). Но этот способ не всегда подходит, потому что иногда может потребовать обработать ошибку на месте, и нужно проверять, что все места вызовов функций правильно обрабатываются. Рассмотрим способы сделать это.
Всё же использовать исключения и тот же try-catch:
int a = 10;
int b = 20;
int sum;
try {
sum = calculateSum(a,b);
} catch (Exception e) {
sum = -1;
}
Основной недостаток в том, что мы можем «забыть» обернуть его в try-catch в месте вызова и пропустить попытку обработки на месте, из-за чего исключение пробросится наверх до общей точки обработки ошибки. Тут можно перейти к checked
исключениям (для Java), но тогда мы получим все те недостатки, о которых упоминалось выше. Этот подход удобно использовать, если обработка ошибки на месте не всегда требуется, но в редком случае она нужна.
Использовать sealed class как результат вызова (Kotlin).
В Kotlin можно ограничить количество наследников у класса, сделать их вычисляемыми на этапе компиляции — это позволяет компилятору проверять, что все возможные варианты будут разобраны в коде. В Java можно сделать общий интерфейс и несколько наследников, правда, теряя проверки на уровне компиляции.
sealed class Result
data class SuccessResult(val value: Int): Result()
data class ExceptionResult(val exception: Exception): Result()
val a = 10
val b = 20
val sum = when (val result = calculateSum(a,b)) {
is SuccessResult -> result.value
is ExceptionResult -> {
result.exception.printStackTrace()
-1
}
}
Тут мы получаем что-то вроде golang
-подхода к ошибкам, когда нужно в явном виде проверять результирующие значения (или явно игнорировать). Подход достаточно практичный и особенно удобный, когда требуется в каждой из ситуаций прокидывать много параметров. Класс Result
можно расширить различными методами, которые упрощают получение результата с пробросом исключения выше, если таковое есть (т.е. нам не нужно в месте вызова обрабатывать ошибку). Основным недостатком будет только создание промежуточных лишних объектов (и чуть более многословная запись), но и его можно убрать, используя inline
классы (если нам достаточно одного аргумента). и, как частный пример, есть класс Result
из Kotlin. Правда, он пока только для внутреннего использования, т.к. в будущем его реализация может немного измениться, но если хочется им воспользоваться, то можно добавить флаг компиляции -Xallow-result-return-type
.
Как один из возможных видов п.2, использование типа из функционального программирования Either
, который может быть либо результатом, либо ошибкой. Сам тип может быть как sealed
классом, так и inline
классом. Ниже пример использования реализации из библиотеки arrow
:
val a = 10
val b = 20
val value = when(val result = calculateSum(a,b)) {
is Either.Left -> {
result.a.printStackTrace()
-1
}
is Either.Right -> result.b
}
Больше всего Either
подойдёт тем, кто любит функциональный подход и кому по душе строить цепочки вызовов.
Использовать Option
или nullable
тип из Kotlin:
fun testFun() {
val a = 10
val b = 20
val sum = calculateSum(a,b) ?: throw RuntimeException("some exception")
}
fun calculateSum(a: Int, b: Int): Int?
Такой подход подойдёт, если не очень важна причина ошибки и когда она только одна. Пустой ответ считается ошибкой и пробрасывается выше. Самая короткая запись, без создания дополнительных объектов, но такой подход не всегда можно применить.
Аналогичен п.4, только использует хардкодное значение как маркер ошибки:
fun testFun() {
val a = 10
val b = 20
val sum = calculateSum(a,b)
if (sum == -1) {
throw RuntimeException(“error”)
}
}
fun calculateSum(a: Int, b: Int): Int
Наверное, это самый старый подход к обработке ошибок, пришедший ещё из C
(или даже с Algol). Никаких накладных расходов, только не совсем понятный код (вместе с ограничениями на выбор результата), но, в отличие от п.4, появляется возможность делать различные коды ошибок, если требуется больше одного возможного исключения.
Все подходы можно комбинировать в зависимости от ситуации, и нет среди них того, который подойдёт во всех случаях.
Так, например, можно добиться подхода golang
к ошибкам, используя sealed
классы, а там, где это не очень удобно, переходить к unchecked
ошибкам.
Или использовать в большей части мест nullable
-тип как маркер того, что не удалось подсчитать значение или достать его откуда-либо (например, как индикатор, что значение не нашлось в базе).
А если же у вас полностью функциональный код вместе с arrow
или ещё какой-либо аналогичной библиотекой, то тогда, скорее всего, лучше использовать Either
.
Что же до http-серверов, то в них проще всего поднимать все ошибки до центральных точек и только в некоторых местах комбинировать nullable
подход с sealed
классами.
Буду рад увидеть в комментариях, что из этого используете вы, а может, есть ещё другие удобные методы обработки ошибок?
И спасибо всем, кто дочитал до конца!
Автор: Евгений Захаров
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/333542
Ссылки в тексте:
[1] Источник: https://stackify.com/web-api-error-handling/
[2] Источник: https://www.javamex.com/tutorials/exceptions/exceptions_hierarchy.shtml
[3] github issue: https://github.com/spring-projects/spring-framework/issues/23473
[4] статье: http://java-performance.info/throwing-an-exception-in-java-is-very-slow/
[5] Источник: https://habr.com/ru/post/471766/?utm_source=habrahabr&utm_medium=rss&utm_campaign=471766
Нажмите здесь для печати.