Размер
Размышления о способах обработки ошибок
Play

Размышления о способах обработки ошибок

Тема обработки ошибок сложна и неоднозначна. До сих пор нет какого-то оптимального подхода или группы подходов к этой проблеме. Все они страдают от тех или иных недостатков. В этой статье я хотел бы поделиться своими мыслями на эту тему, и что не менее важно, почерпнуть новые знания в комментариях.

  • Ошибка ведет к некорректному состоянию программы и мы должны максимально быстро и подробно сообщить о ней пользователю. Системы с таким способом обработки ошибок обладают свойством fail-fast (FF).
  • Ошибка является стандартной ситуацией и будет обработана в автоматическом режиме без участия пользователя. Подобные системы являются fault-tolerant (FT)

Зачем вообще выделять несколько способов работы с ошибками, а не использовать какой-то один для всех случаев? Дело в том, что функции рассчитанные на FF применяемые в режиме FT, как правило, будут приводить к слишком медленной работе всей системы. В то время как функции рассчитанные на FT, используемые в режиме FF не будут предоставлять достаточно информации для быстрого поиска ошибки пользователем.

Множество примеров несоответствия целей и способов использования порождает стандартная функция из JDK Integer.parseInt, которая явно проектировалась из расчета на FF. Например если вы в цикле читаете строки из потока и обрабатываете только числа, а остальные пропускаете. В этом случае try < Integer.parseInt(. ) >catch может замедлить ваш код в несколько раз. Более подробное объяснение и тесты можно посмотреть здесь nadeausoftware.com/articles/2009/08/java_tip_how_parse_integers_quickly.

FT-функции должны возвращать информацию об ошибке в наиболее сжатом виде, чтобы не расходовать напрасно ресурсы. Чаще всего это либо значение типа boolean, либо код ошибки типа int. При этом, если случилась какая-то фатальная ошибка, то информацию о ней пользователю или в лог должна передавать уже вышестоящая FF-функция. FT-функции должны быть минимального размера, чтобы по коду ошибки можно было понять в каком месте эта ошибка произошла.

Если вы пишете обычное бизнес- или веб-приложение, т.е. такое, где основную роль играет внешний пользователь (а таких приложений в настоящий момент подавляющее большинство), а не систему управления марсоходом, то 99% вашего кода будет FF. Т.е. от пользователя будут требовать ввода корректных данных, а если же какие-то некорректные всё-таки проскочили, то падать с ошибкой и требовать от разработчика добавить больше проверок. Весь код, обрабатывающий ошибки, будет занимать < 1% машинного времени, даже если его объем велик, если только вы не будете использовать FF-функции из FT-кода. Поэтому в дальнейшем я хотел бы более подробно рассмотреть способы проектирования FF функций и уменьшения количества кода, необходимого для удобной обработки ошибок.

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

  • Ошибка должна быть описана достаточно подробно, чтобы пользователь понимал причину её возникновения. Например: деление на ноль.
  • Пользователь должен понимать, где искать эту причину. Т.е. не только, что это деление на ноль, но и в какое поле он этот ноль ввел. В какой форме было это поле и на какой вкладке нашего многостраничного приложения была эта форма.
  • Описание ошибки для пользователя не должно содержать всякого информационного мусора, типа трассировки стека вызовов. По крайней мере без клика по кнопке «подробнее».
  • Должна быть доступна информация об ошибке, по которой программист сможет легко найти место её возникновения, т.е. скорее всего это как раз стек вызовов.

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

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

Реализация данного функционала может быть представлена функцией

Т.к. полная реализация может занимать несколько сотен, а то и тысяч строк, в зависимости от сложности форматов, то код стоит разбить на ряд вложенных функций с минимальной зоной ответственности и максимальной чистотой (я про pure functions). Допустим у нас есть следующие функции.

Дерево вызовов может выглядеть так:

В нашем выдуманном примере некоторые должности могут устареть и тогда менеджер должен вручную, в зависимости от неких личных соображений, исправить устаревшую должность на одну из 10 новых, которые пришли ей на замену. Но как было сказано, такие должности встречаются крайне редко.

Один из способов, особенно распространенный в java-сообществе, подразумевает использование исключений для обработки ошибок. Допустим isOutdated возвращает false. В этом случае convertPosition не может продолжать работу и выкидывает исключение. Код, вызывающий convertFiles перехватывает его и уведомляет пользователя о произошедшем, а также пишет стек вызовов в лог (если вдруг должность не устарела, то программист быстро найдет источник ошибки).

Однако, чем плох этот способ (по крайней мере в текущей реализации в jvm)? Функция convertPosition ничего не знает о контексте, в котором она выполняется, поэтому все что она может сказать об ошибке, так это то, что должность устарела. Она не знает у какого человека была эта должность, в каком файле и на какой строке искать ошибку. По такому сообщению об ошибке пользователь вообще не поймет, что ему делать дальше. Ситуацию можно попытаться исправить двумя способами.

Первый — передавать во все внутренние функции контекст (т.е. некий объект, содержащий человека, файл и пр.). При этом усложнятся сигнатуры функций, способ вызова, понизиться читабельность программы в целом, сильно увеличится связанность функций между собой. Иными словами: не надо так.

Второй. convertPosition кидает исключение. convertManager и convertProgrammer содержат код:

Т.е. мы перехватываем исключение, возникшее в convertPosition, а вместо него кидаем новое с измененным сообщением, дополнительной информацией в виде человека и указанием предыдущего исключения в качестве cause-параметра нового исключения. Эту процедуру можно повторить на более высоких уровнях. Таким образом к самому верху у нас будет дерево исключений. Пользователю мы можем выдать сообщение, составленное из сообщений всех наших исключений, так что будет понятно и в каком файле ошибка и в какой строке и что случилось (т.е. должность устарела). Программист получит в логе трассировку.

В чем минусы такого подхода? Во-первых нам приходится кидать много исключений. И хотя ресурсов особо на такое дело не жалко, но здесь уже чувствуется некий излишек в расходах. Во вторых синтаксис таков, что при усложнении примера можно закопаться в try. catch. Например может быть пример, когда на следующем шаге мы передаем в функцию данные, полученные на предыдущем. Тогда уже мы имеем набор вложенных try. catch. Обработка человека могла бы выглядеть так:

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

Я считаю, что исключения в своем текущем виде должны использоваться только с двумя целями: 1. Чтобы покинуть текущий фрейм стека. И для этого, по хорошему, должна быть более легковесная альтернатива, но её сейчас нет. 2. В ситуациях, встречающихся в количестве 1 на миллион строк кода, когда нет никакой больше возможности сообщить об ошибке. Например в стандартной библиотеке scala есть класс Option с методом get, который возвращает значение только если у нас в переменной потомок класса Option — класс Some. Если же там класс None, то возникает исключение. В данном случае нет возможности заменить результат get на Option[X], т.к. это сделает вызов get просто бессмысленным и нам остается только выкинуть исключение. Однако во всем остальном коде в аналогичной ситуации мы может заменить наш результат X на Option[X] и обойтись без исключений.

Так что же предлагает нам стандартная библиотека scala, библиотека scalaz и haskell. В интернете есть множество статей на эту тему, да и сама тема не так уж нова. Поэтому пересказывать эти статьи, как мне кажется, не стоит. Суть их сводится к использованию имеющихся монад.

Например статья про возможности стандартной библиотеки tersesystems.com/2012/12/27/error-handling-in-scala предлагает нам использовать Option, Either и Try. Но всё это работает на достаточно простых примерах. Если же нам при переписывании convertPerson потребуется использовать условие посередине, наподобие if (distance == 0) . то такая ситуация потребует разбиения одного общего for на два, что сразу же создаст кода не меньше чем при использовании try. catch. А если же наша функция, возвращающая Either или Try, будет вызываться из цикла foreach, map или fold, то единственный способ прервать цикл, это использовать scala.util.control.Breaks и переменную на уровне выше, если нужно передать наверх какое-то значение.

Scalaz, со своими \/, -\/ и \/- в сущности почти никак не меняет ситуацию typelevel.org/blog/2014/02/21/error-handling.html Ну или я чего-то про неё не понимаю и буду рад услышать об этом в комментариях.

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

В haskell используется монада Error, которая очень похожа на Try и \/, -\/, \/-. book.realworldhaskell.org/read/error-handling.html Но там не возникают многие из проблем, которые возникают в scala, из-за другого устройства самого кода и стандартной библиотеки. Поэтому там этот подход вполне себе может считаться рабочим, хотя и обладает также рядом недостатков.

Мой велосипед

Я попытался взять лучшее из обоих подходов (исключения и монады) и устранить, по возможности, недостатки. Результат можно посмотреть здесь github.com/cs0ip/habr-error-handling Для дальнейшего прочтения необходимо заглянуть в код, чтобы мне не пришлось приводить всё его описание здесь. Данный код лучше воспринимать не как готовую библиотеку, хотя я и использую его в своей разработке, а скорее как реализацию идеи, которую можно развить. В именах классов используются сокращения во-первых для краткости, во-вторых для уменьшения возможности пересечения с уже имеющимися именами, которые как правило сокращения не используют (по крайней мере у меня).

Я использовал 4 сущности. Res — класс, похожий на Try. Его потомки Ok и Err, соответствующие корректному результату и ошибке. А также класс Exit, служащий для прерывания циклов и передачи значений вверх по стеку. Exit похож на scala.util.control.Breaks. Ко всему этому были добавлены методы, делающие жизнь сносной при использовании кода, не поддерживающего Res. Так, например, Res можно создавать из Option, Either, Try и даже Boolean, что делает возможным вставку в for операторов if, способных прерывать выполнение и не относящимся к предыдущему выражению. Кроме того есть возможность легко обрабатывать код, кидающий исключения с помощью функции safe, без необходимости оборачивать его в Try.

Предположим, что все функции, написанные нами теперь поддерживают Res, тогда convertPerson можно переписать в следующем виде:

Err хранит в себе значение типа Option[Throwable], что во-первых позволяет отслеживать стек вызовов, а во-вторых позволяет отказаться от создания исключения вообще, где нужно повысить производительность. Кроме того Err хранит список сообщений об ошибке и позволяет легко заменять последнее сообщение и добавлять новое. Также надо отметить, что Err полностью персистентен, что исключает возможность случайной модификации данных.

Пример использования Exit может выглядеть так:

exitRes[X] ожидает получить значение типа Res[X]. К сожалению при использовании exitRes (и exit) всегда нужно указывать тип получаемого значения, т.к. компилятор не может его вывести. Теоретически вывод типа можно сделать с помощью макросов, но пока они нестабильны, я не хочу их использовать.

Также хотелось отметить ещё пару моментов. Если вы пишете библиотеку для общественного пользования, то возможно вы хотите указывать для каждой ошибки свой тип, как это можно сделать с помощью исключений (InputException, NullPointerException и т.д.). Err позволяет как указывать на тип ошибки с помощью содержащегося в нем Throwable, так и с помощью каждого элемента из списка сообщений lst. Т.е. вы можете создавать своих наследников Err.Er. Кроме того хотелось бы отметить что благодаря implicit convertion из String в Err.Er строки можно использовать везде, где сигнатура предполагает Err.Er.

Если же вы пишете своё приложение, а не публичное api, то чаще всего в fail-fast режиме вам не важен тип ошибки, а важно лишь сообщение о ней для пользователя и местоположение для возможности исправить. В этом случае в примерах выше можно опустить все проверки типа «case _: NotFoundException» и код станет ещё более компактным.

📎📎📎📎📎📎📎📎📎📎