Источник


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


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


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

Комментарии (16)


  1. AstarothAst
    17.10.2019 14:03
    +1

    Для java монаду Either уже реализует www.vavr.io Ну, и статья заканчивается примерно там, где начинается ее заголовок — как представлять ошибки в коде, описано, а как их обрабатывать уже нет.


  1. reforms
    17.10.2019 15:21

    Если проект ориентирован на GUI, то ошибки подразделяются в прикладном смысле еще на 2 вида: Пользовательские и Системные.
    Пользовательские — те, которые должны отображаться клиенту с нормальным текстом.
    Все системные ошибки пользователь видит всегда как одну, например, Внутренняя ошибка сервера.
    У большинства разработчиков, начинаются трудности в понимании, какую ошибку нужно выбрасывать в том или ином случае.

    Вот здесь бы пару рецептов и лучших практик услышать…


    1. nerumb Автор
      17.10.2019 15:37

      Ошибки на сервере тоже могут быть весьма разными. Можно отталкиваться от стандартных HTTP кодов ошибок.
      Так пользователь может ввести невалидные данные, очень часто отправлять запросы или может быть просто не авторизован.


      1. loki82
        20.10.2019 21:48

        Только вот проблема вырисовывается на ровном месте. Завернуть http ошибку в стандартный объект не получится в некоторых библиотеках. Сам сейчас учусь retrofit. Так вот там ответ отличный от 200 Передаётся строкой и не распаршивается в нормальный вид. Хотя казалось бы… Bad request. И кинуть описание в json. Но нет. Сейчас как раз готовлю серию для 1с разработчиков.


    1. agent10
      17.10.2019 17:41

      Я бы добавил ещё, что есть одни и те же ошибки(как системные так и пользовательские), которые в одних случаях нужно отображать, а в других должны пройти тихо с записью в лог.


    1. JediPhilosopher
      18.10.2019 00:47
      +1

      Опыт показывает, что к «внутренней ошибке сервера» все равно лучше добавить какое-то разъяснение. Либо хотя бы пару осмысленных строчек (только не очень длинных и страшных для непосвященных), либо некий id ошибки или сессии, по которому затем на сервере ее можно будет найти в логах.
      Иначе как обычно: пользователь жалуется что у него ничего не работает, сообщение об ошибке неинформативное (та самая «внутренняя ошибка» и все), а на сервере в логи постоянно валится столько всего, что отследить какая именно ошибка случилась у этого пользователя без дополнительных подсказок очень сложно.


      1. reforms
        18.10.2019 09:41

        Ваша правда. С нашей стороны, более жизненный кейс — когда заказчик присылает скриншот от клиента, Внутренняя ошибка сервера и все. Требуется разобраться срочно, а логов нет, или не дают или присылают через 3 дня. Тогда, наличие дополнительного кода — это просто подарок.
        С другой стороны, тот же заказчик выставляет определнные требования к ошибкам, отдел безопасности выставляет требования к сокрытию данных, да и самих клиентов порой выбешивает — Внутренняя ошибка сервера. EA125. Вот и получается, что не все так однозначно… :)


  1. Beholder
    17.10.2019 20:52
    +1

    Исключения — это не ошибки, а прерывание нормального потока исполнения. Значит, если вы для себя рассчитываете "вот, у меня тут могут возникнуть какие-то ошибки, но поток исполнения это не будет прерывать, я их как-то обработаю" — то используйте не исключения, а какие-то специальные значения.


    1. Terranz
      19.10.2019 17:42

      Exception Driven Development


  1. Nexus7
    18.10.2019 09:19
    +2

    Добавлю свои пять копеек.
    Любая серьёзная ошибка, если она не замыкается на сервере или в глубине приложений, должна иметь возможность доехать до техподдержки, чтобы она могла самостоятельно или с помощью разработчиков объяснить пользователю, что делать дальше. Т.е. в интерфейсе заранее предусматривается страница с ошибкой, на которой выводится код ошибки и какая-либо подробная информация о случившемся, которую пользователь может передать в техподдержку голосом или через e-mail по кнопке «Сообщить об ошибке».

    Для формирования кода ошибки каждый модуль приложения/сервера нумеруется, а внутри него нумеруются все места, кидающие ошибки/исключения. И на выходе получаем код ошибки как

    module * 100 + errorNumber

    Который передаётся с нижних уровней до UI в классе, подобному
    /**
     * Описание ошибки, которое передаётся для отображения и обработки с нижних уровней в UI
     */
    data class ErrorDescription (
        val fatal: Boolean,     // степень важности ошибки:
                                // если true, то дальнейшая работа приложения не возможна, показываем диалог "переустановите или обратитесь к разработчику"
                                // false - показываем "попробуйте повторить операцию"
        val code: Int,          // код ошибки, см. ниже
        val desc: String,       // описание ошибки для пользователя
        val exMessage: String = "",  // сообщение в исключении
        val stackTrace: String = "" // stack trace исключения
    )
    

    Если ошибка приезжает с сервера, то достаточно иметь code и desc, чтобы техподдержка могла быстро её найти в серверных логах.


  1. lgorSL
    18.10.2019 10:22

    В статье смешивается понятие ошибки и исключения. Ошибка — это именно ошибка в коде, когда тот работает не так, как задумывалось. Например, фабрика возвращает null вместо объекта.
    Исключение — когда код работает корректно и происходит что-то нестандартное, но вполне нормальное — например, отсутствует файл, не отвечает сервер, пользователь ввёл кривые данные.
    Соответсвенно, исключения надо обрабатывать и сообщать о них, а подход к ошибкам может быть самым разным, и остановка программы — тоже неплохой вариант.


    1. Nexus7
      18.10.2019 10:41
      +1

      Исключение — это всего лишь один из механизмов доставки ошибки на верхние уровни, практически то же самое, что из функции возвращать -1 и errno присваивать код ошибки, только более удобно и централизованно обрабатываемо.

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


      1. gudvinr
        18.10.2019 21:43

        Если программа может сообщить что не так, значит в принципе об этом было известно и тому, кто добавил вывод сообщения об ошибке.
        Если возникает ошибка, которую никто не предполагал (а иначе её бы не было), то программа может остаться в неожиданном состоянии и экстренное завершение — довольно адекватный вариант. Допустим, в таком случае можно поставить глобальный обработчик и делать дамп всего что только можно и, проверяя его наличие при запуске, предлагать отправить отчёт.


        1. Nexus7
          18.10.2019 21:56

          Разработчики мобильных приложений могут даже не догадываться, какой из дальних микросервисов может сбойнуть и вернуть ошибку. Причём ошибки могут быть плавающими из-за временной недоступности каких-либо интеграционных сервисов (кто-то патч-корд выдернул, DNS лежит и т.д. и т.п.). И что, каждый раз падать? Или просто сказать «Код ошибки 50431, попробуйте повторить операцию позже».

          Особенно это касается встраиваемых систем, представьте, что у марсохода сбоит гироскоп/термометр/дальномер/камера, он куда будет дамп скидывать для отчёта?

          Самое главное, чего нет в этой статье, это как правильно рассказывать пользователю об ошибках, про это пишут в книжках про UI.


          1. gudvinr
            18.10.2019 22:28

            Прочитайте внимательно, пожалуйста.


            Если возникает ошибка, которую никто не предполагал

            Отказ удаленного сервиса (особенно 3rd party) — это не ошибка приложения, а прогнозируемое поведение, которое не оставит приложение в неопределённом состоянии.


            Когда у марсохода сбоит компонента и этот сбой можно обнаружить — это не ошибка системы управления.
            Но в случае именно ошибок для встраиваемых систем ещё более опасно оставаться в состоянии, которое разработчиком не предполагалось и в таких ситуациях космические аппараты перезапускают сами систему управления. Телеметрию, вообще говоря, с них так же передают, в т.ч. и информацию о сбоях.


            1. foal
              20.10.2019 15:10

              Демагогия. Если уж так, то «ошибок, которых никто не предполагал» вообще нет. Чисто логически это доказывается тем, что есть я, который предполагает, наличие любой возможной ошибки, в любой возможной системе. Вы же не уточнили, круг этих никого :) А если исходить из реалий, то отказ удаленного сервера — вполне себе ошибка, которую разработчик не предполагал, но и убивать приложение из-за этого не надо.
              Собственно, вы же, наверное, подходя к перекрестку не вызываете заранее машину скорой помощи, потому что не предполагаете, что вас собьёт машина. Но, если не дай бог это случится, то и добивать Вы себя не станете, правда?