Pull to refresh

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

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

Код в статье приводится на scala, однако рассматриваемый подход может быть реализован на многих других языках (c++ с помощью макросов, java с помощью JetBrains MPS и т.д.). Наиболее близким аналогом рассматриваемого подхода является способ обработки ошибок в haskell.

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

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

Зачем вообще выделять несколько способов работы с ошибками, а не использовать какой-то один для всех случаев? Дело в том, что функции рассчитанные на 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-подходе наша задача максимально быстро свернуть всю текущую деятельность и оповестить пользователя об ошибке, то необходимо сделать сообщение об ошибке максимально информативным и при этом неважно какие ресурсы для этого потребуются, т.к. узким местом всё равно будет пользователь либо программист, который эту ошибку устранит вообще. Т.е. в данном случае ошибка является внештатной ситуацией и система не рассчитана на постоянный цикл обработки такой ошибки с максимальной пропускной способностью.

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

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

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

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

Реализация данного функционала может быть представлена функцией
def convertFiles(files: List[File]): Unit

Т.к. полная реализация может занимать несколько сотен, а то и тысяч строк, в зависимости от сложности форматов, то код стоит разбить на ряд вложенных функций с минимальной зоной ответственности и максимальной чистотой (я про pure functions). Допустим у нас есть следующие функции.
def convertFiles(files: List[File]): Unit
def convertFile(in: File): Unit
def convertStream(is: InputStream, os: OutputStream): Unit
def convertCity(el: InCity): OutCity
def convertPersons(pl: List[InPerson]): List[OutPerson]
def convertManager(el: InManager): OutManager
def convertProgrammer(el: InProgrammer): OutProgrammer
def convertPosition(el: InPosition): OutPosition      
def isOutdated(f: PosType): Boolean

Дерево вызовов может выглядеть так:
def convertFiles(files: List[File]): Unit
  def convertFile(in: File): Unit
    def convertStream(is: InputStream, os: OutputStream): Unit
      def convertCity(c: InCity): OutCity
      def convertPersons(pl: List[InPerson]): List[OutPerson]
        def convertManager(el: InManager): OutManager
          def convertPosition(el: InPosition): OutPosition      
            def isOutdated(f: PosType): Boolean          
        def convertProgrammer(el: InProgrammer): OutProgrammer
          def convertPosition(el: InPosition): OutPosition      
            def isOutdated(f: PosType): Boolean

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

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

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

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

Второй. convertPosition кидает исключение. convertManager и convertProgrammer содержат код:
def convertManager(el: InManager): OutManager = {
  ...
  try {
    ...
    val position = convertPosition(el.position)
    ...    
  } catch {
    case e: PositionException => throw new PersonException(s"ошибка при обработке менеджера ${p.name}", p, e)
  }
  ...
}

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

В чем минусы такого подхода? Во-первых нам приходится кидать много исключений. И хотя ресурсов особо на такое дело не жалко, но здесь уже чувствуется некий излишек в расходах. Во вторых синтаксис таков, что при усложнении примера можно закопаться в try...catch. Например может быть пример, когда на следующем шаге мы передаем в функцию данные, полученные на предыдущем. Тогда уже мы имеем набор вложенных try...catch. Обработка человека могла бы выглядеть так:
case class InPerson(name: String, tel: String, addr: Address, age: Age)
case class OutPerson(id: Int, name: String, homeAdder: Address, workAddr: Address, distance: Double)

def convertPerson(p: InPerson): OutPerson = {
  try {
    val name = convertName(p.name) //например меняем кодировку и возникает исключение, если встречается символ с кодом 0
    val homeAddr = convertAddr(p.addr)
    val age = convertAge(p.age)
    try {
      val (id, wAddr) = db.query("select id, work_addr from persons where tel = ?", p.tel)
      val workAddr = addrFromStr(wAddr)
      val distance = calcDistance(homeAddr, workAddr)
      if (distance == 0)
        throw new PersonException(s"похоже, что человек ${p.name} живет на работе", p, e)
      OutPerson(id, name, homeAddr, workAddr, distance)  
    } catch {
      case e: NotFoundException => throw new PersonException(s"в базе нет данных о человеке с номером телефона ${p.tel}", p, e)
    }    
  } catch {
    case e @ _: AddressException | _: AgeException => throw new PersonException(s"ошибка при обработке человека ${p.name}", p, e)
  }  
}

Вложенные блоки 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 можно переписать в следующем виде:
def convertPerson(p: InPerson): Res[OutPerson] = {
  val res = for {
    name <- convertName(p.name)
    
    homeAddr <- convertAddr(p.addr)
    
    age <- convertAge(p.age)
    
    (id, wAddr) <- Res.safe(db.query("select id, work_addr from persons where tel = ?", p.tel)) mapErr { e => e.ex.get match {
      case _: NotFoundException => e.replace(s"в базе нет данных о человеке с номером телефона ${p.tel}")
    }}
    
    workAddr <- addrFromStr(wAddr)
    
    distance <- calcDistance(homeAddr, workAddr)
    
    _ <- Res(distance != 0, s"похоже, что человек ${p.name} живет на работе")
  } yield OutPerson(id, name, homeAddr, workAddr, distance)   
  
  res.mapErr(e => e.push(s"ошибка при обработке человека ${p.name}"))  
}

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

Пример использования Exit может выглядеть так:
def convertPersons(pl: List[InPerson]): Res[List[OutPerson]] = exitRes[List[OutPerson]]{ex =>
  Ok(pl.foldLeft(Nil){case (z, p) => 
    (p match {
      case p: InManager => convertManager(p)
      case p: InProgrammer => convertProgrammer(p)
    }) match {
      case Ok(out) => out :: z
      case e: Err => ex.go(e)
    }
  })
}  

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» и код станет ещё более компактным.

Java и другие языки


В большинстве языков вполне можно реализовать некую монадическую сущность, наподобие описанного Res. Проблема возникает из-за того, что вся сила Res раскрывается в основном внутри оператора for для scala или do для haskell. Т.е. необходимо реализовать подобный оператор. В принципе, с помощью указанных в начале статьи инструментов, мне кажется это возможным. Описание самого for...yield можно найти здесь docs.scala-lang.org/tutorials/FAQ/yield.html

Плюсы / минусы


Плюсы данного подхода по сравнению с чистой обработкой исключений:
  • Лучший контроль над ошибками;
  • возможность редактировать и дополнять сообщения в вышестоящих функциях;
  • меньший расход ресурсов на исключениях: для указания позиции используется только одно исключение, а для подъема по стеку — легковесное исключение без стека вызовов;
  • иногда значительное уменьшение количества кода;

Плюсы по сравнению со способами, использующими монады по ссылкам (Try, \/, ...):
  • Данных подход работает там, где указанные в статьях методы требуют доработки и попросту неприменимы.

Минусы по сравнению с чистой обработкой исключений:
  • Значительная часть кода должна располагаться в for, что делает его вид несколько непривычным, хотя к этому можно привыкнуть.
  • Может вызывать затруднения при проектировании сверху-вниз. Т.е. когда для нижестоящих по стеку вызовов функций описывается только сигнатура без реализации, а основные усилия прикладываются к данной функции без необходимости отвлекаться на детали. Проблема может возникать из-за того, что некоторые функции не должны возвращать ошибки вообще и соответственно не должны возвращать Res. Как следствие, не всегда сразу ясна сигнатура функции.


Итого


Что хотелось бы сказать в заключении. Данный подход всё ещё обладает минусами, которые, возможно, не удастся исправить без поддержки со стороны языка. Когда-нибудь в далеком и прекрасном будущем хотелось бы увидеть язык с поддержкой легковесных конструкций для передачи значений вверх по стеку. С возможностью отслеживать текущую строчку кода и при этом не тратить ресурсы на получение стека вызовов до самого того момента, когда его нужно будет вывести в лог. А самое главное, хотелось бы иметь возможность описывать корректный результат и ошибку независимо, всегда явно и при обработке не тонуть в конструкциях типа try...catch, но также не модифицировать код под использование монад. Я верю в то, что это возможно, и даже имею некоторые мысли на счет того как, но как всегда на реализацию подобных вещей нет времени. Однако радует то, что любые идеи обычно приходят в головы разных людей независимо и параллельно, поэтому можно надеяться, что когда-нибудь обработка ошибок выйдет на новый уровень.
Tags:
Hubs:
Total votes 15: ↑14 and ↓1+13
Comments36

Articles