Это третья глава предполагаемой книги по Кложе на русском языке.

Содержание

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

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

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

Начинающие программисты пишут код, следуя только положительному пути. Отчасти поэтому так трудно расследовать ошибки. Сервер вернул статус 500. Почему? Возможны сотни причин, по которым запрос не удался. Но запись в логе слишком скупа, чтобы расследовать инцидент.

Опытный программист внимателен к ошибкам. Приходит понимание, что сэкономив на обработке исключений, разработчик на самом деле не выиграет. В перспективе он потратит время на расследование непонятных ошибок.

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

Основы исключений

Прежде чем углубляться в детали, вспомним, что такое исключения и как они себя ведут.

Исключение — это объект, чаще всего экземпляр условного класса Exception. От других классов его отличает особая операция выбрасывания. В разных языках этот оператор называется throw, raise, report.

Брошенный объект прерывает порядок исполнения и всплывает вверх по стеку вызовов. У такого всплытия два исхода. Либо объект пойман оператором catch на одном из уровней, либо перехват так и не состоялся.

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

Когда исключение не поймано, программа завершается с ошибкой. Как правило, перед выходом программа выводит в stderr исключение в виде текста. Чаще всего это класс исключения, сообщение к нему и то, что называют стектрейс. Это цепочка вызовов, сквозь которые всплыло исключение.

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

Clojure это гостевой язык. Он пользуется возможностями, которые предлагает домашняя платформа (далее хост). Исключения — одна из тех областей, в которую Clojure предпочитает не вмешиваться. По умолчанию Clojure использует формы try и catch, аналогичные Java.

Рассмотрим исключения на Java-уровне. Платформа содержит базовый класс Throwable. Это общий предок всех исключений. Другие классы наследуют Throwable, тем самым расширяя его семантику. Например, наследники первого порядка это классы Error и Exception. Последний наследует RuntimeException, и так далее. На схеме ниже базовое дерево исключений:

                ┌─────────────┐
                │   Object    │
                └─────────────┘
                       │
                       ▼
                ┌─────────────┐
             ┌──│  Throwable  │──┐
             │  └─────────────┘  │
             │                   │
             ▼                   ▼
      ┌─────────────┐     ┌─────────────┐
      │    Error    │     │  Exception  │──┐
      └─────────────┘     └─────────────┘  │
                                           │
                                           ▼
                                ┌─────────────────────┐
                                │  RuntimeException   │
                                └─────────────────────┘

Каждый из пакетов Java поставляет дополнительные исключения, унаследованные от описанных выше. Например, java.io.IOException для ошибок ввода-вывода, java.net.ConnectException для проблем в сети и другие.

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

Рассмотрим класс FileNotFoundException. Такое исключение возникает, когда мы обращаемся к несуществующему файлу. Его полная родословная выглядит так:

java.lang.Object
  java.lang.Throwable
    java.lang.Exception
      java.io.IOException
        java.io.FileNotFoundException

Такую схему читают как “выбрасываемое —> исключение —> ошибка ввода-вывода —> файл не найден”. По имени FileNotFoundException легко догадаться, с чем связана проблема. Если же разработчик выбросил Throwable, это осложнит поиск причины.

Различают checked и unchecked исключения, они же проверяемые и нет. Разница между ними в семантике. Считается, что разработчик должен предвидеть checked исключения и обработать их в коде. При чтении файла справедливо ожидать, что его не окажется на диске. Поэтому FileNotFoundException относится к категории checked, проверяемое.

Предвидеть нехватку памяти трудно, поэтому OutOfMemoryError относится к не проверяемым исключениям. Теоретически любая строка когда способна выбросить подобное исключение. Перехватывать его нет смысла, поскольку при нехватке памяти система ведет себя непредсказуемо.

Классы, унаследованные от Error и RuntimeException относятся к не проверяемым (unchecked) исключениям. Другие, унаследованные от Exception — проверяемые (checked).

Чтобы выбросить исключение, достаточно создать его экземпляр и передать оператору throw. Другой оператор catch перехватывает всплывающие исключения. В Java и других языках работает одинаковый критерий отлова — по иерархии классов. Например, если тип искомого исключения IOException, мы поймаем все исключения, унаследованные от этого класса.

Чем выше класс в дереве наследования, тем больше возможных исключений поймает catch. В мире Java считается плохим тоном отлавливать все возможные ошибки классами Throwable или Exception. Современные IDE называют это “too wide catch expression” и показывают предупреждение. Ситуацию решают заменой Exception на несколько специализированных исключений. Например, отдельно ошибки ввода-вывода и сети.

Одного только имени исключения не достаточно, чтобы понять причину его появления. Так, у FileNotFoundException нет поля типа java.io.File, чтобы отследить, какой именно файл не был найден. Большинство исключений принимают строку с сообщением об ошибке. Сообщение формируют так, чтобы его было удобно читать человеку. Например, “File С:/windows/system32/cmd.exe not found”.

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

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

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

Цепочки исключений и контекст

Исключения умеют строиться в цепочку. Каждый новый экземпляр исключения принимает необязательный параметр cause (причина). Он может быть либо Null, либо другим экземпляром.

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

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

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

Хорошо, если в системе предусмотрен последний рубеж защиты, где отлавливают все исключения. Если ошибка дошла до этого уровня, это значит, ее не сумели поймать обработчики рангом ниже. Это говорит о нештатном поведении системы. Пользователю выводят нейтральный текст о том, что его запрос не прошел. Чтобы расследовать причину, исключение пишут в лог и передают сборщику ошибок.

Современный код пишут так, что его верхний уровень это бизнес-логика. Это действия, которые диктует руководство фирмы. При спуске вниз начинается плавный переход в техническую часть. Каждый высокоуровневый шаг сводится к сетевым вызовам или чтению файлов.

Представим, что функция get-user извлекает пользователя по его номеру. Снаружи это черный ящик, и мы ничего не знаем о том, каким способом приходят данные. Бывает, что сперва это база данных, но затем пользователей выносят в отдельный сервис и шлют сетевой запрос. Выразим это на псевдокоде:

func get-user(id):
  let url = "http://api.company.com/user/" + str(id)
  return http.GET(url).body.json()

Если вызвать функцию с номером, которого не существует в системе, мы получим исключение HTTP Error: status 404. Сообщение ничего не говорит о пользователе. Если встретить такую запись в журнале, то мы даже не поймем, к какому сервису обращались.

Чем ниже мы углубляемся в технические детали, тем меньше знаем о бизнес-сущностях. В мире HTTP-клиента нет понятия пользователя. Ему доступны лишь метод, адрес и другие поля. Но чтобы расследовать инцидент, важно знать, что именно мы пытались сделать с точки зрения бизнеса.

Логично разделить ошибку на две части. Ее верхняя часть называет бизнес-причину: не удалось получить пользователя с номером 42. Почему? Из-за технического сбоя: запрос HTTP GET к адресу http://api.company.com/user/42 вернул ответ с кодом 404.

Расставим в коде дополнительные try/catch. Каждый раз, когда поймано исключение их технических недр, мы дополняем его контекстом и отправляем наверх. Этот паттерн называется re-throw, повторный выброс.

Улучшенная версия псевдокода:

func get-user(id):
  try {
    let url = "http://api.company.com/user/" + str(id)
    return http.GET(url).body.json()
  } catch error {
    throw Error("Cannot fetch a user #" + str(id), error)
  }

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

Логично предположить, что проблема с HTTP-запросом тоже имеет причину, например, проблему с установкой TLS-соединения. Это путь ниже в техническую часть. И наоборот, доступ к пользователю может быть одним из шагов, чтобы построить сложный отчет. В таком случае на верхнем уровне проблема с построением отчета, а ее причина – отказ при запросе пользователя.

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

По такому принципу устроены исключения в промышленных языках, например Java или Python. Каждая платформа добавляет небольшие изменения, но в целом картина остается прежней. Ниже мы рассмотрим, что предлагает Clojure.

Переходим к Clojure

Лучший способ познакомиться с исключением это спровоцировать его. Подойдет старый, как мир, прием — поделить число на ноль. Откройте новый сеанс REPL и выполните выражение:

(/ 1 0)

Ниже появятся строки примерно такого содержания:

Execution error (ArithmeticException) at user/eval5848...
Divide by zero

Это рапорт об ошибке. В зависимости от редактора и настроек его вид может отличаться. Например, редактор Emacs с модуле Cider откроет дополнительный буфер *cider-error* с детальной информацией.

Заметим, что в интерактивном сеансе исключение не останавливает его работу. Мы по-прежнему можем выполнить любое выражение. В боевом режиме Clojure-программы ведут себя как обычно. Если в главном треде не поймано исключение, программа останавливается.

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

(try
  (/ 1 0)
  (catch ArithmeticException e
    (println "Weird arithmetics")))

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

В примере выше мы просто вывели на экран текст о том, что вычисления прошли неудачно. Но могли бы извлечь сообщение. Метод .getMessage класса Throwable возвращает текст исключения. Начиная с версии 1.10 Clojure предлагает функцию ex-message, которая делает то же самое:

(try
  (/ 1 0)
  (catch ArithmeticException e
    (println (ex-message e))))

;; Divide by zero

Класс ArithmeticException ловит не все ошибки, связанные с вычислениями. Что случится, если сложить 1 и nil? Даже если поместить расчеты в try/catch, это не спасет от ошибки:

(try
  (+ 1 nil)
  (catch ArithmeticException e
    (println "Weird arithmetics")))

;; Execution error (NullPointerException) at user/eval6159

Дело в том, что классы ArithmeticException и NullPointerException не пересекаются с друг с другом. У них разные пути в дереве исключений, которое мы обсуждали в начале главы. Поэтому перехват одного не влияет на другой. Это правильно, потому что семантика классов разная. Ошибка в арифметике это не то же самое, что Null вместо значения.

Форма try принимает несколько форм catch. Вот как перехватить оба случая:

(try
  (+ 1 nil)
  (catch ArithmeticException e
    (println "Weird arithmetics"))
  (catch NullPointerException e
    (println "You've got a null value")))

Макрос перебирает классы из каждого catch. Он остановится на первом подходящем варианте. Результатом try станет последнее выражения из блока catch, который подошел. В примере выше результат будет nil, потому что это значение вернет функция println. Если ни один вариант не поймал исключение, оно продолжит путь вверх по стеку вызовов.

Ранее мы упоминали, что чем выше класс исключения в дереве, тем больше ситуаций он поймает. Если заменить ArithmeticException на Throwable, такой catch перехватит любое исключение, будь то деление на ноль или Null:

(try
  (/ 1 0)
  (+ 1 nil)
  (catch Throwable e
    (println "I catch everything")))

В отличие от Java, в мире Clojure нет строгих правил о том, какие исключения перехватывать. Этот вопрос остается на усмотрение разработчика. Здравый смысл подсказывает, что перехват Throwable опасен. Создается ложное ощущение, что код работает без ошибок.

В случае в ArithmeticException проблема очевидно в расчетах, но для NullPointerException это не так. Nil вместо числа говорит о том, что проблема не в арифметике, а в функции, которая вернула nil. Иными словами, ошибка кроется в другом месте. Вот почему перехват NullPointerException вредит разработке.

Форму try/catch с широким классом ставят на верхних уровнях кода. Например, когда важно, чтобы программа не прекращала работу никогда. Такой подход используют в HTTP-серверах, при обработке сообщений из очередей, в сетевом ПО.

Иногда мы намеренно бросаем исключения, чтобы сообщить о нештатной ситуации. Форма throw принимает объект исключения и запускает стандартный механизм выброса. Оператор new создает новый Java-объект. Ему передают класс и параметры конструктора:

(let [e (new Exception "something is wrong")]
  (throw e))

Сообщение такого рода не информативно. Даже если мы указали, что проблема в арифметике или базе данных, хотелось бы знать, какие именно значения были на момент ошибки. Поэтому сообщение строят функцией format. Она принимает шаблон и параметры подстановки:

(defn add [a b]
  (when-not (and a b)
    (let [message (format "Value error, a: %s, b: %s" a b)]
      (throw (new Exception message))))
  (+ a b))

Если вызвать функцию выше с одним и параметров nil, текст сообщения будет информативнее:

Execution error at book.exceptions/add (exceptions.clj:86).
Value error, a: 1, b: null

Функция format хороша тем, что отображает nil как null. Это значимое преимущество перед str, которая трактует nil как пустую строку. Вариант с str выглядит так:

(str "Value error, a:" 1 ", b: " nil)
;; Value error, a:1, b:

Результат вводит в заблуждение. Это мы потеряли значение b? Или b пустая строка? Непонятно.

Контекст ошибки

Выше мы упоминали, что запись данных в текст — рискованное занятие. Сообщение может оказаться слишком большим или выдать лишние сведения. Проблему решает класс ExceptionInfo. Это особое исключение из поставки Clojure. Оно спроектировано так, чтобы хранить произвольные данные. Вместе с ним Clojure предлагает набор функций, чтобы упростить работу.

Ключевая функция ex-info создает экземпляр ExceptionInfo. Она принимает сообщение и словарь данных. Это контекст, в котором возникло исключение. Например, если не удалось выполнить HTTP-запрос, полезно указать в словаре HTTP-метод и адрес запроса.

Ex-info только создает исключение, но не бросает его. Поэтому вызов ex-info сразу передают в throw:

(throw (ex-info
        "Cannot get the data from remote server."
        {:user 42
         :http-method "POST"
         :http-url "http://some.host/api"}))

В примере выше мы разделили сообщение и данные. Сообщение не выдает номер пользователя и адрес подсистемы. Текст запишут в лог или отправят пользователю, а контекст обработают отдельно.

При работе с контекстом помните несколько правил. Словарь не может быть nil. Конструктор ExceptionInfo проверяет значение на Null и бросает ошибку входных параметров. Нежелательно хранить в словаре значения, которые не сериализируются. Например, поток или сетевое подключение. В идеале контекст можно записать в JSON-документ. Позже мы рассмотрим, что делать с данными контекста.

Функция ex-data возвращает данные из пойманного исключения. Если это было ExceptionInfo, мы получим словарь. Если исключение другого типа, то nil.

Вообразим, что в модуле объявлена функция authorize-user. При неудачных обстоятельствах она выкидывает исключение как в последнем примере. Код из формы catch получает его контекст и разбивает на отдельные ключи. Затем формирует строку.

(try
  (authorize-user 42)
  (catch Exception e
    (let [data (ex-data e)
          {:keys [http-method http-url]} data]
      (format "HTTP error: %s %s" http-method http-url))))

;; HTTP error: POST http://some.host/api

Когда бросать исключения

На этом этапе читатель наверняка сомневается, в каких случаях кидать исключения, а когда ограничиться проверкой. Действительно, эту тему обходят стороной большинство материалов по Clojure. Рассмотрим типовые ситуации и оптимальные решения.

При обработке коллекций вам не нужно бросать исключения. Как правило, в этом не возникает потребности. Коллекции Clojure спроектированы так, что проблемы nil пройдут мимо вас незамеченными. Например, значение nil трактуется как пустая коллекция, поэтому выражения:

(assoc nil :test 42)
(update nil :test (fnil inc 0))
(into nil [1 2 3])
(merge nil {:test 42})

вернут коллекции. Любой ключ от nil вернет nil, ровно как и разбор словаря или вектора на отдельные переменные сообщает им nil, если подстановка не сработала:

(let [{:keys [a b c]} nil]
  [a b c])

(let [[a b c] nil]
  [a b c])

Оба случая вернут вектор из трех nil, исключения не произойдет.

Термин “nil punning” означает лояльность к nil. С таким подходом ситуацию с пустым значением разрешают без ошибок. Нельзя утверждать, что Clojure целиком nil punning. Пустое значение не влияет на коллекции, но не работает с арифметикой, регулярными выражениями и в других случаях.

Если вы сомневаетесь в коллекции перед ее обработкой, используйте спеку. Вспомните s/valid? и s/conform из прошлой главы. Тем самым вы отделите стадию проверки от обработки. Если валидация не прошла, бросайте исключение. Передайте в контекст данные s/explain-data, чтобы разобрать их позже.

(require '[clojure.spec.alpha :as s])

(s/def ::data (s/coll-of int?))

(when-let [explain (s/explain-data ::data [1 2 3])]
  (throw (ex-info "Validation failed" {:explain explain})))

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

Бывает, функция доступа к ресурсу не бросает исключение. Это справедливо для различных HTTP API. Согласно протоколу HTTP, ответ со статусом 404 такой же верный, как и 200. В индустрии нет единого мнения о том, стоит ли бросать исключение на негативные HTTP-ответы. Как правило, библиотеки предлагают флаг, который регулирует это поведение.

Посылая HTTP-запрос, мы передаем флаг с семантикой “не бросать исключение”. Тем самым мы гарантируем, что исключение возникнет только в аварийных случаях вроде проблем соединения. Но сетевое взаимодействие пройдет без ошибок. Затем анализируем ответ. Если код не был положительным, бросаем исключение. В его контекст передаем основные части запроса и ответа: метод, адрес, статус.

В примере ниже функция, которая извлекает данные о пользователе из сервиса авторизации. Это слегка измененный код из реального проекта. Обмен с сервисом происходит по протоколу HTTP. В качестве клиента используем библиотеку clj-http.

(defn auth-user
  [user-id]
  (let [url "http://auth.company.com"
        params {:form-params {:user-id user-id}
                :throw-exceptions? false
                :coerce :always
                :as :json
                :content-type :json}
        response (client/post url params)
        {:keys [status body]} response]

    (if (= status 200)
      body
      (throw (ex-info "Authentication error"
                      {:user-id user-id
                       :url url
                       :http-status status
                       :http-body body})))))

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

Если бросить исключение на уровне исполнения, бизнес-логика вправе перехватить его и продолжить работу. Но если исполнитель молча подавляет ошибки, это чревато неожиданным поведением. Сюда же относится паттерн “вернуть nil и записать в лог”. Это банальное игнорирование проблемы.

Подробнее о цепочках

Выше мы говорили про цепочки исключений. Рассмотрим, как формировать их на практике. Функция ex-info принимает третий необязательный параметр cause. Это либо nil, либо другое исключение. Оно становится частью нового экземпляра. В примере ниже функция divide перехватывает ошибку деления. Затем оборачивает ее в другое исключение с контекстом.

(defn divide [a b]
  (try
    (/ a b)
    (catch ArithmeticException e
      (throw (ex-info
              "Calculation error"
              {:a a :b b}
              e)))))

Функция ex-cause возвращает исключение-причину, если оно было передано в ex-info. Если причины нет, результат будет nil.

(try
  (divide 1 0)
  (catch Exception e
    (-> e ex-message println)
    (-> e ex-cause ex-message println)))

Этот код выведет строки:

Calculation error
Divide by zero

Пример выше неаккуратен. Мы не можем быть уверены, что у исключения строго одна причина. Их может быть много или ни одной. Напишем функцию, которая вернет последовательность исключений. Расположим их по убыванию старшинства. На первом месте самое главное, на втором его причина, затем причина причины и так далее. Проще всего сделать это формой loop:

(defn ex-chain [e]
  (loop [e e
         result []]
    (if (nil? e)
      result
      (recur (ex-cause e) (conj result e)))))

Для экспериментов объявим переменную e. Она понадобится здесь и ниже по тексту главы, чтобы проверять код. Это будет цепочка из трех звеньев. На ее первом уровне ошибка бизнес-логики: не удалось извлечь данные о пользователе. На втором уровне техническая проблема, что-то не так с авторизацией. На третьем ошибка транспорта: не прошел HTTP-запрос.

(def e
  (ex-info
   "Get user info error"
   {:user-id 42}
   (ex-info "Auth error"
            {:api-key "........."}
            (ex-info "HTTP error"
                     {:method "POST"
                      :url "http://api.site.com"}))))

Это дерево искючений, но не всегда удобно работать с деревом. Плоская структура лучше поддается обходу. Здесь и пригодится ex-chain. Вот как получить сообщения всех звеньев в том же порядке:

(map ex-message (ex-chain e))

("Get user info error" "Auth error" "HTTP error")

А вот как вывести их на экран:

(doseq [e (ex-chain e)]
  (-> e ex-message println))

;; Get user info error
;; Auth error
;; HTTP error

Выразим ex-chain короче и изящней через iterate. Это функция, которая шаг за шагом применяет другую функцию к аргументу. Ограничение take-while some? нужно для того, чтобы остановиться на первом же nil-элементе.

(defn ex-chain
  [^Exception e]
  (take-while some? (iterate ex-cause e)))

Печать исключения

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

Самое простое, что можно сделать с исключением — вывести в консоль. Функция println достаточно умна. Она выводит не только верхний уровень, но и всю цепочку исключений. Для каждого уровня функция печатает путь к классу, словарь данных и текст.

Самое нижнее исключение в цепочке иногда наывают корнем, root. Для нашего удобства println дублирует корень в начале вывода. Так мы сразу увидим первопричину ошибки. Вот что напечатает выражение (println e):

#error {
 :cause HTTP error
 :data {:method POST, :url http://api.site.com}
 :via
 [{:type clojure.lang.ExceptionInfo
   :message Get user info error
   :data {:user-id 42}
   :at [clojure.lang.AFn applyToHelper AFn.java 160]}
  {:type clojure.lang.ExceptionInfo
   :message Auth error
   :data {:api-key .........}
   :at [clojure.lang.AFn applyToHelper AFn.java 160]}
  {:type clojure.lang.ExceptionInfo
   :message HTTP error
   :data {:method POST, :url http://api.site.com}
   :at [clojure.lang.AFn applyToHelper AFn.java 156]}]
 :trace
 [[clojure.lang.AFn applyToHelper AFn.java 156]
  [clojure.lang.AFn applyTo AFn.java 144]
  [clojure.lang.Compiler$InvokeExpr eval Compiler.java 3701]
  [clojure.lang.Compiler$DefExpr eval Compiler.java 457]
  [clojure.lang.Compiler eval Compiler.java 7181]
  ..........]}

Многоточия на конце скрывают часть стек-трейса. Это Java-методы, через которые прошло исключение с момента выброса до перехвата. Каждый элемент трейса это вектор с именем класса, метода, файла и номера строки.

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

Цепочки исключений с контекстом несут больше пользы, чем стек-трейс. Фактически, цепочка — это альтернативный трейс, построенный на данных. Различие в том, что он компактней и в целом больше ориентирован на человека, чем машинный список методов.

Пакет clojure.stacktrace предлагает несколько функций для печати исключений. Так, print-throwable печатает класса и данные исключения без трейса и цепочки причин.

(clojure.stacktrace/print-throwable e)

Результат:

clojure.lang.ExceptionInfo: Get user info error
{:user-id 42}

Вывод на печать легко захватить в строку макросом with-out-str. Форма ниже ничего не напечатает, но вернет строку:

(with-out-str
  (clojure.stacktrace/print-stack-trace e))

Отдельные функции print-stack-trace и print-cause-trace печатают трейсы исключений. Вывод каждой из них можно ограничить параметром n, максимальной глубиной трейса.

Логирование

Печать исключений считается слабой практикой. Это частный случай, когда мы хотим зафиксировать ошибку для дальнейшего разбора. Печать в стандартный поток не подходит для этого по многим причинам.

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

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

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

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

Названные выше проблемы разрешает логирование. Это центральная система, которая фиксирует сообщения от разных частей программы.

Библиотека clojure.tools.logging предлагает набор функций и макросов, чтобы записывать сообщения. Это сторонняя библиотека, поэтому перед использованием добавьте ее в зависимости:

[org.clojure/tools.logging "0.4.1"]

Импортируем библиотеку и запишем простое сообщение:

(require '[clojure.tools.logging :as log])

(log/info "A message from my module")

В REPL-сессии появится запись лога:

INFO: A message from my module

Видим, что лог дополняет сообщение уровнем критичности.

В Clojure логирование устроено из двух уровней. Верхний уровень — это макросы log/info, log/error и другие. Второй уровень называют бекендом. Под ним понимают Java-библиотеку, которая непосредственно пишет сообщения в файлы, отправляет их по сети и выполняет всю работу.

Такое устройство на первый взгляд кажется необычным, но на практике несет преимущества. Исторически сложилось так, что для Java написано много библиотек логирования. Каждая из них предлагает свои классы и методы. Если проект явно использует одну из библиотек, то переход на другую вынуждает править код.

В Clojure эту проблему решили на уровне дизайна библиотеки. Модуль logging в момент старта ищет библиотеки Logback, Log4j и другие. Если ничего не найдено, он использует стандартный java.util.logging.

Из этого правила следует, что достаточно добавить бекенд логирования в зависимости, чтобы он начал работать. Большинство библиотек автоматически ищут свои настройки в текущем пути. Например, если в папку resources подложить специальный XML-файл для бекенда, он прочтет его в момент старта и применит настройки.

Из бекендов большой популярностью пользуется проект Logback. От аналогов его отличает большой выбор т.н. аппендеров (appenders). Так называют пункт назначения, куда писать сообщения. Это может быть файл с автоматической ротацией, удаленный syslog, почтовый сервер и много что еще. Чтобы добавить Logback в проект, добавьте в зависимости запись:

[ch.qos.logback/logback-classic "1.2.3"]

В папку resources положите файл logback.xml следующего содержания:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <charset>UTF-8</charset>
      <pattern>%date{ISO8601} %-5level %logger{36} - %msg %n</pattern>
    </encoder>
  </appender>
  <root level="INFO">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>

Это укороченный пример конфигурации. В данном случае мы задали appender для вывода в консоль. Под тегом pattern указан шаблон сообщения. Параметры шаблона начинаются со знака процента. Подстановка %msg означает текст сообщения, %date — дату и ее формат в фигурных скобках.

Теперь записанное сообщение выглядит так:

(log/info "Hello Logback!")
2019-05-03 17:36:04,001 INFO  book.exceptions - Hello Logback!

Макросы log/info, log/error и другие допускают, что первым аргументом может быть не текст, а пойманное исключение. В этом случае бекенд запишет исключение в лог. Выше мы объявили переменную e для экспериментов. Воспользуемся ей:

(log/error e "Error while processing the request")

Сокращенный результат:

2019-05-03 17:41:03,913 ERROR book.exceptions - Error while processing the request
clojure.lang.ExceptionInfo: Get user info error
	at java.lang.Thread.run(Thread.java:745)
Caused by: clojure.lang.ExceptionInfo: Auth error
	at clojure.lang.Compiler$InvokeExpr.eval(Compiler.java:3701)
	... 30 common frames omitted
Caused by: clojure.lang.ExceptionInfo: HTTP error
	at clojure.lang.Compiler$InvokeExpr.eval(Compiler.java:3701)
	... 31 common frames omitted

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

Logback предлагает несколько модификаций для вывода исключений. Они влияют на длину и детализацию трейса, но игнорируют данные. Аналогично ведут себя Log4j и другие библиотеки.

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

Это довольно сложный путь. Чтобы унаследовать класс в Clojure, нужны навыки работы с Java выше среднего. Придется читать документацию к конкретной библиотеке, исследовать ее классы и интерфейсы.

Такое решение завязано на конкретную библиотеку. Класс для Logback не будет работать с Log4j и наоборот. В мире Clojure это называют “не Clojure-way”, то есть не тот способ, которому принято следовать.

Напишем свою функцию для логирования ошибок. Пусть она принимает исключение и переводит его в текст, как удобно нам. Затем пишет текст в лог с уровнем error.

Чтобы вывести все данные об ошибке, потребуется обойти цепочку исключений. Выше мы определили ex-chain для этой цели. Функция ex-print пробегается по цепочке и печатает данные в консоль. Если нужна строка, вызов ex-print оборачивают в with-out-str:

(defn ex-print
  [^Throwable e]
  (let [indent "  "]
    (doseq [e (ex-chain e)]
      (println (-> e class .getCanonicalName))
      (print indent)
      (println (ex-message e))
      (when-let [data (ex-data e)]
        (print indent)
        (clojure.pprint/pprint data)))))

Результат выглядит приятнее, чем пирамида из трейсов. Четко видны данные, которые привели к ошибке:

clojure.lang.ExceptionInfo
  Get user info error
  {:user-id 42}
clojure.lang.ExceptionInfo
  Auth error
  {:api-key "........."}
clojure.lang.ExceptionInfo
  HTTP error
  {:method "POST", :url "http://api.site.com"}

Осталось собрать функцию для записи в лог. Вот она:

(defn log-error
  [^Throwable e & [^String message]]
  (log/error
   (with-out-str
     (println (or message "Error"))
     (ex-print e))))

Функцию можно вызвать с дополнительным сообщением или без. Если сообщения не было, запишем нейтральное “Error”. Чтобы сообщение и тело исключения не слипались, используем println. Эта функция добавляет перенос строки в конец.

(log-error e)
(log-error e "An error occurred during request")

Последнее выражение запишет в лог следующее:

2019-05-03 19:00:05,590 ERROR book.exceptions - An error occurred during request
clojure.lang.ExceptionInfo
  Get user info error
  ...

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

Исключения как данные. Сбор исключений

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

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

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

Системы сбора ошибок научились определять схожесть исключений по особым правилам. С точки зрения текста сообщения “user 1 not found” и “user 2 not found” отличаются. Но система сбора схлопнет их в одну ошибку и по запросу покажет все варианты.

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

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

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

Среди прочих систем достойно выглядит Sentry. Это приложение на Python и Django. Sentry работает как веб-сервер. На главной страницы видны проекты и краткая статистика по ним. Каждый проект накапливает ошибки, которые он получил от клиентов.

Чтобы отправить ошибку в Sentry, приложение обращается к нему HTTP API методом POST. В теле передают JSON-документ с различными полями. Протокол Sentry предлагает несколько десятков полей, чтобы описать ошибку. Сюда входят параметры физической машины, операционной системы, контекст HTTP-запроса, стек-трейс и другие.

Как правило, приложение не формирует эти данные вручную, а пользуется готовыми библиотеками. Часто они называются Raven или похожим образом. Имеется в виду, сторожевой ворон (анг. raven) несет вести дозорному (анг. sentry).

Для Clojure написаны две библиотеки к Sentry: getsentry/sentry-clj и exoscale/raven. Первая опирается на официальную Java-библиотеку. Это частая практика в мире Clojure — не писать код с нуля, а предоставить обвязку вокруг проверенной Java-библиотеки.

Чтобы воспользоваться sentry-clj, добавьте зависимость в проект:

[io.sentry/sentry-clj "0.7.2"]

На первом шаге инициализируйте библиотеку с нужным DSN. Под DSN понимают уникальный адрес проекта в Sentry. Проект объединяет сообщения по какому-то глобальному признаку. Например, ошибки бекенда в одном проекте, фронтенда – во втором, мобильного приложения – в третьем. Получить DSN проекта можно на странице его дополнительной информации в разделе “интеграция”.

(require '[sentry-clj.core :as sentry])

(def DSN "https://.....@sentry.io/.....")

(sentry/init! DSN)

Теперь когда проект задан, отправим сообщение. Функция send-event принимает словарь параметров сообщения. Нас интересует сценарий, когда мы поймали исключение и хотели бы отправить его без лишних усилий. Достаточно передать ключ :throwable с экземпляром исключения:

(sentry/send-event {:throwable e})

Sentry вернет номер сообщения, а в проекте появится новая запись Предположим, мы передали эксземпляр e, который объявили выше. Это цепочка из трех исключений. В веб-интерфейсе увидим информацию о каждом из них. В поле :extra будут данные верхнего звена исключения, то есть словарь {:user-id 42}.

Недостаток sentry-clj в том, что он полагается на Java-библиотеку. Вспомним, что в мире Java не популярны принципы, которые мы обсуждали в этой главе. Например, хотелось бы иметь данные со всех уровней, а не только верхнего. Иначе мы не узнаем, какой был API-ключ, по какому адресу мы обращались в сеть и другие детали. Технически возможно собрать эти данные вручную и передать в ключ :extra. Но будет привильней научить этому библиотеку.

Это и другие замечания исправлены в exoscale/raven. Библиотека написана на Clojure и потому более гибка. С недавний версий она передает максимально полные данные об исключении. Подключите ее в проект:

[exoscale/raven "0.4.8"]

и отправье исключение e:

(require '[raven.client :as raven])

(raven/capture! DSN e)

Теперь откройте сообщение в Sentry и промотайте вниз. В секции extra будет ключ :via с очень детальной информацией:

[ {
  "type" : "clojure.lang.ExceptionInfo",
  "message" : "Get user info error",
  "data" : {
    "user-id" : 42
  },
  "at" : [ "clojure.lang.AFn", "applyToHelper", "AFn.java", 160 ]
}, {
  "type" : "clojure.lang.ExceptionInfo",
  "message" : "Auth error",
  "data" : {
    "api-key" : "........."
  },
  "at" : [ "clojure.lang.AFn", "applyToHelper", "AFn.java", 160 ]
}, {
  "type" : "clojure.lang.ExceptionInfo",
  "message" : "HTTP error",
  "data" : {
    "method" : "POST",
    "url" : "http://api.site.com"
  },
  "at" : [ "clojure.lang.AFn", "applyToHelper", "AFn.java", 156 ]
} ]

Это вектор словарей, где каждый словарь состоит из класса исключения, его сообщения и данных. В поле :at вектор четырех элементов. Это класс, метод, имя файла и строка, на которой возникло исключение. С таким отчетом расследовать инцидент становится проще.

Структуру выше легко получить в любом месте Clojure. Нам доступна функция Throwable->map. Она принимает исключение и возвращает словарь с ключами :via, :cause и другими. В этом словаре вся информация об исключении, в т.ч. цепочка исключений и трейс.

Зачем нужна функция, если ту же информацию можно извлечь самостоятельно? Преимущество в том, что результат Throwable->map состоит из структур и типов Clojure. Это комбинация словарей, векторов и символов. Такой словарь обрабатывают как обычную Clojure-коллекцию. Его легко записать в форматы EDN или JSON.

Ключ :via представляет особую ценность. Это те данные, что мы привели выше: цепочка исключений с данными. Вот как извлечь их:

(-> e Throwable->map :via)

Теперь когда вы знакомы с логированием и Sentry, напишем защитный декоратор для Ring-приложения. Напомним, мы разобрали декораторы в главе про веб-разработку. Такой декоратор ставят на вершину стека middleware. Он перехватывает все потенциальные ошибки, отправляет в Sentry и возвращает нейтральный ответ о том, что запрос не удался.

(defn wrap-exception
  [handler]
  (fn [request]
    (try
      (handler request)
      (catch Exception e
        (try
          (raven/capture! DSN e)
          (catch Exception e-sentry
            (log/errorf e-sentry "Sentry error: %s" DSN)
            (log/error e "HTTP Error"))
          (finally
            {:status 500
             :body "Internal error, please try later"}))))))

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

Переходы

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

Действительно, если бросить какое-то специфичное исключение, например MyGOTOException, а вызов сверху обернуть в catch с этим классом, то перед нами классический оператор GOTO:

(try
  (do-first-step)
  (do-second-step)
  (when (condition)
    (throw (new MyGOTOException)))
  (do-third-step)
  (catch MyGOTOException e
    (println "Skipped the third one")))

Если что-то пошло не так, мы прыгаем через третий шаг. Трюк называют “исключение как способ управления исполнением”.

Это очень противоречивая практика, и прибегать к ней следует с осторожностью. Исключения не были задуманы для подобных рывков. Тот, кто злоупотребляет прыжками по коду, использует инструмент не по назначению. Код становится неочевидным и трудным в поддержке.

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

Усложним задачу тем, что требуется выполнить ряд проверок, и на каждом отказе прерывать исполнение. Для императивных языков это легкая задача. Код на Python выглядел бы так:

class AccountHandler(RequestHandler):

  def on_get(self, request):

    if not self.check_this(request):
      return BadRequest("Wrong input data")

    if not self.check_that(request):
      return NotFound("No such an account")

    if not self.check_quotas(request):
      return QuotasReached("Request rate is limited")

    return JSONResponse(self.get_data_from_db())

В Clojure нет оператора return. Результат нескольких форм это результат последней. Мы не можем расположить несколько форм if на одном уровне друг под другом. Это не имеет смысла, поскольку даже если какая-то из них вернет ложь, исполнение перейдет к следующей форме.

Вариант с каскадным деревом работает, но выглядит слишком громоздко. Получилось то, что называют The Pile of Doom, пирамида судьбы. Чем больше в ней уровней, тем больше проблем у разработчика. Ради интереса добавьте новое условие в середину:

(defn account-handler
  [request]
  (if (check-this request)
    (if (check-that request)
      (if (check-quotas request)
        {:status 200
         :body (get-data-from-db)}
        (quotas-reached "Request rate is limited"))
      (not-found "No such an account"))
    (bad-request "Wrong input data")))

Подобный случай решают разными способами, в том числе исключениями. Библиотека metosin/ring-http-response предлагает функции, которые выбрасывают исключения с данными HTTP-ответа. В стек middleware добавляют особый декоратор, который перехватывает такие исключения и возвращает ответ клиенту.

Добавьте библиотеку в проект:

[metosin/ring-http-response "0.9.1"]

Теперь обработчик выглядит так:

(require '[ring.util.http-response
           :refer [not-found!
                   bad-request!
                   enhance-your-calm!]])

(defn account-handler
  [request]

  (when-not (check-this request)
    (bad-request! "Wrong input data"))

  (when-not (check-that request)
    (not-found! "No such an account"))

  (when-not (check-quotas request)
    (enhance-your-calm! "Request rate is limited"))

  {:status 200
   :body (get-data-from-db)})

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

Функции из модуля ring.util.http-response бывают с восклицательным знаком на конце и без него. Это сигнал о том, что функция несет побочный эффект, в данном случае бросает исключение. Так, not-found! бросает знакомый нам ex-info, в теле которого HTTP-ответ со статусом 404.

Чтобы схема работала, добавим в стек middleware декоратор wrap-http-response. Он ловит ошибки, выброшенные функциями с восклицательным знаком, достает из них ответ и возвращает пользователю.

(require '[ring.middleware.http-response
           :refer [wrap-http-response]])

(def app
  (-> app-naked
      wrap-params
      wrap-session
      wrap-cookies
      wrap-http-response))

Не злоупотребляйте исключениями для переходов. Убедитесь, что это крайняя мера. Хорошо, если вы используете библиотеку, а не сиюминутное решение. Преимущество библиотеки в том, что она несет документацию и опыт других людей.

Finally и контекстный менеджер

Иногда мы выполняем код в рамках какого-то ресурса. Это может быть файл, сетевой порт или база данных. В начале работы эти ресурсы “открывают”, а в конце “закрывают”. В других терминах говорят, что кто-то занял ресурс, а затем освободил его.

Когда ресурс занят, другие клиенты не могут пользоваться им в полной мере. Например, открытый кем-то файл можно только читать, но не изменять. Считается правильным освобождать ресурс сразу же, как только в нем отпала потребность. Технически это сводится к вызову метода .close у объекта.

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

Эту форму ставят последней внутри try. Исключения в JVM устроены так, что управление переходит в finally даже в случае исключения. Если ошибки не было, finally срабатывает после основного кода из try. Если ошибка возникла, finally будет выполнен в промежутке между ее появлением и выбрасыванием.

Вот как выглядит низкоуровневый доступ к файлу. В примере ниже мы записываем в него несколько байт. Форма finally гаранитрует, что файл будет закрыт, даже если если что-то пойдет не так.

(import '[java.io File FileWriter])

(let [out (new FileWriter (new File "test.txt"))]
  (try
    (.write out "Hello")
    (.write out " ")
    (.write out "Clojure")
    (finally
      (.close out))))

Чтобы программист не забыл закрыть файл, современные языки предлагают контекстные менеджеры. В зависимости от платформы это может быть класс, оператор или макрос. Менеджер исполняет произвольный блок кода с дополнительными эффектами. Он гарантирует, что перед блоком сработает логика входа, а после него – логика выхода. При этом выход сработает даже в том случае, если блок выбросил исключение. Платформа удержит его, выполнит логику выхода и снова бросит.

В языке Python контекстный менеджер выполнен изящно. Это оператор with, который ожидает объект. У объекта должны быть методы __enter__ и __exit__, которые вызываются в момент входа и выхода. Чаще всего with используют при чтении файла. В методе __exit__ срабатывает его закрытие:

with open("/path/to/file.txt", "w") as f:
    f.write("test")

Не составит труда написать такой же менеджер в Clojure. Это будет макрос, который принимает символ объекта для записи и путь к файлу. Объект назначается первому символу. Форма finally гарантирует, что файл будет закрыт при выходе из тела макроса.

(defmacro with-file-writer
  [[bind path] & body]
  `(let [~bind (new FileWriter (new File ~path))]
     (try
       ~@body
       (finally
         (.close ~bind)))))

Пример:

(with-file-writer [out "test.txt"]
  (.write out "Hello macro!"))

Принято называть контекстные менеджеры с префикса with-. Так вы делаете акцент на том, что код работает в рамках временного ресурса.

В библиотеке Clojure уже предусмотрен макрос with-open. Это улучшенная версия кода, что мы написали выше. В отличии от нашего примера, with-open работает с более абстрактной сущностью Reader. Это источник данных, который может быть в том числе и файлом. Выход из макроса, даже с ошибкой, гарантирует закрытие источника.

Как правило, в форме finally нет смысла при обработке коллекций или действий без побочных эффектов. Пользуйтесь ей только если источник данных должен быть занят и освобожден.

Исключения на предикатах

Система try/catch работает на классах и наследовании. Это значит, если требуется отловить особый случай, мы создаем специальный класс и бросаем его экземпляр.

Типичный проект на Java или Python несет на борту отдельный модуль с собственными исключениями. Как правило, это базовое CommonProjectException и унаследованные от него UserNotFound, UserAccessDenied и другие. Это длинный однообразный код, написанный вручную.

В главе про clojure.spec мы упоминали, что предикаты мощнее типов. Это верно и для исключений. Если бы можно было перехватить исключение функцией, нам бы не потребовались классы. В Clojure это возможно при помощи библиотеки Slingshot (анг. рогатка).

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

Автор Slingshot предположил, что ошибкой может быть не только экземпляр исключения, а любое значение в принципе. Например, строка, число, словарь. Выше мы выяснили, что контекст – самый важный элемент исключения. Поэтому логично сосредоточить внимание на словарях, а не Java классах.

В техническом плане библиотека реализует макросы throw+ и try+. Оба они совместимы с обычными throw и try. Если заменить стандартные формы на версии с плюсом, ничего не изменится. Однако новые формы дают следующие преимущества.

Макрос throw+ принимает не только исключение, но и любой Java-объект. Словарь лучше всего подходит на роль этого объекта, потому что передает несколько значений и их семантику по имени ключей. Пример ниже выбросит ex-info с переданным словарем:

(require '[slingshot.slingshot
           :refer [try+ throw+]])

(throw+ {:user-id 42
         :action :create})

Более детальная форма этого макроса: словарь, причина (cause), сообщение и переметры форматирования:

(let [path "/var/lib/file.txt"]
  (try
    (slurp path)
    (catch Exception e
      (throw+ {:path path} e "Cannot open a file %s" path))))

Форма catch внутри try+, помимо классов, ловит исключения по селектору и предикату. Под селектором понимают вектор, где нечетный элемент ключ словаря, а четный – значение. Селектор проверяет, что такие ключи и значения есть в контексте исключения. Если да, исключение считается пойманным, и управление переходит в форму catch с этим селектором.

(try+
 (throw+ {:type ::user-error
          :user 42
          :action :update
          :data {:name "Ivan"}})
 (catch [:type ::user-error] e
   (clojure.pprint/pprint e)))

Считается хорошей практикой добавлять в словарь поле :type. В него записывают тип исключения с текущим простанстом имен. В нашем случе это :book.exceptions/user-error. При отлове исключений прибегают к этому же полю. Пространство гарантирует, что мы не отреагируем на :user-error из другого модуля.

Пример выше напечатает в консоль переданный словарь. Макрос catch достаточно умен и подставляет в переменную e не исключение, а данные, что мы выбросили.

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

Пример ниже предполагает, что операции над пользователем с номером 1 заслуживают особого внимания. В этом есть смысл, потому что первый пользователь, как правило, это супер-админ, основатель фирмы или системный пользователь.

(defn special-user-case?
  [data]
  (when (map? data)
    (let [{:keys [type user]} data]
      (and (= type ::user-error)
           (= user 1)))))

С таким предикатом любой пользователь сможет удалить себя из системы, но не админ:

(try+
 (throw+ {:type ::user-error
          :user 1
          :action :delete})
 (catch special-user-case? e
   (println "Attempt to delete a system account")))

Рассмотрим пример из реального проекта. Это предикат для проверки исключения при работе с Java AWS SDK. При определенных обстоятельствах возможен случай, когда исключение возникло после того, как файл загружен в S3. Такая загрузка считается неудачной, и мы должны удалить файл, чтобы он не занимал место. К сожалению, для этого сценария не существует отдельного класса. Приходится ловить стандартный AmazonS3Exception и проверять его регулярным выражением.

(defn aws-special-case?
  [e]
  (and
   (instance? AmazonS3Exception e)
   (some?
    (re-find
     #"(?i)The Content-Md5 you specified did not match"
     (ex-message e)))))

Библиотеку Slingshot используют другие проекты, например clj-http. Это популярный HTTP-клиент для Clojure. В случае ошибки он кидает ответ через throw+. Если код оборачивает HTTP-запрос в try+, вам доступен более тонкий разбор ошибок. Например, отдельные ветки для статуса 500 и негативного ответа в целом.

(require '[clj-http.client :as client])

(try+
 (client/get "http://example.com/test")
 (catch [:status 500] e
   (println "The service is unavailable"))
 (catch [:type :client/unexceptional-status] e
   (println "The response was not 200")))

Подход Slingshot дает несколько преимуществ. Он скрывает внутренний механизм классов и наследования, дает новый слой абстракции. Чем меньше мы прибегаем к взаимодействию с JVM, тем лучше Clojure-код. Невозможно избежать этого полностью, но по крайней мере доступ к JVM становится изолирован.

Библиотека выводит на первое место данные, а не классы. Это хорошая практика, и она всячески поощряется языком. Clojure спроектирована так, что данные занимают в ней первое место.

Slingshot относится к расширенной технике Clojure. Начинающим не очевидны плюсы, которые библиотека несет в проект. Это нормально. Автор советует сначала разобраться со стандартными формами try/catch. Подключайте Slingshot только если вы остро чувствуете в нем потребность.

Приемы и функции

В последнем разделе мы рассмотрим несколько интересных функций. Их объединяет тема исключений. Функции достаточно просты и потому не стоят того, чтобы выносить их в отдельную библиотеку. Как правило, их копируют из проекта в проект с небольшими изменениями. Автор уверен, у каждого Clojure-разработчика найдется что-то подобное.

Безопасный вызов функции. В замечательном языке Lua нет операторов try и catch. Чтобы обезопасить вызов функции от ошибки, используют pcall. Это сокращение от protected call, защищенный вызов.

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

Тот же самое в Clojure:

(defn pcall
  [f & args]
  (try
    [true (apply f args)]
    (catch Exception e
      [false e])))

Чтобы получить доступ к элементам пары, пользуйтесь векторным разбиением в форме let:

(let [[ok? result-error] (pcall inc 1)]
  (if ok?
    (println (str "The result is " result-error))
    (println "Failure")))

Ошибка и результат. В мире JavaScript популярны функции-колбеки. Они выполняются асинхронно и поэтому не прерывают главный тред в случае ошибки. Существует особое соглашение, что коллбек принимает аргументы error и result. Функция pcall-js это измененный вариант pcall, который возвращает пару “ошибка-результат” по этому соглашению.

(defn pcall-js
  [f & args]
  (try
    [nil (apply f args)]
    (catch Exception e
      [e nil])))

Попытки с задержкой. Иногда функция не гарантирует, что отработает успешно. Такое случается, когда мы обращаемся ко внешнему нестабильному сервису. Функция pcall-retry пытается выполнить целевую функцию за несколько попыток. Первый же удачный вызов станет результатом. Если была ошибка, но число попыток еще не превысило порога, то функция ждет небольшой интервал времени и повторяет вызов. Когда все попытки закончились, функция вызывает исключение.

(defn pcall-retry
  [n f & args]
  (loop [attempt n]
    (let [[ok? res] (apply pcall f args)]
      (cond
        ok? res

        (< attempt n)
        (do
          (Thread/sleep (* attempt 1000))
          (recur (inc n)))

        :else
        (throw res)))))

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

Любопытно, что функции выше стыкуются между собой. Чтобы предотвратить выброс исключения из pcall-retry, завернем ее в pcall:

(pcall pcall-retry get-user-by-id 42)

Тогда даже в случае ошибки получим пару [ok? result].

Форма loop заслуживает особого упоминания. Ее дочерняя форма recur не может располагаться внутри try/catch. Пример ниже не просто ошибочный в плане семантики. При при попытке ее скомпилировать вы получите исключение “Can only recur from tail position”:

(defn pcall-retry
  [n f & args]
  (loop [attempt n]
    (try
      (apply pcall f args)
      (catch Exception e
        (recur (inc n))))))

Это один их тех случаев, когда мы не можем использовать try/catch. На помощь приходит pcall и его вариации.

Throw in place. До сих пор мы кидали исключения двумя формами: ex-info и throw. Первая формирует исключение, а вторая его бросает. Логично совместить их в функцию error!. Заодно сделаем часть аргументов необязательными:

(defn error!
  [message & [data e]]
  (throw (ex-info message (or data {}) e)))

Теперь достаточно написать:

(error! "Some error!" {:type ::error})

, чтобы кинуть исключение с нужными данными.

Форматированное сообщение. Иногда нам нужен не контекст, а подробное сообщение об ошибке. В этом случае ex-info избыточен, поскольку без контекста он не имеет смысла. Достаточно выкинуть Exception с форматированным сообщением. В параметры функции передают шаблон и значения для подстановки. В этом и других примерах часть f на конце означает форматирование.

(defn errorf!
  [template & args]
  (let [message (apply format template args)]
    (throw (new Exception ^String message))))

(errorf! "Cannot process a user %s with the action %s" "AAA" :UPDATE)

Безопасный макрос. Примеры выше можно переписать на макросах. Преимущество макросов в том, что они изящней встраиваются в код. С другой стороны, на них нельзя ссылаться как на объект. Макрос нельзя передать в apply, map и другие высшие функции.

Пример ниже показывает, как выполнить часть кода в безопасном режиме, игнорируя ошибку:

(defmacro with-safe
  [& body]
  `(try
     ~@body
     (catch Exception e#)))

Пустая форма catch возвращает nil. Мы получим его, если случится ошибка:

(with-safe (/ 0 0))
nil

Nil может быть положительным результатом кода, который мы передали в макрос. На практике ошибку передают другим значением. Обычно это кейворд :error или :invalid. По такому принципу работает пакет clojure.spec, который мы разобрали в прошлой главе. Если валидация не прошла, результат будет :clojure.spec.alpha/invalid.

В реальности редко бывает так, что нам безразлично, была ошибка или нет. Поэтому на практике макрос дорабатывают: добавляют в форму catch код для записи исключения в лог или отправки в Sentry.

Заключение

Перечислим основные тезисы главы. Clojure использует систему исключений Java. Эта система работает на классах и дереве наследования. Формы try и catch похожи на одноименные операторы Java.

Особая форма finally дает шанс закрыть ресурс в случае ошибки. Чтобы упростить работу с ресурсом, пользуйтесь контекстными менеджерами. Это макросы вида with-<something>. Для доступа к файлам Clojure предлагает макрос with-open.

Класс ExceptionInfo разработан специально для Clojure. Его преимущество в поле data, куда можно записать любой словарь. Функция ex-info упрощает создание этого исключения.

У каждой ошибки может быть причина, cause. Если вы поймали исключение, лучше бросить новое с текущим контекстом и причиной-оригинальным исключением. Так исключения собираются в цепочку.

В приложении должен быть код, который решает, что делать с пойманным исключением. Лучше всего отправить его в единую систему сбора ошибок. Sentry будет хорошим решением. Проследите, что ваш клиент к Sentry передает всю нужную информацию, а не только стек-трейс.

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

Проект Slingshot предлагает улучшенную схему try/catch. При таком подходе мы оперируем не исключениями, а данными.

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

Код этой главы доступен в одном модуле на Гитхабе.