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


  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 классами.


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


И спасибо всем, кто дочитал до конца!

Источник: https://habr.com/ru/company/funcorp/blog/471766/#habracut

Интересные статьи

Интересные статьи

Периодически мне в разных вариантах задают вопрос, который «в среднем» звучит так: «что лучше: заказать интернет-магазин на бесплатной CMS или купить готовое решение на 1С-Битрикс и сделать магазин на...
В интернет-магазинах, в том числе сделанных на готовых решениях 1C-Битрикс, часто неправильно реализован функционал быстрого заказа «Купить в 1 клик».
1С Битрикс: Управление сайтом (БУС) - CMS №1 в России по версии портала “Рейтинг Рунета” за 2018 год. На рынке c 2003 года. За это время БУС не стоял на месте, обрастал новой функциональностью...
У каждого тимлида есть своё кладбище сотрудников управленческих ошибок. Каждый день публикуются новые статьи «5 ошибок начинающего разработчика», «7 примеров того, как не надо управлять процессам...
Пунктуация очень важна, если вы хотите донести свою мысль четко, и получить ожидаемую реакцию. Однако в английском языке пунктуация серьезно отличается от того, к чему мы привыкли в русском я...