Обработка ошибок в Kotlin/Java: как правильно это делать?
Источник
Обработка ошибок в любой разработке играет важнейшую роль. В программе может пойти не так практически всё: пользователь введёт некорректные данные, или они могут прийти такими по http, или мы ошиблись при написании сериализации/десериализации и в процессе обработки программа падает с ошибкой. Да может банально закончится место на диске.
¯_(ツ)_/¯, нет единого способа, и в каждой конкретной ситуации придётся подбирать наиболее подходящий вариант, но есть рекомендации, как это делать лучше.
Предисловие
К сожалению (или просто такая жизнь?), этот список можно продолжать бесконечно. Разработчику постоянно нужно думать о том, что где-то может возникнуть ошибка, и тут есть 2 ситуации:
- когда происходит ожидаемая ошибка в вызове функции, которую мы предусмотрели и можем попробовать обработать;
- когда в процессе работы происходит неожиданная ошибка, которую мы не предусмотрели.
И если ожидаемые ошибки хотя бы локализованы, то остальные могут произойти практически везде. В случае, если мы не обрабатываем ничего важного, то можно просто упасть с ошибкой (хотя и такое поведение недостаточно и требуется как минимум добавить сообщение в лог об ошибке). Но если именно сейчас происходит обработка платежа и нельзя просто упасть, а нужно хотя бы вернуть ответ о неуспешной операции?
Перед тем как рассмотрим способы обработки ошибок, несколько слов об Exception (исключениях):
Exception
Источник
Иерархия исключений хорошо описана и о ней можно найти много информации, поэтому нет смысла тут её расписывать. Что до сих пор иногда вызывает жаркое обсуждение, так это 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.
Исключения сами по себе являются особыми объектами. Помимо того, что их можно «пробрасывать» через методы, они ещё и собирают stacktrace при создании. Эта особенность потом помогает с анализом проблем и поиском ошибок, но может и привести к некоторым проблемам с производительностью, если логика работы приложения становится сильно завязанной на бросаемые исключения. Как показано в статье, отключение сборки 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
классами.
Буду рад увидеть в комментариях, что из этого используете вы, а может, есть ещё другие удобные методы обработки ошибок?
И спасибо всем, кто дочитал до конца!