Статья Обработка ошибок в Kotlin/Java: как правильно это делать?

  • Автор темы Firewoll
  • Дата начала
  • Ответы 0
  • Просмотры 222

Firewoll

Просто, ваш повелитель😈
Местный

Firewoll

Просто, ваш повелитель😈
Местный


Пожалуйста , Вход или Регистрация чтобы увидеть ссылку!



Обработка ошибок в любой разработке играет важнейшую роль. В программе может пойти не так практически всё: пользователь введёт некорректные данные, или они могут прийти такими по 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 можно в такой же лямбде ничего не оборачивать, а просто вызывать нужную функцию.


хотя иногда...

Исключения сами по себе являются особыми объектами. Помимо того, что их можно «пробрасывать» через методы, они ещё и собирают stacktrace при создании. Эта особенность потом помогает с анализом проблем и поиском ошибок, но может и привести к некоторым проблемам с производительностью, если логика работы приложения становится сильно завязанной на бросаемые исключения. Как показано в
Пожалуйста , Вход или Регистрация чтобы увидеть ссылку!
, отключение сборки stacktrace позволяет в этом случае значительно увеличить их производительность, но к нему стоит прибегать только в исключительных случаях, когда это действительно требуется!


Обработка ошибок

Основное, что нужно сделать с «неожиданными» ошибками, — найти место, где можно их перехватить. В JVM-языках это может быть либо точка создания потока, либо фильтр/точка входа в http-метод, где можно поставить try-catch с обработкой unchecked ошибок. Если вы используете какой-либо фреймворк, то, скорее всего, в нём уже есть возможность создавать общие обработчики ошибок, как, например, в Spring Framework можно использовать методы с аннотацией @ExceptionHandler.


До этих же центральных точек обработки можно «поднимать» исключения, которые мы не хотим обрабатывать в конкретных местах, прокидывая те же unckecked исключения (когда, например, не знаем, что делать именно в конкретном месте и как обрабатывать ошибку). Но этот способ не всегда подходит, потому что иногда может потребовать обработать ошибку на месте, и нужно проверять, что все места вызовов функций правильно обрабатываются. Рассмотрим способы сделать это.


  1. Всё же использовать исключения и тот же try-catch:
    int a = 10;
    int b = 20;
    int sum;
    try {
    sum = calculateSum(a,b);
    } catch (Exception e) {
    sum = -1;
    }

    Основной недостаток в том, что мы можем «забыть» обернуть его в try-catch в месте вызова и пропустить попытку обработки на месте, из-за чего исключение пробросится наверх до общей точки обработки ошибки. Тут можно перейти к checked исключениям (для Java), но тогда мы получим все те недостатки, о которых упоминалось выше. Этот подход удобно использовать, если обработка ошибки на месте не всегда требуется, но в редком случае она нужна.
  2. Использовать 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.
  3. Как один из возможных видов п.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 подойдёт тем, кто любит функциональный подход и кому по душе строить цепочки вызовов.
  4. Использовать 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?

    Такой подход подойдёт, если не очень важна причина ошибки и когда она только одна. Пустой ответ считается ошибкой и пробрасывается выше. Самая короткая запись, без создания дополнительных объектов, но такой подход не всегда можно применить.
  5. Аналогичен п.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 классами.


Буду рад увидеть в комментариях, что из этого используете вы, а может, есть ещё другие удобные методы обработки ошибок?


И спасибо всем, кто дочитал до конца!
 
Сверху Снизу